feat(backend): add dependency readiness diagnostics
This commit is contained in:
parent
19cc34b172
commit
874dda2f73
@ -28,6 +28,7 @@
|
||||
"@bytelyst/fastify-core": "*",
|
||||
"@bytelyst/field-encrypt": "*",
|
||||
"@bytelyst/llm": "*",
|
||||
"@bytelyst/monitoring": "*",
|
||||
"@bytelyst/palace": "*",
|
||||
"@bytelyst/logger": "*",
|
||||
"@bytelyst/webhook-dispatch": "*",
|
||||
|
||||
@ -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);
|
||||
|
||||
58
backend/src/lib/dependency-readiness.test.ts
Normal file
58
backend/src/lib/dependency-readiness.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
157
backend/src/lib/dependency-readiness.ts
Normal file
157
backend/src/lib/dependency-readiness.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user