diff --git a/README.md b/README.md index 2edf2c9..3cd98f8 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,25 @@ -# 🛠️ proactive-deps +# proactive-deps -Proactive Dependency Checks for Node.js Projects +Lightweight, cached, proactive dependency health checks for Node.js services. -`proactive-deps` is a lightweight Node.js library that makes it easy to monitor the health of your app’s runtime dependencies. It lets you define custom async checks for critical external services—like databases, APIs, queues, etc.—and provides real-time status tracking, latency metrics, and Prometheus-style exports. +Define async checks (DBs, REST APIs, queues, etc.), get structured status + latency, and expose Prometheus metrics (via `prom-client`) out-of-the-box. -## 🔍 Why Use This? +## Features -Long-running services often depend on external systems, and when those go down, it can cause confusing or delayed failures. proactive-deps helps you proactively detect issues before they become full outages—without adding brittle health check logic to your app’s core business logic. +- Simple registration of dependency checks +- Per‑dependency TTL + refresh threshold (cache-manager under the hood) +- Latency + health gauges for Prometheus +- Optional collection of Node default metrics +- Skippable checks (e.g. for local dev / disabled services) +- TypeScript first (ships types) -## 🚀 Features - -- ✅ Custom async health checks per dependency -- 🧠 Smart result caching (set TTL per dependency) -- 📈 Built-in latency tracking -- 📊 Prometheus-style metrics export -- 🧪 Live status summaries for dashboards or alerts - -## 📦 Installation +## Install ```bash npm install proactive-deps ``` -## ⚙️ Usage - -#### Starting and Stopping the Dependency Check Interval - -Once you have registered your dependencies, you must call `monitor.startDependencyCheckInterval()` to start the automated interval that periodically checks the status of all registered dependencies. This ensures that the health of your dependencies is monitored continuously at the configured interval. - -If you need to stop the automated checks (e.g., during application shutdown or maintenance), you can call `monitor.stopDependencyCheckInterval()` to stop the interval. - -### Registering a Dependency +## Quick Start ```js import { @@ -39,12 +28,18 @@ import { ERROR_STATUS_CODE, } from 'proactive-deps'; -const monitor = new DependencyMonitor(); +const monitor = new DependencyMonitor({ + // Optional: turn on prom-client default metrics & tweak intervals + collectDefaultMetrics: true, + checkIntervalMs: 15000, + cacheDurationMs: 60000, + refreshThresholdMs: 5000, +}); monitor.register({ name: 'redis', - description: 'Redis cache layer', - impact: 'Responses may be slower due to missing cache.', + description: 'Redis cache', + impact: 'Responses may be slower (cache miss path).', check: async () => { try { // Simulate a health check (e.g., ping Redis) @@ -58,8 +53,8 @@ monitor.register({ }; // Unhealthy status with error details } }, - cacheDurationMs: 10000, // Cache results for 10 seconds - refreshThresholdMs: 5000, // Refresh results if older than 5 seconds + cacheDurationMs: 10000, // (optional) override default cache TTL + refreshThresholdMs: 5000, // (optional) pre-emptive refresh window checkDetails: { type: 'database', server: 'localhost', @@ -71,18 +66,7 @@ monitor.register({ monitor.startDependencyCheckInterval(); ``` -### Using the `skip` Boolean - -The `skip` boolean allows you to mark a dependency as skipped, meaning its health check will not be executed. Skipped dependencies are considered healthy by default, with a latency of `0` and the `skipped` flag set to `true`. - -#### Why Would You Want to Skip a Dependency? - -There are several scenarios where skipping a dependency might be useful: - -- **Temporarily Disabled Services**: If a service is temporarily offline or not in use, you can skip its health check to avoid unnecessary errors or alerts. -- **Development or Testing**: During development or testing, you might want to skip certain dependencies that are not yet implemented or are mocked. - -#### Example with a Skipped Dependency +### Skipping a Dependency ```js monitor.register({ @@ -97,33 +81,7 @@ monitor.register({ }); ``` -When a dependency is skipped, its status will look like this: - -```js -{ - name: 'external-service', - description: 'An external service that is temporarily disabled', - impact: 'No impact since this service is currently unused.', - healthy: true, - health: { - state: 'OK', - code: 0, - latency: 0, - skipped: true, - }, - lastChecked: '2025-04-13T12:00:00Z', -} -``` - -### Why Use `checkDetails`? - -The `checkDetails` property allows you to provide additional metadata about the dependency being monitored. This can be useful for: - -- **Debugging**: Including details like the server, database name, or API endpoint can help quickly identify the source of an issue. -- **Monitoring Dashboards**: Exposing `checkDetails` in status summaries or metrics can provide more context for operations teams. -- **Custom Alerts**: Use `checkDetails` to include specific information in alerts, such as the type of dependency or its criticality. - -#### Example with REST API Dependency +### REST API Example ```js monitor.register({ @@ -157,49 +115,16 @@ monitor.register({ }); ``` -### What Should a Dependency Check Return? +### Return Shape -A registered dependency check can return either a status code or an object with additional details. +Checker returns either: -#### When Healthy: +- `SUCCESS_STATUS_CODE` (number) or +- `{ code, error?, errorMessage? }` -You can return just the status code: +`skip: true` short‑circuits to an OK result with `latency: 0` and `skipped: true`. -```js -SUCCESS_STATUS_CODE; -``` - -Or an object with the status code: - -```js -{ - code: SUCCESS_STATUS_CODE, -} -``` - -- `code`: A status code indicating success (e.g., `SUCCESS_STATUS_CODE`). -- `error`: Should be `undefined` when the dependency is healthy. -- `errorMessage`: Should be `undefined` when the dependency is healthy. - -#### When Errors Are Encountered: - -You can return an object with the status code and optional error details: - -```js -{ - code: ERROR_STATUS_CODE, - error: new Error('Connection failed'), - errorMessage: 'Redis connection failed', -} -``` - -- `code`: A status code indicating an error (e.g., `ERROR_STATUS_CODE`). -- `error`: An `Error` object describing the issue. -- `errorMessage`: A string describing the error in detail. - -This flexibility allows you to return a simple status code for healthy dependencies or provide detailed error information when issues are encountered. The structure ensures consistency across all dependency checks and allows the monitor to handle and report errors effectively. - -### Getting Current Status +### Fetch All Statuses ```js const statuses = await monitor.getAllStatuses(); @@ -222,7 +147,7 @@ console.log(statuses); // ]; ``` -### Getting the Status of a Specific Dependency +### Single Dependency ```js const status = await monitor.getStatus('redis'); @@ -243,43 +168,55 @@ console.log(status); // } ``` -### Prometheus Metrics Output +## Prometheus Metrics + +The monitor lazily initializes `prom-client` gauges (or uses the provided registry): + +- `dependency_latency_ms{dependency}` – last check latency (ms) +- `dependency_health{dependency,impact}` – health state (0 OK, 1 WARNING, 2 CRITICAL) + +Enable default Node metrics by passing `collectDefaultMetrics: true` to the constructor. ```js const metrics = await monitor.getPrometheusMetrics(); console.log(metrics); /* -# HELP dependency_latency_ms Latency of dependency checks in milliseconds +# HELP dependency_latency_ms Last dependency check latency in milliseconds # TYPE dependency_latency_ms gauge dependency_latency_ms{dependency="redis"} 5 -# HELP dependency_health Whether the dependency is currently healthy (0 = healthy, 1 = unhealthy) +# HELP dependency_health Dependency health status (0=OK,1=WARNING,2=CRITICAL) # TYPE dependency_health gauge -dependency_health{dependency="redis", impact="Responses may be slower due to missing cache."} 0 +dependency_health{dependency="redis",impact="Responses may be slower (cache miss path)."} 0 */ ``` -## 📖 API Documentation +### Example Server (PokeAPI Demo) -For detailed API documentation, refer to the [docs](https://dantheuber.github.io/proactive-deps). +See `example/server.js` for a pure Node HTTP server exposing: -## 🧠 Philosophy +- `/pokemon/:name` – live pass‑through to PokeAPI +- `/dependencies` – JSON array of current statuses +- `/metrics` – Prometheus text output -Other tools might let you know that a dependency was broken when you find out the hard way. `proactive-deps` helps you know in advance, by making it dead simple to wrap, register, and expose active health checks for the services your app relies on. +Run it locally: -## 🧪 Ideal Use Cases +```bash +npm run build +node example/server.js +``` -- Embedding in HTTP services to power `/health` or `/metrics` endpoints -- Scheduled checks that alert on failure via cron or background workers -- Internal monitoring dashboards for systems that depend on flaky external services +## API Docs + +For detailed API documentation, refer to the [docs](https://dantheuber.github.io/proactive-deps). -## 🛣️ Future Plans +## Roadmap (abridged) -- [ ] Built-in Prometheus metrics endpoint handler -- [ ] Retry logic with exponential backoff -- [ ] Custom alert hooks (email, Slack, etc.) -- [ ] Custom cache stores. +- Built-in helper for common /metrics endpoint +- Optional retry / backoff helpers +- Alert hooks (Slack, email) +- Pluggable cache stores -## 📄 License +## License MIT © 2025 Daniel Essig diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..eaaab8e --- /dev/null +++ b/example/README.md @@ -0,0 +1,32 @@ +# Example Server + +A minimal HTTP example demonstrating how to use **proactive-deps** to monitor an external REST API (PokeAPI). + +## Features + +- Periodic dependency check of the public https://pokeapi.co API (registered as `PokeAPI`). +- Simple Pokémon data endpoint: `GET /pokemon/:name`. +- Dependency status endpoint: `GET /dependencies`. +- Prometheus metrics endpoint: `GET /metrics` (includes latency and health gauges + default Node metrics). + +## Run Locally + +```bash +npm install +npm run build # builds proactive-deps so example can import the dist output +node example/server.js +``` + +Then in another shell: + +```bash +curl http://localhost:3000/pokemon/ditto +curl http://localhost:3000/dependencies +curl http://localhost:3000/metrics +``` + +## Notes + +- The example avoids Express to keep it lightweight. +- If you modify library source while the server runs, rebuild to reflect changes. +- The dependency monitor caches results for 30s and refreshes every 10s in this demo. diff --git a/example/server.js b/example/server.js new file mode 100644 index 0000000..44f1889 --- /dev/null +++ b/example/server.js @@ -0,0 +1,178 @@ +/* + Simple HTTP server example using proactive-deps to monitor the PokeAPI. + + Endpoints: + GET /pokemon/:name -> Fetches live data from https://pokeapi.co/api/v2/pokemon/:name + GET /dependencies -> Returns the current dependency check statuses (from proactive-deps cache) + GET /metrics -> Prometheus metrics for the dependency monitor (latency & health) + GET / -> Basic help text + + Usage: + 1. Build the library first so the root package exports are available (dist/): + npm install + npm run build + 2. Start the example server: + node example/server.js + 3. Try it: + curl http://localhost:3000/pokemon/ditto + curl http://localhost:3000/dependencies + curl http://localhost:3000/metrics + + Notes: + - This example uses only Node built‑ins (no Express) to keep dependencies minimal. + - If you want live reloading or TS in the example, you can adapt it to use ts-node / nodemon. +*/ + +const http = require('http'); +const { URL } = require('url'); +let proactive; +try { + // Prefer built version (dist) when available + proactive = require('..'); +} catch (e) { + // Fallback to source for local dev prior to build + proactive = require('../src'); +} + +const { DependencyMonitor, SUCCESS_STATUS_CODE, ERROR_STATUS_CODE } = proactive; + +const monitor = new DependencyMonitor({ + // Collect default metrics (optional) + collectDefaultMetrics: true, + // Faster interval for demo purposes + checkIntervalMs: 10000, + cacheDurationMs: 30000, + refreshThresholdMs: 5000, +}); + +// Register a dependency check for the public PokeAPI +monitor.register({ + name: 'PokeAPI', + description: 'Public Pokémon REST API', + impact: 'Pokémon data cannot be fetched for users', + checkDetails: { + type: 'rest', + url: 'https://pokeapi.co/api/v2/pokemon/pikachu', + method: 'GET', + expectedStatusCode: 200, + }, + async check() { + try { + const res = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu'); + if (res.ok) { + return SUCCESS_STATUS_CODE; + } + return { + code: ERROR_STATUS_CODE, + errorMessage: `Unexpected status code ${res.status}`, + }; + } catch (error) { + return { + code: ERROR_STATUS_CODE, + error, + errorMessage: 'Error calling PokeAPI', + }; + } + }, +}); + +monitor.startDependencyCheckInterval(); + +function sendJson(res, statusCode, body) { + const json = JSON.stringify(body, null, 2); + res.writeHead(statusCode, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + }); + res.end(json); +} + +async function handlePokemonRequest(res, name) { + try { + const response = await fetch( + `https://pokeapi.co/api/v2/pokemon/${encodeURIComponent(name)}`, + ); + if (!response.ok) { + sendJson(res, response.status, { + error: `PokeAPI returned status ${response.status}`, + }); + return; + } + const data = await response.json(); + // Return a trimmed subset of data for brevity + const subset = { + name: data.name, + id: data.id, + height: data.height, + weight: data.weight, + base_experience: data.base_experience, + types: data.types.map((t) => t.type.name), + abilities: data.abilities.map((a) => a.ability.name), + }; + sendJson(res, 200, subset); + } catch (error) { + sendJson(res, 502, { + error: 'Failed to fetch from PokeAPI', + details: error.message, + }); + } +} + +const server = http.createServer(async (req, res) => { + const method = req.method || 'GET'; + const urlObj = new URL(req.url, `http://${req.headers.host}`); + const path = urlObj.pathname || '/'; + + // Simple routing + if (method === 'GET' && path === '/') { + sendJson(res, 200, { + message: 'Proactive Deps Example Server', + routes: ['/pokemon/:name', '/dependencies', '/metrics'], + }); + return; + } + + if (method === 'GET' && path.startsWith('/pokemon/')) { + const name = path.split('/')[2]; + if (!name) { + sendJson(res, 400, { error: 'Missing Pokémon name' }); + return; + } + await handlePokemonRequest(res, name); + return; + } + + if (method === 'GET' && path === '/dependencies') { + try { + const statuses = await monitor.getAllStatuses(); + sendJson(res, 200, statuses); + } catch (error) { + sendJson(res, 500, { + error: 'Unable to fetch dependency statuses', + details: error.message, + }); + } + return; + } + + if (method === 'GET' && path === '/metrics') { + try { + const metrics = await monitor.getPrometheusMetrics(); + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(metrics); + } catch (error) { + sendJson(res, 500, { + error: 'Unable to render Prometheus metrics', + details: error.message, + }); + } + return; + } + + sendJson(res, 404, { error: 'Not found' }); +}); + +const PORT = process.env.PORT || 3000; +server.listen(PORT, () => { + console.log(`Example server listening on http://localhost:${PORT}`); +}); diff --git a/package-lock.json b/package-lock.json index 84e7e82..d152ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "proactive-deps", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proactive-deps", - "version": "1.2.2", + "version": "1.3.0", "license": "MIT", "dependencies": { - "cache-manager": "^6.4.2" + "cache-manager": "^6.4.2", + "prom-client": "^15.1.3" }, "devDependencies": { "@types/jest": "^29.5.14", @@ -966,6 +967,15 @@ "buffer": "^6.0.3" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@shikijs/engine-oniguruma": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.2.2.tgz", @@ -1409,6 +1419,12 @@ ], "license": "MIT" }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3596,6 +3612,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -3897,6 +3926,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index 65db7af..2a1fa71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proactive-deps", - "version": "1.2.2", + "version": "1.3.0", "description": "A library for managing proactive dependency checks in Node.js applications.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -52,7 +52,8 @@ "typescript": "^5.8.3" }, "dependencies": { - "cache-manager": "^6.4.2" + "cache-manager": "^6.4.2", + "prom-client": "^15.1.3" }, "files": [ "dist/**/*" diff --git a/src/monitor.ts b/src/monitor.ts index eb7e7c4..97ebcc3 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -1,4 +1,6 @@ import { createCache, Cache as CacheManagerCache } from 'cache-manager'; +import promClient from 'prom-client'; +import type { Registry, Gauge } from 'prom-client'; import { DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CACHE_DURATION_MS, @@ -13,7 +15,6 @@ import { DependencyStatus, } from './types'; import formatCheckResult from './lib/format-check-result'; -import formatPrometheusMetrics from './lib/format-prometheus-metrics'; /** * DependencyMonitor is a class that monitors the status of various dependencies @@ -30,6 +31,13 @@ class DependencyMonitor implements DependencyMonitorInterface { private _refreshThresholdMs: number = DEFAULT_REFRESH_THRESHOLD_MS; private _cacheDurationMs: number = DEFAULT_CACHE_DURATION_MS; private _checkIntervalMs: number = DEFAULT_CHECK_INTERVAL_MS; + // Prometheus registry (created lazily if not provided via options) + private _registry?: Registry; + // Gauges for dependency latency and health. Label generics reflect configured label names. + private _latencyGauge?: Gauge<'dependency'>; + private _healthGauge?: Gauge<'dependency' | 'impact'>; + private _metricsInitialized = false; + private _collectDefaultMetrics = false; public checkIntervalStarted: boolean = false; @@ -56,6 +64,12 @@ class DependencyMonitor implements DependencyMonitorInterface { ttl: this._cacheDurationMs, refreshThreshold: this._refreshThresholdMs, }); + + // prometheus options + this._registry = options.registry; // may be undefined; created during _initPromClient + this._collectDefaultMetrics = !!options.collectDefaultMetrics; + // Eagerly initialize metrics so they are always available + this._initPromClient(); } public startDependencyCheckInterval(): void { @@ -96,7 +110,9 @@ class DependencyMonitor implements DependencyMonitorInterface { const start = Date.now(); const checkResults = await dependency.check(); const latencyMs = Date.now() - start; - return formatCheckResult(dependency, checkResults, latencyMs); + const status = formatCheckResult(dependency, checkResults, latencyMs); + this._updateMetrics(status); + return status; }, dependency.cacheDurationMs || this._cacheDurationMs, dependency.refreshThresholdMs || this._refreshThresholdMs, @@ -112,6 +128,7 @@ class DependencyMonitor implements DependencyMonitorInterface { status, dependency.cacheDurationMs, ); + this._updateMetrics(status); return status; } } @@ -146,8 +163,68 @@ class DependencyMonitor implements DependencyMonitorInterface { } public async getPrometheusMetrics(): Promise { - const statuses = await this._getAllDependenciesStatus(); - return formatPrometheusMetrics(statuses); + await this._getAllDependenciesStatus(); // ensure gauges updated before render + this._initPromClient(); + return this._registry!.metrics(); + } + + /** + * Returns the underlying prom-client Registry if initialized. + */ + public getPrometheusRegistry() { + this._initPromClient(); + return this._registry; + } + + private _initPromClient(): boolean { + if (this._metricsInitialized) return true; + if (!this._registry) { + this._registry = new promClient.Registry(); + if (this._collectDefaultMetrics) { + promClient.collectDefaultMetrics({ + register: this._registry, + }); + } + } + + const latencyName = 'dependency_latency_ms'; + const healthName = 'dependency_health'; + + const existingLatency = this._registry.getSingleMetric(latencyName) as + | Gauge<'dependency'> + | undefined; + this._latencyGauge = + existingLatency || + new promClient.Gauge<'dependency'>({ + name: latencyName, + help: 'Last dependency check latency in milliseconds', + labelNames: ['dependency'], + registers: [this._registry], + }); + + const existingHealth = this._registry.getSingleMetric(healthName) as + | Gauge<'dependency' | 'impact'> + | undefined; + this._healthGauge = + existingHealth || + new promClient.Gauge<'dependency' | 'impact'>({ + name: healthName, + help: 'Dependency health status (0=OK,1=WARNING,2=CRITICAL)', + labelNames: ['dependency', 'impact'], + registers: [this._registry], + }); + + this._metricsInitialized = true; + return true; + } + + private _updateMetrics(status: DependencyStatus) { + this._initPromClient(); + const { name, impact, health } = status; + const valueMap: Record = { OK: 0, WARNING: 1, CRITICAL: 2 }; + const value = valueMap[health.state]; + this._latencyGauge!.set({ dependency: name }, health.latency); + this._healthGauge!.set({ dependency: name, impact: impact }, value); } } diff --git a/src/types/dependency-monitor-interface.ts b/src/types/dependency-monitor-interface.ts index 84889cf..a964f08 100644 --- a/src/types/dependency-monitor-interface.ts +++ b/src/types/dependency-monitor-interface.ts @@ -82,4 +82,9 @@ export interface DependencyMonitorInterface { * // dependency_latency{dependency="Database"} 50 */ getPrometheusMetrics(): Promise; + /** + * Returns the underlying prom-client Registry used for metrics. + * Metrics are always enabled; this method never returns undefined. + */ + getPrometheusRegistry(): any; } diff --git a/src/types/dependency-monitor-options.ts b/src/types/dependency-monitor-options.ts index 999904d..edaeed4 100644 --- a/src/types/dependency-monitor-options.ts +++ b/src/types/dependency-monitor-options.ts @@ -32,4 +32,12 @@ export type DependencyMonitorOptions = { * @default 15000 */ checkIntervalMs?: number; + /** + * Optional existing prom-client Registry instance to register metrics with. If omitted a new Registry is created. + */ + registry?: import('prom-client').Registry; + /** + * When true and a new Registry is created internally, default process metrics will also be collected. + */ + collectDefaultMetrics?: boolean; }; diff --git a/test/integration/mock-server.integration.test.ts b/test/integration/mock-server.integration.test.ts new file mode 100644 index 0000000..6359ece --- /dev/null +++ b/test/integration/mock-server.integration.test.ts @@ -0,0 +1,88 @@ +import { + DependencyMonitor, + SUCCESS_STATUS_CODE, + ERROR_STATUS_CODE, +} from '../../src'; +import { createMockServer } from './mock-server'; + +/** + * Basic integration test exercising the mock server with proactive-deps. + */ + +describe('DependencyMonitor integration with mock server', () => { + const mock = createMockServer(); + let port: number; + const monitor = new DependencyMonitor({ + collectDefaultMetrics: false, + checkIntervalMs: 200, // fast for test + cacheDurationMs: 500, + refreshThresholdMs: 100, + }); + + beforeAll(async () => { + port = await mock.start(); + monitor.register({ + name: 'mock-api', + description: 'Local mock dependency', + impact: 'Feature X degraded', + checkDetails: { + type: 'rest', + url: `http://localhost:${port}/dynamic`, + method: 'GET', + expectedStatusCode: 200, + }, + async check() { + try { + const res = await fetch(`http://localhost:${port}/dynamic`); + if (res.ok) return SUCCESS_STATUS_CODE; + return { + code: ERROR_STATUS_CODE, + errorMessage: `status ${res.status}`, + }; + } catch (error) { + return { + code: ERROR_STATUS_CODE, + error: error as Error, + errorMessage: 'fetch failed', + }; + } + }, + }); + monitor.startDependencyCheckInterval(); + }); + + afterAll(async () => { + monitor.stopDependencyCheckInterval(); + await mock.stop(); + }); + + test('reports OK state initially', async () => { + // Ensure at least one interval pass + await new Promise((r) => setTimeout(r, 350)); + const status = await monitor.getStatus('mock-api'); + expect(status.healthy).toBe(true); + expect(status.health.code).toBe(SUCCESS_STATUS_CODE); + + // Metrics should reflect OK (health gauge = 0) + const metrics = await monitor.getPrometheusMetrics(); + expect(metrics).toMatch(/dependency_latency_ms{dependency="mock-api"} \d+/); + expect(metrics).toMatch( + /dependency_health{dependency="mock-api",impact="Feature X degraded"} 0(\.\d+)?/, + ); + }); + + test('reports CRITICAL when server set to error', async () => { + mock.setMode('error'); + // wait for interval + cache refresh + await new Promise((r) => setTimeout(r, 400)); + const status = await monitor.getStatus('mock-api'); + expect(status.healthy).toBe(false); + expect(status.health.code).toBe(ERROR_STATUS_CODE); + + // Metrics should now reflect CRITICAL (health gauge = 2) + const metrics = await monitor.getPrometheusMetrics(); + expect(metrics).toMatch( + /dependency_health{dependency="mock-api",impact="Feature X degraded"} 2(\.\d+)?/, + ); + }); +}); diff --git a/test/integration/mock-server.ts b/test/integration/mock-server.ts new file mode 100644 index 0000000..55e2fd4 --- /dev/null +++ b/test/integration/mock-server.ts @@ -0,0 +1,120 @@ +/** + * Simple HTTP mock server used in integration tests. + * Exposes endpoints that simulate healthy, warning, and error states. + * + * Routes: + * GET /health/ok -> 200 { status: 'ok' } + * GET /health/slow -> 200 after a short delay (simulate latency) + * GET /health/error -> 500 { status: 'error' } + * GET /data/:id -> 200 { id, value } + * GET /toggle/:mode -> switch internal mode (ok|error|flaky) + * GET /dynamic -> responds based on current mode + * + * The exported createMockServer() helper returns start()/stop() controls + * so tests can own server lifecycle without relying on global state. + */ +import http, { IncomingMessage, ServerResponse } from 'http'; +import { URL } from 'url'; + +export interface MockServerHandle { + port: number; + start(): Promise; + stop(): Promise; + getMode(): string; + setMode(mode: string): void; +} + +export function createMockServer(requestedPort: number = 0): MockServerHandle { + let server: http.Server | null = null; + let mode: 'ok' | 'error' | 'flaky' = 'ok'; + let currentPort = requestedPort; + + const respondJSON = (res: ServerResponse, code: number, body: any) => { + const json = JSON.stringify(body); + res.writeHead(code, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + }); + res.end(json); + }; + + const handler = async (req: IncomingMessage, res: ServerResponse) => { + const method = req.method || 'GET'; + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const path = url.pathname; + + if (method === 'GET' && path === '/health/ok') { + return respondJSON(res, 200, { status: 'ok' }); + } + if (method === 'GET' && path === '/health/error') { + return respondJSON(res, 500, { status: 'error' }); + } + if (method === 'GET' && path === '/health/slow') { + await new Promise((r) => setTimeout(r, 150)); + return respondJSON(res, 200, { status: 'ok', delayed: true }); + } + if (method === 'GET' && path.startsWith('/data/')) { + const id = path.split('/')[2]; + return respondJSON(res, 200, { id, value: `value-${id}` }); + } + if (method === 'GET' && path.startsWith('/toggle/')) { + const newMode = path.split('/')[2]; + if (newMode === 'ok' || newMode === 'error' || newMode === 'flaky') { + mode = newMode; + return respondJSON(res, 200, { mode }); + } + return respondJSON(res, 400, { error: 'invalid mode' }); + } + if (method === 'GET' && path === '/dynamic') { + if (mode === 'ok') return respondJSON(res, 200, { status: 'ok' }); + if (mode === 'error') return respondJSON(res, 500, { status: 'error' }); + // flaky mode — alternate + const now = Date.now(); + if (now % 2 === 0) + return respondJSON(res, 200, { status: 'ok', flaky: true }); + return respondJSON(res, 500, { status: 'error', flaky: true }); + } + + respondJSON(res, 404, { error: 'not found' }); + }; + + return { + port: currentPort, + getMode: () => mode, + setMode: (m: string) => { + if (m === 'ok' || m === 'error' || m === 'flaky') mode = m; + }, + async start() { + if (server) return currentPort; + server = http.createServer(handler); + await new Promise((resolve) => { + server!.listen(currentPort, () => { + // if ephemeral port (0), capture the assigned port + const address = server!.address(); + if (typeof address === 'object' && address && 'port' in address) { + currentPort = address.port; + } + resolve(); + }); + }); + return currentPort; + }, + async stop() { + if (!server) return; + await new Promise((resolve, reject) => { + server!.close((err) => (err ? reject(err) : resolve())); + }); + server = null; + }, + }; +} + +// When run directly (manual local debugging): +if (require.main === module) { + (async () => { + const srv = createMockServer(3001); + const p = await srv.start(); + // eslint-disable-next-line no-console + console.log(`Mock server listening on http://localhost:${p}`); + })(); +} diff --git a/test/monitor.test.ts b/test/monitor.test.ts index 87880a4..2da39d7 100644 --- a/test/monitor.test.ts +++ b/test/monitor.test.ts @@ -1,5 +1,10 @@ +import promClient from 'prom-client'; import { DependencyMonitor } from '../src/monitor'; -import { SUCCESS_STATUS_CODE, ERROR_STATUS_CODE } from '../src/constants'; +import { + SUCCESS_STATUS_CODE, + ERROR_STATUS_CODE, + WARNING_STATUS_CODE, +} from '../src/constants'; import { DatabaseCheckDetails } from '../src/types'; describe('DependencyMonitor', () => { @@ -211,7 +216,7 @@ describe('DependencyMonitor', () => { const metrics = await monitor.getPrometheusMetrics(); expect(metrics).toContain('dependency_latency_ms{dependency="redis"}'); expect(metrics).toContain( - 'dependency_health{dependency="redis", impact="Responses may be slower due to missing cache."} 0', + 'dependency_health{dependency="redis",impact="Responses may be slower due to missing cache."} 0', ); }); @@ -290,4 +295,85 @@ describe('DependencyMonitor', () => { lastChecked: expect.any(String), }); }); + it('registers and updates gauges using injected registry', async () => { + const registry = new promClient.Registry(); + const monitor = new DependencyMonitor({ registry }); + + monitor.register({ + name: 'redis', + description: 'Redis cache', + impact: 'Cache latency may increase', + check: async () => ({ code: SUCCESS_STATUS_CODE }), + }); + monitor.register({ + name: 'api', + description: 'External API', + impact: 'Some features degraded', + check: async () => ({ code: WARNING_STATUS_CODE }), + }); + monitor.register({ + name: 'skipped', + description: 'Skipped dep', + impact: 'None', + skip: true, + check: async () => ({ code: SUCCESS_STATUS_CODE }), + }); + + await monitor.getAllStatuses(); // trigger checks & metrics + const metrics = await monitor.getPrometheusMetrics(); + + expect(metrics).toContain('dependency_latency_ms{dependency="redis"}'); + expect(metrics).toContain('dependency_latency_ms{dependency="api"}'); + // skipped dependency does not execute check so latency metric not emitted + expect(metrics).toMatch( + /dependency_health\{dependency="redis",impact="Cache latency may increase"} 0/, + ); + expect(metrics).toMatch( + /dependency_health\{dependency="api",impact="Some features degraded"} 1/, + ); + }); + describe('prom-client integration', () => { + it('handles repeated metrics calls (init short-circuit)', async () => { + const registry = new promClient.Registry(); + const monitor = new DependencyMonitor({ + registry, + }); + monitor.register({ + name: 'svc', + description: 'Service', + impact: 'Degraded responses', + check: async () => ({ code: SUCCESS_STATUS_CODE }), + }); + const first = await monitor.getPrometheusMetrics(); + const second = await monitor.getPrometheusMetrics(); // triggers early return branch + expect(first).toContain('dependency_latency_ms{dependency="svc"}'); + expect(second).toContain( + 'dependency_health{dependency="svc",impact="Degraded responses"} 0', + ); + }); + }); + it('getPrometheusRegistry() returns registry', async () => { + const registry = new promClient.Registry(); + const monitor = new DependencyMonitor({ registry }); + monitor.register({ + name: 'redis', + description: 'Redis cache', + impact: 'Cache latency may increase', + check: async () => ({ code: SUCCESS_STATUS_CODE }), + }); + expect(monitor.getPrometheusRegistry()).toBe(registry); + }); + it('collectDefaultMetrics option enables default metrics', async () => { + const monitor = new DependencyMonitor({ + collectDefaultMetrics: true, + }); + monitor.register({ + name: 'redis', + description: 'Redis cache', + impact: 'Cache latency may increase', + check: async () => ({ code: SUCCESS_STATUS_CODE }), + }); + const metrics = await monitor.getPrometheusMetrics(); + expect(metrics).toContain('# HELP process_cpu_seconds_total'); // default metric + }); });