feat(monitoring): add @bytelyst/monitoring package

This commit is contained in:
Saravana Achu Mac 2026-02-14 15:57:41 -08:00
parent 125eb03745
commit e9b33fb518
11 changed files with 260 additions and 111 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View 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"
}
}

View 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);
});
});

View 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 },
};
}

View File

@ -0,0 +1 @@
export * from './health.js';

View 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
View File

@ -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

View File

@ -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`);

View File

@ -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"