Skip to content

Commit 0f413f2

Browse files
authored
Merge pull request Talenttrust#136 from njohnchi/feature/backend-26-metrics-and-observability
feat: implement metrics and observability with tests and docs
2 parents 1bbeb15 + b2ff056 commit 0f413f2

8 files changed

Lines changed: 625 additions & 0 deletions

File tree

docs/backend/observability.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Backend Observability
2+
3+
This document explains service-level health signaling and Prometheus metrics exposure in `Talenttrust-Backend`.
4+
5+
## Endpoints
6+
7+
### `GET /health/live`
8+
9+
Returns process liveness only.
10+
11+
```json
12+
{
13+
"status": "up",
14+
"service": "talenttrust-backend"
15+
}
16+
```
17+
18+
### `GET /health` and `GET /health/ready`
19+
20+
Returns service-level health with runtime and dependency signals.
21+
22+
```json
23+
{
24+
"service": "talenttrust-backend",
25+
"status": "up",
26+
"timestamp": "2026-03-24T00:00:00.000Z",
27+
"uptimeSeconds": 102.34,
28+
"signals": {
29+
"eventLoopLagMs": 12,
30+
"heapUsedBytes": 23893648,
31+
"heapTotalBytes": 30523392,
32+
"heapUsedRatio": 0.78
33+
},
34+
"dependencies": []
35+
}
36+
```
37+
38+
Status behavior:
39+
40+
- `up`: all local and dependency checks are healthy.
41+
- `degraded`: one or more checks are elevated but still serving.
42+
- `down`: one or more checks are critical. HTTP status is `503`.
43+
44+
### `GET /metrics`
45+
46+
Exposes metrics in Prometheus text format.
47+
48+
If `METRICS_AUTH_TOKEN` is set, requests must include:
49+
50+
```text
51+
Authorization: Bearer <token>
52+
```
53+
54+
If auth is missing/invalid, route returns `401`.
55+
56+
## Configuration
57+
58+
| Variable | Default | Notes |
59+
|---|---|---|
60+
| `PORT` | `3001` | API listener port |
61+
| `SERVICE_NAME` | `talenttrust-backend` | Name used in health payload and metrics labels |
62+
| `METRICS_ENABLED` | `true` | Set to `false` to return `404` on `/metrics` |
63+
| `METRICS_AUTH_TOKEN` | _unset_ | Enables bearer-token protection for `/metrics` |
64+
65+
## Exported Prometheus Metrics
66+
67+
- `http_requests_total{method,route,status_code}`
68+
- `http_request_duration_seconds{method,route,status_code}`
69+
- `service_health_status{service}` (`up=2`, `degraded=1`, `down=0`)
70+
- Node/process default metrics from `prom-client` (prefixed by `<service>_`)
71+
72+
## Security and Threat Notes
73+
74+
### Threat: unauthorized scraping of operational details
75+
76+
Mitigation:
77+
78+
- Token-gate `/metrics` with `METRICS_AUTH_TOKEN`.
79+
- Restrict route at network boundary (ingress, WAF, service mesh) to trusted scrapers only.
80+
81+
### Threat: high cardinality metrics causing memory growth
82+
83+
Mitigation:
84+
85+
- Route labels use bounded path values from Express route templates.
86+
- No request payload, IDs, or user-provided fields are added as labels.
87+
88+
### Threat: health endpoint leaking secrets
89+
90+
Mitigation:
91+
92+
- Health responses contain only runtime capacity indicators and dependency status.
93+
- Avoid including credentials or raw stack traces in dependency details.
94+
95+
## Prometheus scrape example
96+
97+
```yaml
98+
scrape_configs:
99+
- job_name: talenttrust-backend
100+
metrics_path: /metrics
101+
static_configs:
102+
- targets: ['talenttrust-backend:3001']
103+
authorization:
104+
type: Bearer
105+
credentials: ${METRICS_AUTH_TOKEN}
106+
```
107+
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
defaultThresholds,
3+
HealthService,
4+
RuntimeSignalProviders,
5+
} from './health-service';
6+
import { DependencyChecker } from './types';
7+
8+
function createProviders(overrides: Partial<RuntimeSignalProviders> = {}): RuntimeSignalProviders {
9+
return {
10+
now: () => new Date('2026-03-24T00:00:00.000Z'),
11+
uptimeSeconds: () => 42,
12+
eventLoopLagMs: () => 25,
13+
memoryUsage: () => ({
14+
rss: 10,
15+
heapTotal: 100,
16+
heapUsed: 45,
17+
external: 1,
18+
arrayBuffers: 1,
19+
}),
20+
...overrides,
21+
};
22+
}
23+
24+
describe('HealthService', () => {
25+
it('returns degraded when event loop lag crosses degraded threshold', async () => {
26+
const service = new HealthService(
27+
'talenttrust-backend',
28+
[],
29+
createProviders({
30+
eventLoopLagMs: () => defaultThresholds.degradedEventLoopLagMs,
31+
}),
32+
);
33+
34+
const report = await service.getReport();
35+
36+
expect(report.status).toBe('degraded');
37+
expect(report.signals.eventLoopLagMs).toBe(defaultThresholds.degradedEventLoopLagMs);
38+
});
39+
40+
it('returns down when memory usage crosses down threshold', async () => {
41+
const service = new HealthService(
42+
'talenttrust-backend',
43+
[],
44+
createProviders({
45+
memoryUsage: () => ({
46+
rss: 10,
47+
heapTotal: 100,
48+
heapUsed: 95,
49+
external: 1,
50+
arrayBuffers: 1,
51+
}),
52+
}),
53+
);
54+
55+
const report = await service.getReport();
56+
57+
expect(report.status).toBe('down');
58+
expect(report.signals.heapUsedRatio).toBe(0.95);
59+
});
60+
61+
it('marks dependency as down when checker throws and keeps error detail', async () => {
62+
const failingDependency: DependencyChecker = {
63+
name: 'database',
64+
check: async () => {
65+
throw new Error('dial timeout');
66+
},
67+
};
68+
69+
const service = new HealthService(
70+
'talenttrust-backend',
71+
[failingDependency],
72+
createProviders(),
73+
);
74+
75+
const report = await service.getReport();
76+
77+
expect(report.status).toBe('down');
78+
expect(report.dependencies).toHaveLength(1);
79+
expect(report.dependencies[0].name).toBe('database');
80+
expect(report.dependencies[0].status).toBe('down');
81+
expect(report.dependencies[0].details).toContain('dial timeout');
82+
});
83+
84+
it('closes provider resources on close()', () => {
85+
const close = jest.fn();
86+
const service = new HealthService('talenttrust-backend', [], createProviders({ close }));
87+
88+
service.close();
89+
90+
expect(close).toHaveBeenCalledTimes(1);
91+
});
92+
});
93+
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { monitorEventLoopDelay } from 'perf_hooks';
2+
3+
import {
4+
DependencyChecker,
5+
DependencyHealth,
6+
HealthReport,
7+
ServiceStatus,
8+
} from './types';
9+
10+
const STATUS_ORDER: Record<ServiceStatus, number> = {
11+
up: 0,
12+
degraded: 1,
13+
down: 2,
14+
};
15+
16+
export interface RuntimeSignalProviders {
17+
now: () => Date;
18+
uptimeSeconds: () => number;
19+
eventLoopLagMs: () => number;
20+
memoryUsage: () => NodeJS.MemoryUsage;
21+
close?: () => void;
22+
}
23+
24+
export interface HealthServiceLike {
25+
getReport: () => Promise<HealthReport>;
26+
close?: () => void;
27+
}
28+
29+
export interface HealthThresholds {
30+
degradedEventLoopLagMs: number;
31+
downEventLoopLagMs: number;
32+
degradedHeapUsedRatio: number;
33+
downHeapUsedRatio: number;
34+
}
35+
36+
export const defaultThresholds: HealthThresholds = {
37+
degradedEventLoopLagMs: 250,
38+
downEventLoopLagMs: 1000,
39+
degradedHeapUsedRatio: 0.85,
40+
downHeapUsedRatio: 0.95,
41+
};
42+
43+
/**
44+
* Computes service-level health from local runtime signals and dependency checks.
45+
*/
46+
export class HealthService implements HealthServiceLike {
47+
constructor(
48+
private readonly serviceName: string,
49+
private readonly dependencyCheckers: DependencyChecker[] = [],
50+
private readonly providers: RuntimeSignalProviders = createDefaultProviders(),
51+
private readonly thresholds: HealthThresholds = defaultThresholds,
52+
) {}
53+
54+
async getReport(): Promise<HealthReport> {
55+
const now = this.providers.now();
56+
const memory = this.providers.memoryUsage();
57+
const eventLoopLagMs = this.providers.eventLoopLagMs();
58+
const heapUsedRatio = memory.heapTotal > 0 ? memory.heapUsed / memory.heapTotal : 0;
59+
const dependencyResults = await Promise.all(
60+
this.dependencyCheckers.map((checker) => evaluateDependency(checker, now.toISOString())),
61+
);
62+
63+
const signalStatuses: ServiceStatus[] = [
64+
evaluateEventLoopStatus(eventLoopLagMs, this.thresholds),
65+
evaluateHeapStatus(heapUsedRatio, this.thresholds),
66+
];
67+
68+
const status = mergeStatuses(
69+
signalStatuses.concat(dependencyResults.map((dependency) => dependency.status)),
70+
);
71+
72+
return {
73+
service: this.serviceName,
74+
status,
75+
timestamp: now.toISOString(),
76+
uptimeSeconds: this.providers.uptimeSeconds(),
77+
signals: {
78+
eventLoopLagMs,
79+
heapUsedBytes: memory.heapUsed,
80+
heapTotalBytes: memory.heapTotal,
81+
heapUsedRatio,
82+
},
83+
dependencies: dependencyResults,
84+
};
85+
}
86+
87+
close(): void {
88+
this.providers.close?.();
89+
}
90+
}
91+
92+
function createDefaultProviders(): RuntimeSignalProviders {
93+
const loopLagMonitor = monitorEventLoopDelay({ resolution: 20 });
94+
loopLagMonitor.enable();
95+
96+
return {
97+
now: () => new Date(),
98+
uptimeSeconds: () => process.uptime(),
99+
eventLoopLagMs: () => {
100+
const lagMs = Number(loopLagMonitor.mean) / 1_000_000;
101+
return Number.isFinite(lagMs) ? lagMs : 0;
102+
},
103+
memoryUsage: () => process.memoryUsage(),
104+
close: () => loopLagMonitor.disable(),
105+
};
106+
}
107+
108+
async function evaluateDependency(
109+
checker: DependencyChecker,
110+
observedAt: string,
111+
): Promise<DependencyHealth> {
112+
try {
113+
const result = await checker.check();
114+
return {
115+
name: checker.name,
116+
status: result.status,
117+
details: result.details,
118+
observedAt,
119+
};
120+
} catch (error) {
121+
return {
122+
name: checker.name,
123+
status: 'down',
124+
details:
125+
error instanceof Error
126+
? `Dependency check failed: ${error.message}`
127+
: 'Dependency check failed',
128+
observedAt,
129+
};
130+
}
131+
}
132+
133+
function evaluateEventLoopStatus(
134+
eventLoopLagMs: number,
135+
thresholds: HealthThresholds,
136+
): ServiceStatus {
137+
if (eventLoopLagMs >= thresholds.downEventLoopLagMs) {
138+
return 'down';
139+
}
140+
141+
if (eventLoopLagMs >= thresholds.degradedEventLoopLagMs) {
142+
return 'degraded';
143+
}
144+
145+
return 'up';
146+
}
147+
148+
function evaluateHeapStatus(
149+
heapUsedRatio: number,
150+
thresholds: HealthThresholds,
151+
): ServiceStatus {
152+
if (heapUsedRatio >= thresholds.downHeapUsedRatio) {
153+
return 'down';
154+
}
155+
156+
if (heapUsedRatio >= thresholds.degradedHeapUsedRatio) {
157+
return 'degraded';
158+
}
159+
160+
return 'up';
161+
}
162+
163+
function mergeStatuses(statuses: ServiceStatus[]): ServiceStatus {
164+
if (statuses.length === 0) {
165+
return 'up';
166+
}
167+
168+
return statuses.reduce((current, next) =>
169+
STATUS_ORDER[next] > STATUS_ORDER[current] ? next : current,
170+
);
171+
}
172+
173+
export function healthReportToHttpStatus(status: ServiceStatus): number {
174+
return status === 'down' ? 503 : 200;
175+
}
176+
177+
178+
179+

0 commit comments

Comments
 (0)