From e9b33fb518f473f08436ff6e8a10b72b829c5fab Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 14 Feb 2026 15:57:41 -0800 Subject: [PATCH] feat(monitoring): add @bytelyst/monitoring package --- README.md | 1 + docs/ROADMAP.md | 4 +- .../ALL_OTHER_WORKSTREAMS_REMAINING.md | 3 +- packages/monitoring/package.json | 21 ++++ .../src/__tests__/monitoring.test.ts | 99 +++++++++++++++ packages/monitoring/src/health.ts | 115 ++++++++++++++++++ packages/monitoring/src/index.ts | 1 + packages/monitoring/tsconfig.json | 9 ++ pnpm-lock.yaml | 5 + services/monitoring/health-check.ts | 112 +---------------- services/monitoring/package.json | 1 + 11 files changed, 260 insertions(+), 111 deletions(-) create mode 100644 packages/monitoring/package.json create mode 100644 packages/monitoring/src/__tests__/monitoring.test.ts create mode 100644 packages/monitoring/src/health.ts create mode 100644 packages/monitoring/src/index.ts create mode 100644 packages/monitoring/tsconfig.json diff --git a/README.md b/README.md index c547acfe..745c77b2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 11a40e37..a6446a6b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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) diff --git a/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md b/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md index fa2a2a58..6d8f40a4 100644 --- a/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md +++ b/docs/workstreams/ALL_OTHER_WORKSTREAMS_REMAINING.md @@ -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 diff --git a/packages/monitoring/package.json b/packages/monitoring/package.json new file mode 100644 index 00000000..99883e46 --- /dev/null +++ b/packages/monitoring/package.json @@ -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" + } +} diff --git a/packages/monitoring/src/__tests__/monitoring.test.ts b/packages/monitoring/src/__tests__/monitoring.test.ts new file mode 100644 index 00000000..4bb3e624 --- /dev/null +++ b/packages/monitoring/src/__tests__/monitoring.test.ts @@ -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); + }); +}); diff --git a/packages/monitoring/src/health.ts b/packages/monitoring/src/health.ts new file mode 100644 index 00000000..94e2e48d --- /dev/null +++ b/packages/monitoring/src/health.ts @@ -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; + 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 { + 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 | undefined; + try { + details = (await res.json()) as Record; + } 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 { + 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 }, + }; +} diff --git a/packages/monitoring/src/index.ts b/packages/monitoring/src/index.ts new file mode 100644 index 00000000..9d719a55 --- /dev/null +++ b/packages/monitoring/src/index.ts @@ -0,0 +1 @@ +export * from './health.js'; diff --git a/packages/monitoring/tsconfig.json b/packages/monitoring/tsconfig.json new file mode 100644 index 00000000..5edad813 --- /dev/null +++ b/packages/monitoring/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af4bc4b4..a79cb6fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/services/monitoring/health-check.ts b/services/monitoring/health-check.ts index 400fc30c..aa962084 100644 --- a/services/monitoring/health-check.ts +++ b/services/monitoring/health-check.ts @@ -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; - 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 { - 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 | undefined; - try { - details = (await res.json()) as Record; - } 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 { - 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`); diff --git a/services/monitoring/package.json b/services/monitoring/package.json index d6b6edb0..8ee9bd02 100644 --- a/services/monitoring/package.json +++ b/services/monitoring/package.json @@ -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"