feat(backend): add dependency readiness diagnostics

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:12:23 -07:00
parent 19cc34b172
commit 874dda2f73
7 changed files with 268 additions and 1 deletions

View File

@ -28,6 +28,7 @@
"@bytelyst/fastify-core": "*",
"@bytelyst/field-encrypt": "*",
"@bytelyst/llm": "*",
"@bytelyst/monitoring": "*",
"@bytelyst/palace": "*",
"@bytelyst/logger": "*",
"@bytelyst/webhook-dispatch": "*",

View File

@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { SignJWT } from 'jose';
@ -30,6 +30,10 @@ afterAll(async () => {
await app.close();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function makeToken(role: string) {
return new SignJWT({
sub: 'diagnostics-user',
@ -80,6 +84,27 @@ describe('diagnostics routes', () => {
expect(typeof body.featureFlagsEnabled).toBe('boolean');
});
it('GET /api/diagnostics/readiness returns dependency health summary', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify({ status: 'ok' }), { status: 200 })),
);
const res = await app.inject({ method: 'GET', url: '/api/diagnostics/readiness' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.overall).toBe('ready');
expect(body.summary).toMatchObject({ total: 5, unhealthy: 0, unreachable: 0 });
expect(body.dependencies.map((dep: { name: string }) => dep.name)).toEqual([
'datastore',
'encryption',
'platform-service',
'extraction-service',
'mcp',
]);
});
it('GET /health returns standard health response', async () => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);

View File

@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import type { ServiceCheck } from '@bytelyst/monitoring';
import { getDependencyReadiness } from './dependency-readiness.js';
describe('dependency readiness', () => {
it('reports all production dependencies as ready when local and service checks are healthy', async () => {
const serviceChecker = vi.fn(async target => ({
name: target.name,
url: target.url,
status: 'healthy',
responseTimeMs: 4,
details: { ok: true },
}) satisfies ServiceCheck);
const report = await getDependencyReadiness({ serviceChecker, timeoutMs: 25 });
expect(report.overall).toBe('ready');
expect(report.summary).toMatchObject({ healthy: 5, unhealthy: 0, unreachable: 0, total: 5 });
expect(report.dependencies.map(dep => dep.name)).toEqual([
'datastore',
'encryption',
'platform-service',
'extraction-service',
'mcp',
]);
expect(report.dependencies.find(dep => dep.name === 'mcp')?.details).toMatchObject({
registeredToolsHealthy: true,
});
expect(serviceChecker).toHaveBeenCalledWith(
expect.objectContaining({ name: 'platform-service', path: '/health' }),
{ timeoutMs: 25 },
);
expect(serviceChecker).toHaveBeenCalledWith(
expect.objectContaining({ name: 'extraction-service', path: '/health' }),
{ timeoutMs: 25 },
);
expect(serviceChecker).toHaveBeenCalledWith(
expect.objectContaining({ name: 'mcp-server', path: '/health' }),
{ timeoutMs: 25 },
);
});
it('degrades when a platform dependency is unreachable', async () => {
const serviceChecker = vi.fn(async target => ({
name: target.name,
url: target.url,
status: target.name === 'platform-service' ? 'unreachable' : 'healthy',
responseTimeMs: 6,
error: target.name === 'platform-service' ? 'connection refused' : undefined,
}) satisfies ServiceCheck);
const report = await getDependencyReadiness({ serviceChecker });
expect(report.overall).toBe('degraded');
expect(report.summary.unreachable).toBe(1);
expect(report.dependencies.find(dep => dep.name === 'platform-service')?.error).toBe('connection refused');
});
});

View File

@ -0,0 +1,157 @@
import { checkService, type ServiceCheck, type ServiceTarget } from '@bytelyst/monitoring';
import { getNotesMcpToolsForRegistration } from '../mcp/register-note-tools.js';
import { config } from './config.js';
import { initDatastore } from './datastore.js';
import { getEncryptionReadiness } from './field-encrypt.js';
export type DependencyStatus = ServiceCheck['status'];
export type DependencyCheck = ServiceCheck & {
details?: Record<string, unknown>;
};
export interface DependencyReadinessReport {
overall: 'ready' | 'degraded' | 'down';
timestamp: string;
dependencies: DependencyCheck[];
summary: {
healthy: number;
unhealthy: number;
unreachable: number;
total: number;
};
}
type ServiceChecker = (target: ServiceTarget, opts?: { timeoutMs?: number }) => Promise<ServiceCheck>;
function elapsedSince(start: number): number {
return Math.round(performance.now() - start);
}
function normalizeServiceBaseUrl(url: string): string {
const trimmed = url.replace(/\/+$/, '');
return trimmed.endsWith('/api') ? trimmed.slice(0, -4) : trimmed;
}
function summarize(checks: DependencyCheck[]): DependencyReadinessReport['summary'] {
return {
healthy: checks.filter(check => check.status === 'healthy').length,
unhealthy: checks.filter(check => check.status === 'unhealthy').length,
unreachable: checks.filter(check => check.status === 'unreachable').length,
total: checks.length,
};
}
function overallFrom(summary: DependencyReadinessReport['summary']): DependencyReadinessReport['overall'] {
if (summary.healthy === summary.total) {
return 'ready';
}
if (summary.healthy === 0) {
return 'down';
}
return 'degraded';
}
export async function checkDatastoreReadiness(): Promise<DependencyCheck> {
const start = performance.now();
try {
const healthy = await initDatastore().isHealthy();
return {
name: 'datastore',
url: config.DB_PROVIDER,
status: healthy ? 'healthy' : 'unhealthy',
responseTimeMs: elapsedSince(start),
details: { provider: config.DB_PROVIDER },
...(healthy ? {} : { error: 'Datastore provider reported unhealthy' }),
};
} catch (error) {
return {
name: 'datastore',
url: config.DB_PROVIDER,
status: 'unreachable',
responseTimeMs: elapsedSince(start),
error: error instanceof Error ? error.message : String(error),
details: { provider: config.DB_PROVIDER },
};
}
}
export async function checkEncryptionReadiness(): Promise<DependencyCheck> {
const start = performance.now();
const readiness = getEncryptionReadiness();
return {
name: 'encryption',
url: readiness.keyProvider,
status: readiness.ready ? 'healthy' : 'unhealthy',
responseTimeMs: elapsedSince(start),
details: {
enabled: readiness.enabled,
keyProvider: readiness.keyProvider,
},
...(readiness.ready ? {} : { error: 'Field encryption is not ready' }),
};
}
async function checkHttpDependency(
checker: ServiceChecker,
target: ServiceTarget,
timeoutMs: number,
): Promise<DependencyCheck> {
return checker(target, { timeoutMs });
}
async function checkMcpReadiness(
checker: ServiceChecker,
timeoutMs: number,
): Promise<DependencyCheck> {
const baseUrl = normalizeServiceBaseUrl(config.MCP_SERVER_URL);
const tools = getNotesMcpToolsForRegistration();
const serviceCheck = await checker({ name: 'mcp-server', url: baseUrl, path: '/health' }, { timeoutMs });
const hasTools = tools.length > 0;
const status: DependencyStatus =
serviceCheck.status === 'healthy' && hasTools ? 'healthy' : serviceCheck.status;
return {
...serviceCheck,
name: 'mcp',
status: hasTools ? status : 'unhealthy',
details: {
...(serviceCheck.details ?? {}),
registeredToolCount: tools.length,
registeredToolsHealthy: hasTools,
},
...(!hasTools ? { error: 'No NoteLett MCP tools are registered locally' } : {}),
};
}
export async function getDependencyReadiness(options: {
timeoutMs?: number;
serviceChecker?: ServiceChecker;
} = {}): Promise<DependencyReadinessReport> {
const timeoutMs = options.timeoutMs ?? 1500;
const serviceChecker = options.serviceChecker ?? checkService;
const dependencies = await Promise.all([
checkDatastoreReadiness(),
checkEncryptionReadiness(),
checkHttpDependency(
serviceChecker,
{ name: 'platform-service', url: normalizeServiceBaseUrl(config.PLATFORM_SERVICE_URL), path: '/health' },
timeoutMs,
),
checkHttpDependency(
serviceChecker,
{ name: 'extraction-service', url: normalizeServiceBaseUrl(config.EXTRACTION_SERVICE_URL), path: '/health' },
timeoutMs,
),
checkMcpReadiness(serviceChecker, timeoutMs),
]);
const summary = summarize(dependencies);
return {
overall: overallFrom(summary),
timestamp: new Date().toISOString(),
dependencies,
summary,
};
}

View File

@ -1,5 +1,6 @@
import { requireRole } from './auth.js';
import { config } from './config.js';
import { getDependencyReadiness } from './dependency-readiness.js';
import { getAllFlags } from './feature-flags.js';
import { PRODUCT_ID } from './product-config.js';
import { flushEvents, getBufferedEvents } from './telemetry.js';
@ -50,4 +51,9 @@ export async function diagnosticsRoutes(app: DiagnosticsRouteApp) {
featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED,
};
});
app.get('/api/diagnostics/readiness', async req => {
await assertDiagnosticsAccess(req);
return getDependencyReadiness();
});
}

View File

@ -50,6 +50,23 @@ export function getEncryptor(): FieldEncryptor {
return _encryptor;
}
export function getEncryptionReadiness(): {
enabled: boolean;
keyProvider: typeof config.FIELD_ENCRYPT_KEY_PROVIDER;
ready: boolean;
} {
const keyReady =
!_enabled ||
config.FIELD_ENCRYPT_KEY_PROVIDER !== 'env' ||
config.FIELD_ENCRYPT_KEY.trim().length > 0;
return {
enabled: _enabled,
keyProvider: config.FIELD_ENCRYPT_KEY_PROVIDER,
ready: _enabled && keyReady,
};
}
/** @internal — for testing only. */
export function _resetEncryptor(): void {
_encryptor = null;

3
pnpm-lock.yaml generated
View File

@ -719,6 +719,9 @@ importers:
'@bytelyst/logger':
specifier: '*'
version: link:../../learning_ai/learning_ai_common_plat/packages/logger
'@bytelyst/monitoring':
specifier: '*'
version: link:../../learning_ai/learning_ai_common_plat/packages/monitoring
'@bytelyst/palace':
specifier: '*'
version: link:../../learning_ai/learning_ai_common_plat/packages/palace