feat(monitoring): add @bytelyst/monitoring package
This commit is contained in:
parent
125eb03745
commit
e9b33fb518
@ -68,6 +68,7 @@ learning_ai_common_plat/
|
||||
| `@bytelyst/design-tokens` | Cross-platform design tokens (JSON → CSS/TS/Kotlin/Swift) | — |
|
||||
| `@bytelyst/extraction` | Extraction service client + shared types | `@bytelyst/api-client` |
|
||||
| `@bytelyst/testing` | Shared test helpers (Fastify inject, schema asserts) | `vitest` |
|
||||
| `@bytelyst/monitoring` | Health-check aggregation utilities | — |
|
||||
|
||||
## Shared Services
|
||||
|
||||
|
||||
@ -533,7 +533,7 @@ The following gaps were identified by scanning every import in the actual codeba
|
||||
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
||||
- [ ] **7.3** Create reusable GitHub Actions workflow templates for service CI
|
||||
- [x] **7.4** Add `@bytelyst/blob` package (extract blob storage client + SAS generation)
|
||||
- [ ] **7.5** Add `@bytelyst/monitoring` package (health check aggregator)
|
||||
- [x] **7.5** Add `@bytelyst/monitoring` package (health check aggregator)
|
||||
- [x] **7.6** Add `@bytelyst/testing` package (shared test utilities, mock factories) — created with 10 tests
|
||||
- [ ] **7.7** Evaluate Python shared package for `cosmos_client.py` + `blob_client.py` if MindLyst adds Python backend
|
||||
- [ ] **7.8** Integrate `@bytelyst/design-tokens` into LysnrAI dashboards (unified design language)
|
||||
@ -557,7 +557,7 @@ The following gaps were identified by scanning every import in the actual codeba
|
||||
| **4** | `@bytelyst/design-tokens` (4 platforms) | 24 | 23 | ✅ CSS synced to MindLyst; CONTRIBUTING updated; visual verify pending |
|
||||
| **5** | CI/CD + Docker (pre-copy strategy) | 23 | 21 | ⚠️ All Dockerfiles rewritten, CI workflows created; Docker build blocked by proxy |
|
||||
| **6** | Verification + docs + cleanup | 28 | 21 | ✅ Docs updated, depcheck done, git clean; E2E needs services |
|
||||
| **7** | Future enhancements (+testing pkg) | 10 | 2 | 🔲 @bytelyst/testing + @bytelyst/blob created |
|
||||
| **7** | Future enhancements (+testing pkg) | 10 | 3 | 🔲 @bytelyst/testing + @bytelyst/blob + @bytelyst/monitoring created |
|
||||
| **Total** | **10 packages (+1 bonus: logger)** | **278** | **251** | **~90% complete** |
|
||||
|
||||
### Bonus Package (not in original roadmap)
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
- 2026-02-14: Added Dependabot config (`.github/dependabot.yml`)
|
||||
- 2026-02-14: Added pre-commit token auto-generation (`lint-staged` for `packages/design-tokens`)
|
||||
- 2026-02-14: Added `@bytelyst/blob` shared package (Blob client helpers + SAS URL generation)
|
||||
- 2026-02-14: Added `@bytelyst/monitoring` shared package (health-check aggregation)
|
||||
|
||||
## Prereqs (Local)
|
||||
|
||||
@ -73,7 +74,7 @@ Publishing + repo hygiene
|
||||
- [ ] **7.2** Add Changesets for automated version management and changelogs
|
||||
- [ ] **7.3** Create reusable GitHub Actions workflow templates for service CI
|
||||
- [x] **7.4** Add `@bytelyst/blob` package (extract blob storage client + SAS generation)
|
||||
- [ ] **7.5** Add `@bytelyst/monitoring` package (health check aggregator)
|
||||
- [x] **7.5** Add `@bytelyst/monitoring` package (health check aggregator)
|
||||
- [ ] **7.7** Evaluate Python shared package for `cosmos_client.py` + `blob_client.py` if MindLyst adds Python backend
|
||||
- [ ] **7.8** Integrate `@bytelyst/design-tokens` into LysnrAI dashboards (unified design language)
|
||||
- [x] **7.9** Add pre-commit hooks to auto-run token generation when JSON changes
|
||||
|
||||
21
packages/monitoring/package.json
Normal file
21
packages/monitoring/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@bytelyst/monitoring",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Health-check aggregation utilities for ByteLyst services and dashboards",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
}
|
||||
}
|
||||
99
packages/monitoring/src/__tests__/monitoring.test.ts
Normal file
99
packages/monitoring/src/__tests__/monitoring.test.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { checkService, generateHealthReport, type ServiceTarget } from '../index.js';
|
||||
|
||||
describe('monitoring', () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('checkService returns healthy for 200', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: 'ok' }),
|
||||
})) as any;
|
||||
|
||||
const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' });
|
||||
expect(res.status).toBe('healthy');
|
||||
expect(res.details).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('checkService returns unhealthy for non-2xx', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
})) as any;
|
||||
|
||||
const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' });
|
||||
expect(res.status).toBe('unhealthy');
|
||||
expect(res.error).toContain('HTTP 503');
|
||||
});
|
||||
|
||||
it('checkService returns unreachable on fetch error', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error('network');
|
||||
}) as any;
|
||||
|
||||
const res = await checkService({ name: 'svc', url: 'http://x', path: '/health' });
|
||||
expect(res.status).toBe('unreachable');
|
||||
expect(res.error).toContain('network');
|
||||
});
|
||||
|
||||
it('generateHealthReport sets overall=down when all unreachable', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error('network');
|
||||
}) as any;
|
||||
|
||||
const services: ServiceTarget[] = [
|
||||
{ name: 'a', url: 'http://a', path: '/health' },
|
||||
{ name: 'b', url: 'http://b', path: '/health' },
|
||||
];
|
||||
|
||||
const report = await generateHealthReport(services, { timeoutMs: 10 });
|
||||
expect(report.overall).toBe('down');
|
||||
expect(report.summary.unreachable).toBe(2);
|
||||
});
|
||||
|
||||
it('generateHealthReport sets overall=degraded when mixed', async () => {
|
||||
const calls = vi.fn(async (url: string) => {
|
||||
if (url.includes('good')) return { ok: true, status: 200, json: async () => ({}) };
|
||||
return { ok: false, status: 500, json: async () => ({}) };
|
||||
});
|
||||
globalThis.fetch = calls as any;
|
||||
|
||||
const services: ServiceTarget[] = [
|
||||
{ name: 'good', url: 'http://good', path: '/health' },
|
||||
{ name: 'bad', url: 'http://bad', path: '/health' },
|
||||
];
|
||||
|
||||
const report = await generateHealthReport(services);
|
||||
expect(report.overall).toBe('degraded');
|
||||
expect(report.summary.healthy).toBe(1);
|
||||
expect(report.summary.unhealthy).toBe(1);
|
||||
});
|
||||
|
||||
it('generateHealthReport sets overall=healthy when all healthy', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
})) as any;
|
||||
|
||||
const services: ServiceTarget[] = [
|
||||
{ name: 'a', url: 'http://a', path: '/health' },
|
||||
{ name: 'b', url: 'http://b', path: '/health' },
|
||||
];
|
||||
|
||||
const report = await generateHealthReport(services);
|
||||
expect(report.overall).toBe('healthy');
|
||||
expect(report.summary.healthy).toBe(2);
|
||||
});
|
||||
});
|
||||
115
packages/monitoring/src/health.ts
Normal file
115
packages/monitoring/src/health.ts
Normal file
@ -0,0 +1,115 @@
|
||||
export interface ServiceTarget {
|
||||
name: string;
|
||||
url: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ServiceCheck {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'healthy' | 'unhealthy' | 'unreachable';
|
||||
responseTimeMs: number;
|
||||
details?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HealthReport {
|
||||
overall: 'healthy' | 'degraded' | 'down';
|
||||
timestamp: string;
|
||||
services: ServiceCheck[];
|
||||
summary: { healthy: number; unhealthy: number; unreachable: number; total: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default service targets (LysnrAI local stack).
|
||||
*/
|
||||
export const DEFAULT_SERVICES: ServiceTarget[] = [
|
||||
{ name: 'Backend API', url: process.env.BACKEND_URL || 'http://localhost:8000', path: '/health' },
|
||||
{
|
||||
name: 'Growth Service',
|
||||
url: process.env.GROWTH_SERVICE_URL || 'http://localhost:4001',
|
||||
path: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Billing Service',
|
||||
url: process.env.BILLING_SERVICE_URL || 'http://localhost:4002',
|
||||
path: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Platform Service',
|
||||
url: process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003',
|
||||
path: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Admin Dashboard',
|
||||
url: process.env.ADMIN_DASHBOARD_URL || 'http://localhost:3001',
|
||||
path: '/api/health',
|
||||
},
|
||||
{
|
||||
name: 'User Dashboard',
|
||||
url: process.env.USER_DASHBOARD_URL || 'http://localhost:3002',
|
||||
path: '/api/health',
|
||||
},
|
||||
];
|
||||
|
||||
export async function checkService(
|
||||
svc: ServiceTarget,
|
||||
opts?: { timeoutMs?: number }
|
||||
): Promise<ServiceCheck> {
|
||||
const fullUrl = `${svc.url}${svc.path}`;
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(fullUrl, { signal: AbortSignal.timeout(opts?.timeoutMs ?? 5_000) });
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
|
||||
if (res.ok) {
|
||||
let details: Record<string, unknown> | undefined;
|
||||
try {
|
||||
details = (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { name: svc.name, url: svc.url, status: 'healthy', responseTimeMs: elapsed, details };
|
||||
}
|
||||
|
||||
return {
|
||||
name: svc.name,
|
||||
url: svc.url,
|
||||
status: 'unhealthy',
|
||||
responseTimeMs: elapsed,
|
||||
error: `HTTP ${res.status}`,
|
||||
};
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
return {
|
||||
name: svc.name,
|
||||
url: svc.url,
|
||||
status: 'unreachable',
|
||||
responseTimeMs: elapsed,
|
||||
error: String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateHealthReport(
|
||||
services: ServiceTarget[] = DEFAULT_SERVICES,
|
||||
opts?: { timeoutMs?: number }
|
||||
): Promise<HealthReport> {
|
||||
const checks = await Promise.all(services.map(svc => checkService(svc, opts)));
|
||||
|
||||
const healthy = checks.filter(c => c.status === 'healthy').length;
|
||||
const unhealthy = checks.filter(c => c.status === 'unhealthy').length;
|
||||
const unreachable = checks.filter(c => c.status === 'unreachable').length;
|
||||
|
||||
let overall: HealthReport['overall'] = 'healthy';
|
||||
if (unreachable === checks.length) overall = 'down';
|
||||
else if (unhealthy > 0 || unreachable > 0) overall = 'degraded';
|
||||
|
||||
return {
|
||||
overall,
|
||||
timestamp: new Date().toISOString(),
|
||||
services: checks,
|
||||
summary: { healthy, unhealthy, unreachable, total: checks.length },
|
||||
};
|
||||
}
|
||||
1
packages/monitoring/src/index.ts
Normal file
1
packages/monitoring/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './health.js';
|
||||
9
packages/monitoring/tsconfig.json
Normal file
9
packages/monitoring/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
5
pnpm-lock.yaml
generated
5
pnpm-lock.yaml
generated
@ -130,6 +130,8 @@ importers:
|
||||
|
||||
packages/logger: {}
|
||||
|
||||
packages/monitoring: {}
|
||||
|
||||
packages/react-auth:
|
||||
dependencies:
|
||||
'@bytelyst/api-client':
|
||||
@ -323,6 +325,9 @@ importers:
|
||||
|
||||
services/monitoring:
|
||||
devDependencies:
|
||||
'@bytelyst/monitoring':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/monitoring
|
||||
'@types/node':
|
||||
specifier: ^22.12.0
|
||||
version: 22.19.11
|
||||
|
||||
@ -18,111 +18,7 @@
|
||||
* USER_DASHBOARD_URL (default: http://localhost:3002)
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
interface ServiceCheck {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'healthy' | 'unhealthy' | 'unreachable';
|
||||
responseTimeMs: number;
|
||||
details?: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface HealthReport {
|
||||
overall: 'healthy' | 'degraded' | 'down';
|
||||
timestamp: string;
|
||||
services: ServiceCheck[];
|
||||
summary: { healthy: number; unhealthy: number; unreachable: number; total: number };
|
||||
}
|
||||
|
||||
const SERVICES = [
|
||||
{ name: 'Backend API', url: process.env.BACKEND_URL || 'http://localhost:8000', path: '/health' },
|
||||
{
|
||||
name: 'Growth Service',
|
||||
url: process.env.GROWTH_SERVICE_URL || 'http://localhost:4001',
|
||||
path: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Billing Service',
|
||||
url: process.env.BILLING_SERVICE_URL || 'http://localhost:4002',
|
||||
path: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Platform Service',
|
||||
url: process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003',
|
||||
path: '/health',
|
||||
},
|
||||
{
|
||||
name: 'Admin Dashboard',
|
||||
url: process.env.ADMIN_DASHBOARD_URL || 'http://localhost:3001',
|
||||
path: '/api/health',
|
||||
},
|
||||
{
|
||||
name: 'User Dashboard',
|
||||
url: process.env.USER_DASHBOARD_URL || 'http://localhost:3002',
|
||||
path: '/api/health',
|
||||
},
|
||||
];
|
||||
|
||||
async function checkService(svc: {
|
||||
name: string;
|
||||
url: string;
|
||||
path: string;
|
||||
}): Promise<ServiceCheck> {
|
||||
const fullUrl = `${svc.url}${svc.path}`;
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(fullUrl, { signal: AbortSignal.timeout(5_000) });
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
|
||||
if (res.ok) {
|
||||
let details: Record<string, unknown> | undefined;
|
||||
try {
|
||||
details = (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { name: svc.name, url: svc.url, status: 'healthy', responseTimeMs: elapsed, details };
|
||||
}
|
||||
return {
|
||||
name: svc.name,
|
||||
url: svc.url,
|
||||
status: 'unhealthy',
|
||||
responseTimeMs: elapsed,
|
||||
error: `HTTP ${res.status}`,
|
||||
};
|
||||
} catch (err) {
|
||||
const elapsed = Math.round(performance.now() - start);
|
||||
return {
|
||||
name: svc.name,
|
||||
url: svc.url,
|
||||
status: 'unreachable',
|
||||
responseTimeMs: elapsed,
|
||||
error: String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport(): Promise<HealthReport> {
|
||||
const checks = await Promise.all(SERVICES.map(checkService));
|
||||
|
||||
const healthy = checks.filter(c => c.status === 'healthy').length;
|
||||
const unhealthy = checks.filter(c => c.status === 'unhealthy').length;
|
||||
const unreachable = checks.filter(c => c.status === 'unreachable').length;
|
||||
|
||||
let overall: HealthReport['overall'] = 'healthy';
|
||||
if (unreachable === checks.length) overall = 'down';
|
||||
else if (unhealthy > 0 || unreachable > 0) overall = 'degraded';
|
||||
|
||||
return {
|
||||
overall,
|
||||
timestamp: new Date().toISOString(),
|
||||
services: checks,
|
||||
summary: { healthy, unhealthy, unreachable, total: checks.length },
|
||||
};
|
||||
}
|
||||
import { DEFAULT_SERVICES, generateHealthReport } from '@bytelyst/monitoring';
|
||||
|
||||
// ── CLI / HTTP server mode ──
|
||||
|
||||
@ -134,18 +30,18 @@ if (args.includes('--serve')) {
|
||||
const PORT = Number(process.env.MONITOR_PORT || 4004);
|
||||
|
||||
const server = createServer(async (_req, res) => {
|
||||
const report = await generateReport();
|
||||
const report = await generateHealthReport(DEFAULT_SERVICES);
|
||||
res.writeHead(report.overall === 'healthy' ? 200 : 503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(report, null, 2));
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`🩺 Monitoring dashboard running on http://localhost:${PORT}`);
|
||||
console.log(` Checking ${SERVICES.length} services every request`);
|
||||
console.log(` Checking ${DEFAULT_SERVICES.length} services every request`);
|
||||
});
|
||||
} else {
|
||||
// One-shot check
|
||||
const report = await generateReport();
|
||||
const report = await generateHealthReport(DEFAULT_SERVICES);
|
||||
const icon = { healthy: '✅', degraded: '⚠️', down: '❌' };
|
||||
console.log(`\n${icon[report.overall]} Overall: ${report.overall.toUpperCase()}\n`);
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"serve": "tsx health-check.ts --serve"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bytelyst/monitoring": "workspace:*",
|
||||
"@types/node": "^22.12.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user