From b55a8e9845341c4841baa7ef9fb960c3eae36dd7 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 19:47:21 -0700 Subject: [PATCH 1/9] prom client --- package-lock.json | 40 +++++++++- package.json | 3 +- src/monitor.ts | 99 ++++++++++++++++++++++++- src/types/dependency-monitor-options.ts | 17 +++++ test/monitor.test.ts | 70 ++++++++++++++++- 5 files changed, 223 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84e7e82..fd067bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.2.2", "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..8869355 100644 --- a/package.json +++ b/package.json @@ -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..f402fd0 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -1,4 +1,5 @@ import { createCache, Cache as CacheManagerCache } from 'cache-manager'; +import promClient from 'prom-client'; import { DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CACHE_DURATION_MS, @@ -15,6 +16,10 @@ import { import formatCheckResult from './lib/format-check-result'; import formatPrometheusMetrics from './lib/format-prometheus-metrics'; +// Prometheus support is optional; we lazy-require prom-client only if metrics are requested +// Using loose any typing to avoid forcing downstream consumers to install @types/prom-client +type PromClientModule = any; + /** * DependencyMonitor is a class that monitors the status of various dependencies * (e.g., databases, APIs) and provides methods to check their health and latency. @@ -30,6 +35,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; + // prom-client related (all optional / lazy) + private _promClient?: PromClientModule; + private _registry?: any; // use any to avoid requiring prom-client types for consumers + private _latencyGauge?: any; + private _healthGauge?: any; + private _metricsInitialized = false; + private _collectDefaultMetrics = false; public checkIntervalStarted: boolean = false; @@ -56,6 +68,11 @@ class DependencyMonitor implements DependencyMonitorInterface { ttl: this._cacheDurationMs, refreshThreshold: this._refreshThresholdMs, }); + + // store prometheus options but delay initialization + this._promClient = options.promClient || promClient; + this._registry = options.registry; + this._collectDefaultMetrics = !!options.collectDefaultMetrics; } public startDependencyCheckInterval(): void { @@ -96,7 +113,13 @@ 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 +135,7 @@ class DependencyMonitor implements DependencyMonitorInterface { status, dependency.cacheDurationMs, ); + this._updateMetrics(status); return status; } } @@ -146,8 +170,79 @@ class DependencyMonitor implements DependencyMonitorInterface { } public async getPrometheusMetrics(): Promise { + // If prom-client is available / configured use the registry; otherwise fall back to static formatting + await this._getAllDependenciesStatus(); // ensure gauges updated + if (this._initPromClient()) { + return this._registry.metrics(); + } const statuses = await this._getAllDependenciesStatus(); - return formatPrometheusMetrics(statuses); + return formatPrometheusMetrics(statuses); // legacy formatting + } + + /** + * Returns the underlying prom-client Registry if initialized. + */ + public getPrometheusRegistry() { + if (this._initPromClient()) return this._registry; + return undefined; + } + + private _initPromClient(): boolean { + if (this._metricsInitialized) return true; + try { + if (!this._registry) { + this._registry = new this._promClient.Registry(); + if (this._collectDefaultMetrics) { + this._promClient.collectDefaultMetrics({ + register: this._registry, + }); + } + } + + const latencyName = 'dependency_latency_ms'; + const healthName = 'dependency_health'; + + const existingLatency = this._registry.getSingleMetric(latencyName); + this._latencyGauge = + existingLatency || + new this._promClient.Gauge({ + name: latencyName, + help: 'Last dependency check latency in milliseconds', + labelNames: ['dependency'], + registers: [this._registry], + }); + + const existingHealth = this._registry.getSingleMetric(healthName); + this._healthGauge = + existingHealth || + new this._promClient.Gauge({ + name: healthName, + help: 'Dependency health status (0=OK,1=WARNING,2=CRITICAL)', + labelNames: ['dependency', 'impact'], + registers: [this._registry], + }); + + this._metricsInitialized = true; + return true; + } catch (e) { + // prom-client not installed or failed; suppress + return false; + } + } + + private _updateMetrics(status: DependencyStatus) { + if (!this._initPromClient()) return; + if (!this._latencyGauge || !this._healthGauge) return; + const { name, impact, health } = status; + const state = health.state; + const value = + state === 'OK' + ? 0 + : state === 'WARNING' + ? 1 + : 2; /* CRITICAL */ + this._latencyGauge.set({ dependency: name }, health.latency); + this._healthGauge.set({ dependency: name, impact: impact || '' }, value); } } diff --git a/src/types/dependency-monitor-options.ts b/src/types/dependency-monitor-options.ts index 999904d..3156139 100644 --- a/src/types/dependency-monitor-options.ts +++ b/src/types/dependency-monitor-options.ts @@ -32,4 +32,21 @@ export type DependencyMonitorOptions = { * @default 15000 */ checkIntervalMs?: number; + /** + * Optional prom-client module instance to use for metrics. If not provided the library will attempt + * to lazy-load prom-client. If prom-client cannot be resolved, metrics collection becomes a no-op + * and `getPrometheusMetrics` will return an empty string. + * @example + * import * as promClient from 'prom-client'; + * const monitor = new DependencyMonitor({ promClient }); + */ + promClient?: any; // typed as any to avoid hard dependency on prom-client types at compile for consumers not using it + /** + * Optional existing Registry instance to register metrics with. If omitted a new Registry is created. + */ + registry?: any; + /** + * When true and a new Registry is created internally, default process metrics will also be collected. + */ + collectDefaultMetrics?: boolean; }; diff --git a/test/monitor.test.ts b/test/monitor.test.ts index 87880a4..1da8c72 100644 --- a/test/monitor.test.ts +++ b/test/monitor.test.ts @@ -1,5 +1,6 @@ +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 +212,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 +291,69 @@ describe('DependencyMonitor', () => { lastChecked: expect.any(String), }); }); + it('registers and updates gauges using injected registry', async () => { + const registry = new promClient.Registry(); + const monitor = new DependencyMonitor({ promClient, 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('falls back to legacy formatting when prom-client init fails', async () => { + const badPromClient: any = {}; // missing Registry -> will cause _initPromClient to throw + const monitor = new DependencyMonitor({ promClient: badPromClient }); + monitor.register({ + name: 'legacy', + description: 'Legacy dependency', + impact: 'None', + check: async () => ({ code: SUCCESS_STATUS_CODE }), + }); + const metrics = await monitor.getPrometheusMetrics(); + expect(metrics).toContain('# HELP dependency_health'); // legacy formatter marker + }); + + it('handles repeated metrics calls (init short-circuit)', async () => { + const registry = new promClient.Registry(); + const monitor = new DependencyMonitor({ + promClient, + 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'); + }); + }) }); From 050c20c382aca8b33955e46ea18e4e3c7d44a219 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:00:25 -0700 Subject: [PATCH 2/9] example server, and integration tests --- README.md | 198 ++++++------------ example/README.md | 28 +++ example/server.js | 169 +++++++++++++++ src/monitor.ts | 106 ++++------ src/types/dependency-monitor-interface.ts | 5 + src/types/dependency-monitor-options.ts | 11 +- .../mock-server.integration.test.ts | 73 +++++++ test/integration/mock-server.ts | 119 +++++++++++ test/monitor.test.ts | 47 +++-- 9 files changed, 540 insertions(+), 216 deletions(-) create mode 100644 example/README.md create mode 100644 example/server.js create mode 100644 test/integration/mock-server.integration.test.ts create mode 100644 test/integration/mock-server.ts diff --git a/README.md b/README.md index 2edf2c9..f79af66 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,41 @@ -# đŸ› ïž 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 { - DependencyMonitor, - SUCCESS_STATUS_CODE, - ERROR_STATUS_CODE, -} from 'proactive-deps'; - -const monitor = new DependencyMonitor(); +import { DependencyMonitor, SUCCESS_STATUS_CODE, ERROR_STATUS_CODE } from 'proactive-deps'; + +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 +49,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 +62,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 +77,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 +111,15 @@ monitor.register({ }); ``` -### What Should a Dependency Check Return? - -A registered dependency check can return either a status code or an object with additional details. - -#### When Healthy: - -You can return just the status code: - -```js -SUCCESS_STATUS_CODE; -``` - -Or an object with the status code: +### Return Shape -```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. +Checker returns either: +* `SUCCESS_STATUS_CODE` (number) or +* `{ code, error?, errorMessage? }` -#### When Errors Are Encountered: +`skip: true` short‑circuits to an OK result with `latency: 0` and `skipped: true`. -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 +142,7 @@ console.log(statuses); // ]; ``` -### Getting the Status of a Specific Dependency +### Single Dependency ```js const status = await monitor.getStatus('redis'); @@ -243,43 +163,51 @@ 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 - -For detailed API documentation, refer to the [docs](https://dantheuber.github.io/proactive-deps). - -## 🧠 Philosophy - -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. +### Example Server (PokeAPI Demo) +See `example/server.js` for a pure Node HTTP server exposing: +* `/pokemon/:name` – live pass‑through to PokeAPI +* `/dependencies` – JSON array of current statuses +* `/metrics` – Prometheus text output -## đŸ§Ș Ideal Use Cases +Run it locally: +```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 -## đŸ›Łïž Future Plans +For detailed API documentation, refer to the [docs](https://dantheuber.github.io/proactive-deps). -- [ ] Built-in Prometheus metrics endpoint handler -- [ ] Retry logic with exponential backoff -- [ ] Custom alert hooks (email, Slack, etc.) -- [ ] Custom cache stores. +## Roadmap (abridged) +* 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..ca17c01 --- /dev/null +++ b/example/README.md @@ -0,0 +1,28 @@ +# 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..61ce017 --- /dev/null +++ b/example/server.js @@ -0,0 +1,169 @@ +/* + 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/src/monitor.ts b/src/monitor.ts index f402fd0..8d580a6 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -14,7 +14,6 @@ import { DependencyStatus, } from './types'; import formatCheckResult from './lib/format-check-result'; -import formatPrometheusMetrics from './lib/format-prometheus-metrics'; // Prometheus support is optional; we lazy-require prom-client only if metrics are requested // Using loose any typing to avoid forcing downstream consumers to install @types/prom-client @@ -36,8 +35,8 @@ class DependencyMonitor implements DependencyMonitorInterface { private _cacheDurationMs: number = DEFAULT_CACHE_DURATION_MS; private _checkIntervalMs: number = DEFAULT_CHECK_INTERVAL_MS; // prom-client related (all optional / lazy) - private _promClient?: PromClientModule; - private _registry?: any; // use any to avoid requiring prom-client types for consumers + private _promClient: PromClientModule; + private _registry: any; // use any to avoid requiring prom-client types for consumers private _latencyGauge?: any; private _healthGauge?: any; private _metricsInitialized = false; @@ -69,10 +68,12 @@ class DependencyMonitor implements DependencyMonitorInterface { refreshThreshold: this._refreshThresholdMs, }); - // store prometheus options but delay initialization + // store prometheus options and eagerly ensure prom-client presence this._promClient = options.promClient || promClient; - this._registry = options.registry; + 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 { @@ -170,79 +171,64 @@ class DependencyMonitor implements DependencyMonitorInterface { } public async getPrometheusMetrics(): Promise { - // If prom-client is available / configured use the registry; otherwise fall back to static formatting - await this._getAllDependenciesStatus(); // ensure gauges updated - if (this._initPromClient()) { - return this._registry.metrics(); - } - const statuses = await this._getAllDependenciesStatus(); - return formatPrometheusMetrics(statuses); // legacy formatting + await this._getAllDependenciesStatus(); // ensure gauges updated before render + this._initPromClient(); + return this._registry.metrics(); } /** * Returns the underlying prom-client Registry if initialized. */ public getPrometheusRegistry() { - if (this._initPromClient()) return this._registry; - return undefined; + this._initPromClient(); + return this._registry; } private _initPromClient(): boolean { if (this._metricsInitialized) return true; - try { - if (!this._registry) { - this._registry = new this._promClient.Registry(); - if (this._collectDefaultMetrics) { - this._promClient.collectDefaultMetrics({ - register: this._registry, - }); - } + if (!this._registry) { + this._registry = new this._promClient.Registry(); + if (this._collectDefaultMetrics) { + this._promClient.collectDefaultMetrics({ + register: this._registry, + }); } + } - const latencyName = 'dependency_latency_ms'; - const healthName = 'dependency_health'; - - const existingLatency = this._registry.getSingleMetric(latencyName); - this._latencyGauge = - existingLatency || - new this._promClient.Gauge({ - name: latencyName, - help: 'Last dependency check latency in milliseconds', - labelNames: ['dependency'], - registers: [this._registry], - }); + const latencyName = 'dependency_latency_ms'; + const healthName = 'dependency_health'; + + const existingLatency = this._registry.getSingleMetric(latencyName); + this._latencyGauge = + existingLatency || + new this._promClient.Gauge({ + name: latencyName, + help: 'Last dependency check latency in milliseconds', + labelNames: ['dependency'], + registers: [this._registry], + }); - const existingHealth = this._registry.getSingleMetric(healthName); - this._healthGauge = - existingHealth || - new this._promClient.Gauge({ - name: healthName, - help: 'Dependency health status (0=OK,1=WARNING,2=CRITICAL)', - labelNames: ['dependency', 'impact'], - registers: [this._registry], - }); + const existingHealth = this._registry.getSingleMetric(healthName); + this._healthGauge = + existingHealth || + new this._promClient.Gauge({ + name: healthName, + help: 'Dependency health status (0=OK,1=WARNING,2=CRITICAL)', + labelNames: ['dependency', 'impact'], + registers: [this._registry], + }); - this._metricsInitialized = true; - return true; - } catch (e) { - // prom-client not installed or failed; suppress - return false; - } + this._metricsInitialized = true; + return true; } private _updateMetrics(status: DependencyStatus) { - if (!this._initPromClient()) return; - if (!this._latencyGauge || !this._healthGauge) return; + this._initPromClient(); const { name, impact, health } = status; - const state = health.state; - const value = - state === 'OK' - ? 0 - : state === 'WARNING' - ? 1 - : 2; /* CRITICAL */ - this._latencyGauge.set({ dependency: name }, health.latency); - this._healthGauge.set({ dependency: name, impact: impact || '' }, value); + 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 3156139..605f1d8 100644 --- a/src/types/dependency-monitor-options.ts +++ b/src/types/dependency-monitor-options.ts @@ -33,14 +33,11 @@ export type DependencyMonitorOptions = { */ checkIntervalMs?: number; /** - * Optional prom-client module instance to use for metrics. If not provided the library will attempt - * to lazy-load prom-client. If prom-client cannot be resolved, metrics collection becomes a no-op - * and `getPrometheusMetrics` will return an empty string. - * @example - * import * as promClient from 'prom-client'; - * const monitor = new DependencyMonitor({ promClient }); + * Optional prom-client module instance override to use for metrics. If not provided, the default + * installed prom-client dependency is used. Metrics are always enabled; an error is thrown if + * prom-client cannot be loaded. */ - promClient?: any; // typed as any to avoid hard dependency on prom-client types at compile for consumers not using it + promClient?: any; /** * Optional existing Registry instance to register metrics with. If omitted a new Registry is created. */ diff --git a/test/integration/mock-server.integration.test.ts b/test/integration/mock-server.integration.test.ts new file mode 100644 index 0000000..0095f2d --- /dev/null +++ b/test/integration/mock-server.integration.test.ts @@ -0,0 +1,73 @@ +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..36b0e20 --- /dev/null +++ b/test/integration/mock-server.ts @@ -0,0 +1,119 @@ +/** + * 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 1da8c72..0e09f07 100644 --- a/test/monitor.test.ts +++ b/test/monitor.test.ts @@ -325,19 +325,6 @@ describe('DependencyMonitor', () => { expect(metrics).toMatch(/dependency_health\{dependency="api",impact="Some features degraded"} 1/); }); describe('prom-client integration', () => { - it('falls back to legacy formatting when prom-client init fails', async () => { - const badPromClient: any = {}; // missing Registry -> will cause _initPromClient to throw - const monitor = new DependencyMonitor({ promClient: badPromClient }); - monitor.register({ - name: 'legacy', - description: 'Legacy dependency', - impact: 'None', - check: async () => ({ code: SUCCESS_STATUS_CODE }), - }); - const metrics = await monitor.getPrometheusMetrics(); - expect(metrics).toContain('# HELP dependency_health'); // legacy formatter marker - }); - it('handles repeated metrics calls (init short-circuit)', async () => { const registry = new promClient.Registry(); const monitor = new DependencyMonitor({ @@ -355,5 +342,37 @@ describe('DependencyMonitor', () => { 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({ promClient, 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({ promClient, 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 + }); + it('emits metrics with blank impact when impact not provided', async () => { + const monitor = new DependencyMonitor({ promClient }); + monitor.register({ + name: 'noimpact', + description: 'No impact dep', + check: async () => ({ code: SUCCESS_STATUS_CODE }), + } as any); + const metrics = await monitor.getPrometheusMetrics(); + expect(metrics).toMatch(/dependency_health\{dependency="noimpact",impact=""} 0/); + }); }); From 7f51212ba2e4943e5e672044c15eee90e075fcc5 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:00:34 -0700 Subject: [PATCH 3/9] indent --- test/integration/mock-server.integration.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/integration/mock-server.integration.test.ts b/test/integration/mock-server.integration.test.ts index 0095f2d..3bada49 100644 --- a/test/integration/mock-server.integration.test.ts +++ b/test/integration/mock-server.integration.test.ts @@ -52,10 +52,10 @@ describe('DependencyMonitor integration with mock server', () => { 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+)?/); + // 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 () => { @@ -66,8 +66,8 @@ describe('DependencyMonitor integration with mock server', () => { 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+)?/); + // 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+)?/); }); }); From d7e7cf0ed2fffbf7cc10b0e9d125f8d00648aa56 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:00:41 -0700 Subject: [PATCH 4/9] 1.3.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd067bc..d152ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "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", diff --git a/package.json b/package.json index 8869355..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", From f7793807ad5ba644f4c2c1b098de272ae72313ee Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:22:41 -0700 Subject: [PATCH 5/9] lint --- README.md | 49 ++++---- example/README.md | 4 + example/server.js | 33 ++++-- src/monitor.ts | 20 ++-- .../mock-server.integration.test.ts | 29 +++-- test/integration/mock-server.ts | 9 +- test/monitor.test.ts | 111 ++++++++++-------- 7 files changed, 152 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index f79af66..3cd98f8 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ Define async checks (DBs, REST APIs, queues, etc.), get structured status + late ## Features -* 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) +- 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) ## Install @@ -22,7 +22,11 @@ npm install proactive-deps ## Quick Start ```js -import { DependencyMonitor, SUCCESS_STATUS_CODE, ERROR_STATUS_CODE } from 'proactive-deps'; +import { + DependencyMonitor, + SUCCESS_STATUS_CODE, + ERROR_STATUS_CODE, +} from 'proactive-deps'; const monitor = new DependencyMonitor({ // Optional: turn on prom-client default metrics & tweak intervals @@ -49,8 +53,8 @@ monitor.register({ }; // Unhealthy status with error details } }, - cacheDurationMs: 10000, // (optional) override default cache TTL - refreshThresholdMs: 5000, // (optional) pre-emptive refresh window + cacheDurationMs: 10000, // (optional) override default cache TTL + refreshThresholdMs: 5000, // (optional) pre-emptive refresh window checkDetails: { type: 'database', server: 'localhost', @@ -114,8 +118,9 @@ monitor.register({ ### Return Shape Checker returns either: -* `SUCCESS_STATUS_CODE` (number) or -* `{ code, error?, errorMessage? }` + +- `SUCCESS_STATUS_CODE` (number) or +- `{ code, error?, errorMessage? }` `skip: true` short‑circuits to an OK result with `latency: 0` and `skipped: true`. @@ -167,8 +172,8 @@ console.log(status); 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) +- `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. @@ -187,12 +192,15 @@ dependency_health{dependency="redis",impact="Responses may be slower (cache miss ``` ### Example Server (PokeAPI Demo) + See `example/server.js` for a pure Node HTTP server exposing: -* `/pokemon/:name` – live pass‑through to PokeAPI -* `/dependencies` – JSON array of current statuses -* `/metrics` – Prometheus text output + +- `/pokemon/:name` – live pass‑through to PokeAPI +- `/dependencies` – JSON array of current statuses +- `/metrics` – Prometheus text output Run it locally: + ```bash npm run build node example/server.js @@ -203,10 +211,11 @@ node example/server.js For detailed API documentation, refer to the [docs](https://dantheuber.github.io/proactive-deps). ## Roadmap (abridged) -* Built-in helper for common /metrics endpoint -* Optional retry / backoff helpers -* Alert hooks (Slack, email) -* Pluggable cache stores + +- Built-in helper for common /metrics endpoint +- Optional retry / backoff helpers +- Alert hooks (Slack, email) +- Pluggable cache stores ## License diff --git a/example/README.md b/example/README.md index ca17c01..eaaab8e 100644 --- a/example/README.md +++ b/example/README.md @@ -3,12 +3,14 @@ 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 @@ -16,6 +18,7 @@ node example/server.js ``` Then in another shell: + ```bash curl http://localhost:3000/pokemon/ditto curl http://localhost:3000/dependencies @@ -23,6 +26,7 @@ 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 index 61ce017..44f1889 100644 --- a/example/server.js +++ b/example/server.js @@ -34,11 +34,7 @@ try { proactive = require('../src'); } -const { - DependencyMonitor, - SUCCESS_STATUS_CODE, - ERROR_STATUS_CODE, -} = proactive; +const { DependencyMonitor, SUCCESS_STATUS_CODE, ERROR_STATUS_CODE } = proactive; const monitor = new DependencyMonitor({ // Collect default metrics (optional) @@ -93,9 +89,13 @@ function sendJson(res, statusCode, body) { async function handlePokemonRequest(res, name) { try { - const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${encodeURIComponent(name)}`); + 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}` }); + sendJson(res, response.status, { + error: `PokeAPI returned status ${response.status}`, + }); return; } const data = await response.json(); @@ -106,12 +106,15 @@ async function handlePokemonRequest(res, name) { 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), + 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 }); + sendJson(res, 502, { + error: 'Failed to fetch from PokeAPI', + details: error.message, + }); } } @@ -144,7 +147,10 @@ const server = http.createServer(async (req, res) => { const statuses = await monitor.getAllStatuses(); sendJson(res, 200, statuses); } catch (error) { - sendJson(res, 500, { error: 'Unable to fetch dependency statuses', details: error.message }); + sendJson(res, 500, { + error: 'Unable to fetch dependency statuses', + details: error.message, + }); } return; } @@ -155,7 +161,10 @@ const server = http.createServer(async (req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end(metrics); } catch (error) { - sendJson(res, 500, { error: 'Unable to render Prometheus metrics', details: error.message }); + sendJson(res, 500, { + error: 'Unable to render Prometheus metrics', + details: error.message, + }); } return; } diff --git a/src/monitor.ts b/src/monitor.ts index 8d580a6..7e74d66 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -68,12 +68,12 @@ class DependencyMonitor implements DependencyMonitorInterface { refreshThreshold: this._refreshThresholdMs, }); - // store prometheus options and eagerly ensure prom-client presence - this._promClient = options.promClient || promClient; - this._registry = options.registry; // may be undefined; created during _initPromClient - this._collectDefaultMetrics = !!options.collectDefaultMetrics; - // Eagerly initialize metrics so they are always available - this._initPromClient(); + // store prometheus options and eagerly ensure prom-client presence + this._promClient = options.promClient || promClient; + 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 { @@ -114,13 +114,9 @@ class DependencyMonitor implements DependencyMonitorInterface { const start = Date.now(); const checkResults = await dependency.check(); const latencyMs = Date.now() - start; - const status = formatCheckResult( - dependency, - checkResults, - latencyMs, - ); + const status = formatCheckResult(dependency, checkResults, latencyMs); this._updateMetrics(status); - return status; + return status; }, dependency.cacheDurationMs || this._cacheDurationMs, dependency.refreshThresholdMs || this._refreshThresholdMs, diff --git a/test/integration/mock-server.integration.test.ts b/test/integration/mock-server.integration.test.ts index 3bada49..6359ece 100644 --- a/test/integration/mock-server.integration.test.ts +++ b/test/integration/mock-server.integration.test.ts @@ -1,4 +1,8 @@ -import { DependencyMonitor, SUCCESS_STATUS_CODE, ERROR_STATUS_CODE } from '../../src'; +import { + DependencyMonitor, + SUCCESS_STATUS_CODE, + ERROR_STATUS_CODE, +} from '../../src'; import { createMockServer } from './mock-server'; /** @@ -31,9 +35,16 @@ describe('DependencyMonitor integration with mock server', () => { 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}` }; + return { + code: ERROR_STATUS_CODE, + errorMessage: `status ${res.status}`, + }; } catch (error) { - return { code: ERROR_STATUS_CODE, error: error as Error, errorMessage: 'fetch failed' }; + return { + code: ERROR_STATUS_CODE, + error: error as Error, + errorMessage: 'fetch failed', + }; } }, }); @@ -47,7 +58,7 @@ describe('DependencyMonitor integration with mock server', () => { test('reports OK state initially', async () => { // Ensure at least one interval pass - await new Promise(r => setTimeout(r, 350)); + 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); @@ -55,19 +66,23 @@ describe('DependencyMonitor integration with mock server', () => { // 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+)?/); + 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)); + 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+)?/); + 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 index 36b0e20..55e2fd4 100644 --- a/test/integration/mock-server.ts +++ b/test/integration/mock-server.ts @@ -50,7 +50,7 @@ export function createMockServer(requestedPort: number = 0): MockServerHandle { return respondJSON(res, 500, { status: 'error' }); } if (method === 'GET' && path === '/health/slow') { - await new Promise(r => setTimeout(r, 150)); + await new Promise((r) => setTimeout(r, 150)); return respondJSON(res, 200, { status: 'ok', delayed: true }); } if (method === 'GET' && path.startsWith('/data/')) { @@ -70,7 +70,8 @@ export function createMockServer(requestedPort: number = 0): MockServerHandle { 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 }); + if (now % 2 === 0) + return respondJSON(res, 200, { status: 'ok', flaky: true }); return respondJSON(res, 500, { status: 'error', flaky: true }); } @@ -86,7 +87,7 @@ export function createMockServer(requestedPort: number = 0): MockServerHandle { async start() { if (server) return currentPort; server = http.createServer(handler); - await new Promise(resolve => { + await new Promise((resolve) => { server!.listen(currentPort, () => { // if ephemeral port (0), capture the assigned port const address = server!.address(); @@ -101,7 +102,7 @@ export function createMockServer(requestedPort: number = 0): MockServerHandle { async stop() { if (!server) return; await new Promise((resolve, reject) => { - server!.close(err => (err ? reject(err) : resolve())); + server!.close((err) => (err ? reject(err) : resolve())); }); server = null; }, diff --git a/test/monitor.test.ts b/test/monitor.test.ts index 0e09f07..5e0d070 100644 --- a/test/monitor.test.ts +++ b/test/monitor.test.ts @@ -1,6 +1,10 @@ import promClient from 'prom-client'; import { DependencyMonitor } from '../src/monitor'; -import { SUCCESS_STATUS_CODE, ERROR_STATUS_CODE, WARNING_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', () => { @@ -292,57 +296,63 @@ describe('DependencyMonitor', () => { }); }); it('registers and updates gauges using injected registry', async () => { + const registry = new promClient.Registry(); + const monitor = new DependencyMonitor({ promClient, 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({ promClient, registry }); - - monitor.register({ - name: 'redis', - description: 'Redis cache', - impact: 'Cache latency may increase', - check: async () => ({ code: SUCCESS_STATUS_CODE }), + const monitor = new DependencyMonitor({ + promClient, + registry, }); 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, + name: 'svc', + description: 'Service', + impact: 'Degraded responses', 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({ - promClient, - 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'); - }); + 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({ promClient, registry }); @@ -355,7 +365,10 @@ describe('DependencyMonitor', () => { expect(monitor.getPrometheusRegistry()).toBe(registry); }); it('collectDefaultMetrics option enables default metrics', async () => { - const monitor = new DependencyMonitor({ promClient, collectDefaultMetrics: true }); + const monitor = new DependencyMonitor({ + promClient, + collectDefaultMetrics: true, + }); monitor.register({ name: 'redis', description: 'Redis cache', @@ -373,6 +386,8 @@ describe('DependencyMonitor', () => { check: async () => ({ code: SUCCESS_STATUS_CODE }), } as any); const metrics = await monitor.getPrometheusMetrics(); - expect(metrics).toMatch(/dependency_health\{dependency="noimpact",impact=""} 0/); + expect(metrics).toMatch( + /dependency_health\{dependency="noimpact",impact=""} 0/, + ); }); }); From 12c1b123d9dd2fa8b785a6e7da8dac332e9a408d Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:25:19 -0700 Subject: [PATCH 6/9] Update test/monitor.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/monitor.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/monitor.test.ts b/test/monitor.test.ts index 5e0d070..cc73729 100644 --- a/test/monitor.test.ts +++ b/test/monitor.test.ts @@ -384,7 +384,7 @@ describe('DependencyMonitor', () => { name: 'noimpact', description: 'No impact dep', check: async () => ({ code: SUCCESS_STATUS_CODE }), - } as any); + } as Omit[0], 'impact'>); const metrics = await monitor.getPrometheusMetrics(); expect(metrics).toMatch( /dependency_health\{dependency="noimpact",impact=""} 0/, From 535d3786ca4d8371fd1f89948b5785d12b1e6784 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:37:52 -0700 Subject: [PATCH 7/9] prom-client types --- src/monitor.ts | 32 +++++++++++-------------- src/types/dependency-monitor-options.ts | 10 ++------ test/monitor.test.ts | 8 +++---- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/monitor.ts b/src/monitor.ts index 7e74d66..da090d1 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -1,5 +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, @@ -15,10 +16,6 @@ import { } from './types'; import formatCheckResult from './lib/format-check-result'; -// Prometheus support is optional; we lazy-require prom-client only if metrics are requested -// Using loose any typing to avoid forcing downstream consumers to install @types/prom-client -type PromClientModule = any; - /** * DependencyMonitor is a class that monitors the status of various dependencies * (e.g., databases, APIs) and provides methods to check their health and latency. @@ -34,11 +31,11 @@ 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; - // prom-client related (all optional / lazy) - private _promClient: PromClientModule; - private _registry: any; // use any to avoid requiring prom-client types for consumers - private _latencyGauge?: any; - private _healthGauge?: any; + // 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; @@ -68,8 +65,7 @@ class DependencyMonitor implements DependencyMonitorInterface { refreshThreshold: this._refreshThresholdMs, }); - // store prometheus options and eagerly ensure prom-client presence - this._promClient = options.promClient || promClient; + // prometheus options this._registry = options.registry; // may be undefined; created during _initPromClient this._collectDefaultMetrics = !!options.collectDefaultMetrics; // Eagerly initialize metrics so they are always available @@ -169,7 +165,7 @@ class DependencyMonitor implements DependencyMonitorInterface { public async getPrometheusMetrics(): Promise { await this._getAllDependenciesStatus(); // ensure gauges updated before render this._initPromClient(); - return this._registry.metrics(); + return this._registry!.metrics(); } /** @@ -183,9 +179,9 @@ class DependencyMonitor implements DependencyMonitorInterface { private _initPromClient(): boolean { if (this._metricsInitialized) return true; if (!this._registry) { - this._registry = new this._promClient.Registry(); + this._registry = new promClient.Registry(); if (this._collectDefaultMetrics) { - this._promClient.collectDefaultMetrics({ + promClient.collectDefaultMetrics({ register: this._registry, }); } @@ -194,20 +190,20 @@ class DependencyMonitor implements DependencyMonitorInterface { const latencyName = 'dependency_latency_ms'; const healthName = 'dependency_health'; - const existingLatency = this._registry.getSingleMetric(latencyName); + const existingLatency = this._registry.getSingleMetric(latencyName) as Gauge<'dependency'> | undefined; this._latencyGauge = existingLatency || - new this._promClient.Gauge({ + new promClient.Gauge<'dependency'>({ name: latencyName, help: 'Last dependency check latency in milliseconds', labelNames: ['dependency'], registers: [this._registry], }); - const existingHealth = this._registry.getSingleMetric(healthName); + const existingHealth = this._registry.getSingleMetric(healthName) as Gauge<'dependency' | 'impact'> | undefined; this._healthGauge = existingHealth || - new this._promClient.Gauge({ + new promClient.Gauge<'dependency' | 'impact'>({ name: healthName, help: 'Dependency health status (0=OK,1=WARNING,2=CRITICAL)', labelNames: ['dependency', 'impact'], diff --git a/src/types/dependency-monitor-options.ts b/src/types/dependency-monitor-options.ts index 605f1d8..edaeed4 100644 --- a/src/types/dependency-monitor-options.ts +++ b/src/types/dependency-monitor-options.ts @@ -33,15 +33,9 @@ export type DependencyMonitorOptions = { */ checkIntervalMs?: number; /** - * Optional prom-client module instance override to use for metrics. If not provided, the default - * installed prom-client dependency is used. Metrics are always enabled; an error is thrown if - * prom-client cannot be loaded. + * Optional existing prom-client Registry instance to register metrics with. If omitted a new Registry is created. */ - promClient?: any; - /** - * Optional existing Registry instance to register metrics with. If omitted a new Registry is created. - */ - registry?: any; + registry?: import('prom-client').Registry; /** * When true and a new Registry is created internally, default process metrics will also be collected. */ diff --git a/test/monitor.test.ts b/test/monitor.test.ts index 5e0d070..5f342c2 100644 --- a/test/monitor.test.ts +++ b/test/monitor.test.ts @@ -297,7 +297,7 @@ describe('DependencyMonitor', () => { }); it('registers and updates gauges using injected registry', async () => { const registry = new promClient.Registry(); - const monitor = new DependencyMonitor({ promClient, registry }); + const monitor = new DependencyMonitor({ registry }); monitor.register({ name: 'redis', @@ -336,7 +336,6 @@ describe('DependencyMonitor', () => { it('handles repeated metrics calls (init short-circuit)', async () => { const registry = new promClient.Registry(); const monitor = new DependencyMonitor({ - promClient, registry, }); monitor.register({ @@ -355,7 +354,7 @@ describe('DependencyMonitor', () => { }); it('getPrometheusRegistry() returns registry', async () => { const registry = new promClient.Registry(); - const monitor = new DependencyMonitor({ promClient, registry }); + const monitor = new DependencyMonitor({ registry }); monitor.register({ name: 'redis', description: 'Redis cache', @@ -366,7 +365,6 @@ describe('DependencyMonitor', () => { }); it('collectDefaultMetrics option enables default metrics', async () => { const monitor = new DependencyMonitor({ - promClient, collectDefaultMetrics: true, }); monitor.register({ @@ -379,7 +377,7 @@ describe('DependencyMonitor', () => { expect(metrics).toContain('# HELP process_cpu_seconds_total'); // default metric }); it('emits metrics with blank impact when impact not provided', async () => { - const monitor = new DependencyMonitor({ promClient }); + const monitor = new DependencyMonitor({}); monitor.register({ name: 'noimpact', description: 'No impact dep', From 8a8966e2831c252d594270d78cf81c136627735c Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:42:34 -0700 Subject: [PATCH 8/9] impact is required --- src/monitor.ts | 2 +- test/monitor.test.ts | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src/monitor.ts b/src/monitor.ts index da090d1..f3c34d3 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -220,7 +220,7 @@ class DependencyMonitor implements DependencyMonitorInterface { 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); + this._healthGauge!.set({ dependency: name, impact: impact }, value); } } diff --git a/test/monitor.test.ts b/test/monitor.test.ts index 23ef57f..2da39d7 100644 --- a/test/monitor.test.ts +++ b/test/monitor.test.ts @@ -376,16 +376,4 @@ describe('DependencyMonitor', () => { const metrics = await monitor.getPrometheusMetrics(); expect(metrics).toContain('# HELP process_cpu_seconds_total'); // default metric }); - it('emits metrics with blank impact when impact not provided', async () => { - const monitor = new DependencyMonitor({}); - monitor.register({ - name: 'noimpact', - description: 'No impact dep', - check: async () => ({ code: SUCCESS_STATUS_CODE }), - } as Omit[0], 'impact'>); - const metrics = await monitor.getPrometheusMetrics(); - expect(metrics).toMatch( - /dependency_health\{dependency="noimpact",impact=""} 0/, - ); - }); }); From 823692c7ba6de51668ab7663b15bd16ce2b1aa39 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Sat, 16 Aug 2025 21:42:49 -0700 Subject: [PATCH 9/9] format --- src/monitor.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/monitor.ts b/src/monitor.ts index f3c34d3..97ebcc3 100644 --- a/src/monitor.ts +++ b/src/monitor.ts @@ -165,7 +165,7 @@ class DependencyMonitor implements DependencyMonitorInterface { public async getPrometheusMetrics(): Promise { await this._getAllDependenciesStatus(); // ensure gauges updated before render this._initPromClient(); - return this._registry!.metrics(); + return this._registry!.metrics(); } /** @@ -190,7 +190,9 @@ class DependencyMonitor implements DependencyMonitorInterface { const latencyName = 'dependency_latency_ms'; const healthName = 'dependency_health'; - const existingLatency = this._registry.getSingleMetric(latencyName) as Gauge<'dependency'> | undefined; + const existingLatency = this._registry.getSingleMetric(latencyName) as + | Gauge<'dependency'> + | undefined; this._latencyGauge = existingLatency || new promClient.Gauge<'dependency'>({ @@ -200,7 +202,9 @@ class DependencyMonitor implements DependencyMonitorInterface { registers: [this._registry], }); - const existingHealth = this._registry.getSingleMetric(healthName) as Gauge<'dependency' | 'impact'> | undefined; + const existingHealth = this._registry.getSingleMetric(healthName) as + | Gauge<'dependency' | 'impact'> + | undefined; this._healthGauge = existingHealth || new promClient.Gauge<'dependency' | 'impact'>({