@nage-api/observability 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +141 -0
- package/dist/health.controller.d.ts +61 -0
- package/dist/health.controller.js +141 -0
- package/dist/health.d.ts +43 -0
- package/dist/health.js +102 -0
- package/dist/http.interceptor.d.ts +41 -0
- package/dist/http.interceptor.js +107 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +42 -0
- package/dist/metrics.d.ts +63 -0
- package/dist/metrics.js +168 -0
- package/dist/observability.module.d.ts +33 -0
- package/dist/observability.module.js +93 -0
- package/dist/ports.d.ts +73 -0
- package/dist/ports.js +18 -0
- package/dist/tokens.d.ts +19 -0
- package/dist/tokens.js +19 -0
- package/dist/tracing.d.ts +41 -0
- package/dist/tracing.js +103 -0
- package/package.json +62 -0
package/dist/health.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Readiness and liveness (PLAN.md §21).
|
|
4
|
+
*
|
|
5
|
+
* Liveness answers "is this process wedged?" — it must not touch a dependency,
|
|
6
|
+
* because a database outage restarting every pod turns a degraded system into
|
|
7
|
+
* an outage. Readiness answers "should traffic come here?" and does check
|
|
8
|
+
* dependencies.
|
|
9
|
+
*
|
|
10
|
+
* Two things the legacy framework got wrong and this fixes:
|
|
11
|
+
*
|
|
12
|
+
* **A slow probe is a failing probe.** Every check runs under a timeout, so a
|
|
13
|
+
* dependency that hangs is reported as down in bounded time rather than holding
|
|
14
|
+
* the probe open until the orchestrator's own timeout fires.
|
|
15
|
+
*
|
|
16
|
+
* **Not every dependency is critical.** A cache being down should degrade the
|
|
17
|
+
* report, not fail it — pulling a node out of the load balancer because Redis
|
|
18
|
+
* blinked makes the outage worse.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.HealthRegistry = void 0;
|
|
22
|
+
exports.healthCheck = healthCheck;
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 2000;
|
|
24
|
+
class HealthRegistry {
|
|
25
|
+
#checks = new Map();
|
|
26
|
+
#timeoutMs;
|
|
27
|
+
#clock;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
30
|
+
this.#clock = options.clock ?? { now: () => Date.now() };
|
|
31
|
+
}
|
|
32
|
+
register(check) {
|
|
33
|
+
this.#checks.set(check.name, check);
|
|
34
|
+
return this;
|
|
35
|
+
}
|
|
36
|
+
unregister(name) {
|
|
37
|
+
this.#checks.delete(name);
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
get names() {
|
|
41
|
+
return [...this.#checks.keys()];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run every check.
|
|
45
|
+
*
|
|
46
|
+
* Concurrently, because readiness is polled every few seconds and running ten
|
|
47
|
+
* probes in series makes the probe itself the slow dependency.
|
|
48
|
+
*/
|
|
49
|
+
async check() {
|
|
50
|
+
const results = await Promise.all([...this.#checks.values()].map((check) => this.#run(check)));
|
|
51
|
+
const critical = [...this.#checks.values()].filter((check) => check.critical !== false);
|
|
52
|
+
const criticalNames = new Set(critical.map((check) => check.name));
|
|
53
|
+
const failedCritical = results.some((result) => result.status === 'down' && criticalNames.has(result.name));
|
|
54
|
+
const failedOptional = results.some((result) => result.status === 'down');
|
|
55
|
+
const status = failedCritical ? 'down' : failedOptional ? 'degraded' : 'up';
|
|
56
|
+
return { status, checks: results };
|
|
57
|
+
}
|
|
58
|
+
async #run(check) {
|
|
59
|
+
const startedAt = this.#clock.now();
|
|
60
|
+
let timer;
|
|
61
|
+
try {
|
|
62
|
+
await Promise.race([
|
|
63
|
+
check.check(),
|
|
64
|
+
new Promise((_resolve, reject) => {
|
|
65
|
+
timer = setTimeout(() => {
|
|
66
|
+
reject(new Error(`timed out after ${String(this.#timeoutMs)}ms`));
|
|
67
|
+
}, this.#timeoutMs);
|
|
68
|
+
timer.unref();
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
return { name: check.name, status: 'up', durationMs: this.#clock.now() - startedAt };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
return {
|
|
75
|
+
name: check.name,
|
|
76
|
+
status: 'down',
|
|
77
|
+
durationMs: this.#clock.now() - startedAt,
|
|
78
|
+
// Operator-facing. The controller decides what an unauthenticated
|
|
79
|
+
// caller sees, and by default it is nothing but the status.
|
|
80
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
// Always cleared: a pending timer per probe, every few seconds, is a leak
|
|
85
|
+
// that only shows up after a week of uptime.
|
|
86
|
+
if (timer !== undefined)
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
exports.HealthRegistry = HealthRegistry;
|
|
92
|
+
/** Build a check from any async probe — a `SELECT 1`, a `PING`, a HEAD request. */
|
|
93
|
+
function healthCheck(name, probe, options = {}) {
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
...(options.critical === undefined ? {} : { critical: options.critical }),
|
|
97
|
+
check: async () => {
|
|
98
|
+
await probe();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One interceptor that times every request, counts it, and traces it
|
|
3
|
+
* (PLAN.md §18, §21).
|
|
4
|
+
*
|
|
5
|
+
* The route **template** is the metric label, never the resolved path:
|
|
6
|
+
* `/orders/:id` is one time series, `/orders/1`…`/orders/9999999` is a
|
|
7
|
+
* cardinality explosion that eventually takes the scraper down. That single
|
|
8
|
+
* decision is most of what makes HTTP metrics safe to leave on.
|
|
9
|
+
*/
|
|
10
|
+
import { type CallHandler, type ExecutionContext, type NestInterceptor } from '@nestjs/common';
|
|
11
|
+
import { type Observable } from 'rxjs';
|
|
12
|
+
import type { Clock, MetricsPort, TracerPort } from './ports.js';
|
|
13
|
+
interface RequestLike {
|
|
14
|
+
method?: string;
|
|
15
|
+
route?: {
|
|
16
|
+
path?: string;
|
|
17
|
+
};
|
|
18
|
+
url?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface HttpObservabilityOptions {
|
|
21
|
+
readonly metrics: MetricsPort;
|
|
22
|
+
readonly tracer: TracerPort;
|
|
23
|
+
readonly clock?: Clock;
|
|
24
|
+
}
|
|
25
|
+
export declare const HTTP_REQUESTS_TOTAL = "nage_http_requests_total";
|
|
26
|
+
export declare const HTTP_DURATION_MS = "nage_http_request_duration_ms";
|
|
27
|
+
export declare const HTTP_IN_FLIGHT = "nage_http_requests_in_flight";
|
|
28
|
+
export declare class HttpObservabilityInterceptor implements NestInterceptor {
|
|
29
|
+
#private;
|
|
30
|
+
constructor(options: HttpObservabilityOptions);
|
|
31
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The route template, or `unknown`.
|
|
35
|
+
*
|
|
36
|
+
* Never the resolved URL: one series per id is how a metrics endpoint grows
|
|
37
|
+
* without bound.
|
|
38
|
+
*/
|
|
39
|
+
export declare function routeTemplate(request: RequestLike): string;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=http.interceptor.d.ts.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* One interceptor that times every request, counts it, and traces it
|
|
4
|
+
* (PLAN.md §18, §21).
|
|
5
|
+
*
|
|
6
|
+
* The route **template** is the metric label, never the resolved path:
|
|
7
|
+
* `/orders/:id` is one time series, `/orders/1`…`/orders/9999999` is a
|
|
8
|
+
* cardinality explosion that eventually takes the scraper down. That single
|
|
9
|
+
* decision is most of what makes HTTP metrics safe to leave on.
|
|
10
|
+
*/
|
|
11
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
12
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
13
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
14
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
15
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
16
|
+
};
|
|
17
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
18
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.HttpObservabilityInterceptor = exports.HTTP_IN_FLIGHT = exports.HTTP_DURATION_MS = exports.HTTP_REQUESTS_TOTAL = void 0;
|
|
22
|
+
exports.routeTemplate = routeTemplate;
|
|
23
|
+
const common_1 = require("@nestjs/common");
|
|
24
|
+
const rxjs_1 = require("rxjs");
|
|
25
|
+
exports.HTTP_REQUESTS_TOTAL = 'nage_http_requests_total';
|
|
26
|
+
exports.HTTP_DURATION_MS = 'nage_http_request_duration_ms';
|
|
27
|
+
exports.HTTP_IN_FLIGHT = 'nage_http_requests_in_flight';
|
|
28
|
+
let HttpObservabilityInterceptor = class HttpObservabilityInterceptor {
|
|
29
|
+
#metrics;
|
|
30
|
+
#tracer;
|
|
31
|
+
#clock;
|
|
32
|
+
#inFlight = 0;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.#metrics = options.metrics;
|
|
35
|
+
this.#tracer = options.tracer;
|
|
36
|
+
this.#clock = options.clock ?? { now: () => Date.now() };
|
|
37
|
+
}
|
|
38
|
+
intercept(context, next) {
|
|
39
|
+
if (context.getType() !== 'http')
|
|
40
|
+
return next.handle();
|
|
41
|
+
const http = context.switchToHttp();
|
|
42
|
+
const request = http.getRequest();
|
|
43
|
+
const method = request.method ?? 'GET';
|
|
44
|
+
const route = routeTemplate(request);
|
|
45
|
+
const startedAt = this.#clock.now();
|
|
46
|
+
this.#inFlight += 1;
|
|
47
|
+
this.#metrics.gauge(exports.HTTP_IN_FLIGHT, this.#inFlight);
|
|
48
|
+
const span = this.#tracer.startSpan(`${method} ${route}`, {
|
|
49
|
+
kind: 'server',
|
|
50
|
+
attributes: { 'http.method': method, 'http.route': route },
|
|
51
|
+
});
|
|
52
|
+
let completed = false;
|
|
53
|
+
const complete = (status) => {
|
|
54
|
+
// One request, however many values the handler emits. A route that returns
|
|
55
|
+
// a multi-value observable — an SSE stream, say — otherwise decrements the
|
|
56
|
+
// in-flight gauge once per emission and drives it negative, while counting
|
|
57
|
+
// one request as several.
|
|
58
|
+
if (completed)
|
|
59
|
+
return;
|
|
60
|
+
completed = true;
|
|
61
|
+
this.#inFlight -= 1;
|
|
62
|
+
this.#metrics.gauge(exports.HTTP_IN_FLIGHT, this.#inFlight);
|
|
63
|
+
const labels = { method, route, status: String(status) };
|
|
64
|
+
this.#metrics.increment(exports.HTTP_REQUESTS_TOTAL, 1, labels);
|
|
65
|
+
this.#metrics.observe(exports.HTTP_DURATION_MS, this.#clock.now() - startedAt, { method, route });
|
|
66
|
+
span.setAttribute('http.status_code', status);
|
|
67
|
+
span.end();
|
|
68
|
+
};
|
|
69
|
+
return next.handle().pipe((0, rxjs_1.tap)(() => {
|
|
70
|
+
complete(http.getResponse().statusCode ?? 200);
|
|
71
|
+
}), (0, rxjs_1.catchError)((error) => {
|
|
72
|
+
span.recordError(error);
|
|
73
|
+
// The filter has not run yet, so the response carries no status: take
|
|
74
|
+
// it from the error where it has one, and fall back to 500.
|
|
75
|
+
complete(statusOf(error));
|
|
76
|
+
return (0, rxjs_1.throwError)(() => error);
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
exports.HttpObservabilityInterceptor = HttpObservabilityInterceptor;
|
|
81
|
+
exports.HttpObservabilityInterceptor = HttpObservabilityInterceptor = __decorate([
|
|
82
|
+
(0, common_1.Injectable)(),
|
|
83
|
+
__metadata("design:paramtypes", [Object])
|
|
84
|
+
], HttpObservabilityInterceptor);
|
|
85
|
+
/**
|
|
86
|
+
* The route template, or `unknown`.
|
|
87
|
+
*
|
|
88
|
+
* Never the resolved URL: one series per id is how a metrics endpoint grows
|
|
89
|
+
* without bound.
|
|
90
|
+
*/
|
|
91
|
+
function routeTemplate(request) {
|
|
92
|
+
const path = request.route?.path;
|
|
93
|
+
if (typeof path === 'string' && path !== '')
|
|
94
|
+
return path;
|
|
95
|
+
return 'unknown';
|
|
96
|
+
}
|
|
97
|
+
function statusOf(error) {
|
|
98
|
+
if (typeof error !== 'object' || error === null)
|
|
99
|
+
return 500;
|
|
100
|
+
const candidate = error;
|
|
101
|
+
if (typeof candidate.httpStatus === 'number')
|
|
102
|
+
return candidate.httpStatus;
|
|
103
|
+
if (typeof candidate.status === 'number')
|
|
104
|
+
return candidate.status;
|
|
105
|
+
return 500;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=http.interceptor.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nage-api/observability` — logging, tracing, metrics and health (PLAN.md §18,
|
|
3
|
+
* §21, §25 P1).
|
|
4
|
+
*
|
|
5
|
+
* Ports with no-op defaults, so the framework's own instrumentation costs
|
|
6
|
+
* nothing when nobody is collecting, and an application that wants OpenTelemetry
|
|
7
|
+
* or Prometheus binds an adapter without a caller changing.
|
|
8
|
+
*/
|
|
9
|
+
export type * from '@nage-api/contracts';
|
|
10
|
+
export { NageObservabilityModule, type NageObservabilityModuleOptions, } from './observability.module.js';
|
|
11
|
+
export { HealthRegistry, healthCheck, type HealthRegistryOptions } from './health.js';
|
|
12
|
+
export { HealthController, MetricsController, type PublicHealthCheck, type PublicHealthReport, } from './health.controller.js';
|
|
13
|
+
export { DEFAULT_BUCKETS, MetricsRegistry, NoopMetrics, renderLabels, type MetricSample, type MetricType, type MetricsRegistryOptions, } from './metrics.js';
|
|
14
|
+
export { NoopTracer, RecordingTracer, type RecordedSpan, type RecordingTracerOptions, } from './tracing.js';
|
|
15
|
+
export { HttpObservabilityInterceptor, HTTP_DURATION_MS, HTTP_IN_FLIGHT, HTTP_REQUESTS_TOTAL, routeTemplate, type HttpObservabilityOptions, } from './http.interceptor.js';
|
|
16
|
+
export { NAGE_HEALTH, NAGE_HEALTH_EXPOSE_DETAIL, NAGE_METRICS, NAGE_METRICS_REGISTRY, NAGE_TRACER, } from './tokens.js';
|
|
17
|
+
export { systemClock, type Clock, type HealthCheck, type HealthCheckResult, type HealthReport, type HealthStatus, type MetricLabels, type MetricsPort, type Span, type SpanOptions, type TracerPort, } from './ports.js';
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `@nage-api/observability` — logging, tracing, metrics and health (PLAN.md §18,
|
|
4
|
+
* §21, §25 P1).
|
|
5
|
+
*
|
|
6
|
+
* Ports with no-op defaults, so the framework's own instrumentation costs
|
|
7
|
+
* nothing when nobody is collecting, and an application that wants OpenTelemetry
|
|
8
|
+
* or Prometheus binds an adapter without a caller changing.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.systemClock = exports.NAGE_TRACER = exports.NAGE_METRICS_REGISTRY = exports.NAGE_METRICS = exports.NAGE_HEALTH_EXPOSE_DETAIL = exports.NAGE_HEALTH = exports.routeTemplate = exports.HTTP_REQUESTS_TOTAL = exports.HTTP_IN_FLIGHT = exports.HTTP_DURATION_MS = exports.HttpObservabilityInterceptor = exports.RecordingTracer = exports.NoopTracer = exports.renderLabels = exports.NoopMetrics = exports.MetricsRegistry = exports.DEFAULT_BUCKETS = exports.MetricsController = exports.HealthController = exports.healthCheck = exports.HealthRegistry = exports.NageObservabilityModule = void 0;
|
|
12
|
+
var observability_module_js_1 = require("./observability.module.js");
|
|
13
|
+
Object.defineProperty(exports, "NageObservabilityModule", { enumerable: true, get: function () { return observability_module_js_1.NageObservabilityModule; } });
|
|
14
|
+
var health_js_1 = require("./health.js");
|
|
15
|
+
Object.defineProperty(exports, "HealthRegistry", { enumerable: true, get: function () { return health_js_1.HealthRegistry; } });
|
|
16
|
+
Object.defineProperty(exports, "healthCheck", { enumerable: true, get: function () { return health_js_1.healthCheck; } });
|
|
17
|
+
var health_controller_js_1 = require("./health.controller.js");
|
|
18
|
+
Object.defineProperty(exports, "HealthController", { enumerable: true, get: function () { return health_controller_js_1.HealthController; } });
|
|
19
|
+
Object.defineProperty(exports, "MetricsController", { enumerable: true, get: function () { return health_controller_js_1.MetricsController; } });
|
|
20
|
+
var metrics_js_1 = require("./metrics.js");
|
|
21
|
+
Object.defineProperty(exports, "DEFAULT_BUCKETS", { enumerable: true, get: function () { return metrics_js_1.DEFAULT_BUCKETS; } });
|
|
22
|
+
Object.defineProperty(exports, "MetricsRegistry", { enumerable: true, get: function () { return metrics_js_1.MetricsRegistry; } });
|
|
23
|
+
Object.defineProperty(exports, "NoopMetrics", { enumerable: true, get: function () { return metrics_js_1.NoopMetrics; } });
|
|
24
|
+
Object.defineProperty(exports, "renderLabels", { enumerable: true, get: function () { return metrics_js_1.renderLabels; } });
|
|
25
|
+
var tracing_js_1 = require("./tracing.js");
|
|
26
|
+
Object.defineProperty(exports, "NoopTracer", { enumerable: true, get: function () { return tracing_js_1.NoopTracer; } });
|
|
27
|
+
Object.defineProperty(exports, "RecordingTracer", { enumerable: true, get: function () { return tracing_js_1.RecordingTracer; } });
|
|
28
|
+
var http_interceptor_js_1 = require("./http.interceptor.js");
|
|
29
|
+
Object.defineProperty(exports, "HttpObservabilityInterceptor", { enumerable: true, get: function () { return http_interceptor_js_1.HttpObservabilityInterceptor; } });
|
|
30
|
+
Object.defineProperty(exports, "HTTP_DURATION_MS", { enumerable: true, get: function () { return http_interceptor_js_1.HTTP_DURATION_MS; } });
|
|
31
|
+
Object.defineProperty(exports, "HTTP_IN_FLIGHT", { enumerable: true, get: function () { return http_interceptor_js_1.HTTP_IN_FLIGHT; } });
|
|
32
|
+
Object.defineProperty(exports, "HTTP_REQUESTS_TOTAL", { enumerable: true, get: function () { return http_interceptor_js_1.HTTP_REQUESTS_TOTAL; } });
|
|
33
|
+
Object.defineProperty(exports, "routeTemplate", { enumerable: true, get: function () { return http_interceptor_js_1.routeTemplate; } });
|
|
34
|
+
var tokens_js_1 = require("./tokens.js");
|
|
35
|
+
Object.defineProperty(exports, "NAGE_HEALTH", { enumerable: true, get: function () { return tokens_js_1.NAGE_HEALTH; } });
|
|
36
|
+
Object.defineProperty(exports, "NAGE_HEALTH_EXPOSE_DETAIL", { enumerable: true, get: function () { return tokens_js_1.NAGE_HEALTH_EXPOSE_DETAIL; } });
|
|
37
|
+
Object.defineProperty(exports, "NAGE_METRICS", { enumerable: true, get: function () { return tokens_js_1.NAGE_METRICS; } });
|
|
38
|
+
Object.defineProperty(exports, "NAGE_METRICS_REGISTRY", { enumerable: true, get: function () { return tokens_js_1.NAGE_METRICS_REGISTRY; } });
|
|
39
|
+
Object.defineProperty(exports, "NAGE_TRACER", { enumerable: true, get: function () { return tokens_js_1.NAGE_TRACER; } });
|
|
40
|
+
var ports_js_1 = require("./ports.js");
|
|
41
|
+
Object.defineProperty(exports, "systemClock", { enumerable: true, get: function () { return ports_js_1.systemClock; } });
|
|
42
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-process metrics registry with Prometheus text exposition
|
|
3
|
+
* (PLAN.md §21).
|
|
4
|
+
*
|
|
5
|
+
* Small on purpose. A metrics client is not where a framework should spend a
|
|
6
|
+
* dependency: the exposition format is a dozen lines, and everything harder
|
|
7
|
+
* about metrics — cardinality, retention, aggregation — belongs to whatever
|
|
8
|
+
* scrapes them.
|
|
9
|
+
*
|
|
10
|
+
* The one thing this file takes seriously is **cardinality**. A label whose
|
|
11
|
+
* value comes from a request path or a user id creates a new time series per
|
|
12
|
+
* value, and a metrics endpoint that grows without bound will eventually take
|
|
13
|
+
* the scraper down with it. The registry caps series per metric and reports the
|
|
14
|
+
* overflow rather than silently dropping or silently growing.
|
|
15
|
+
*/
|
|
16
|
+
import type { MetricLabels, MetricsPort } from './ports.js';
|
|
17
|
+
export type MetricType = 'counter' | 'gauge' | 'histogram';
|
|
18
|
+
export interface MetricSample {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly type: MetricType;
|
|
21
|
+
readonly labels: MetricLabels;
|
|
22
|
+
readonly value: number;
|
|
23
|
+
/** Histograms only: observation count and sum, for computing an average. */
|
|
24
|
+
readonly count?: number;
|
|
25
|
+
readonly sum?: number;
|
|
26
|
+
readonly buckets?: Readonly<Record<string, number>>;
|
|
27
|
+
}
|
|
28
|
+
export interface MetricsRegistryOptions {
|
|
29
|
+
/** Distinct label combinations kept per metric name. */
|
|
30
|
+
readonly maxSeriesPerMetric?: number;
|
|
31
|
+
/** Histogram bucket boundaries, in the metric's own unit. */
|
|
32
|
+
readonly buckets?: readonly number[];
|
|
33
|
+
}
|
|
34
|
+
/** Latency buckets in milliseconds, spanning "fast" to "something is wrong". */
|
|
35
|
+
export declare const DEFAULT_BUCKETS: readonly number[];
|
|
36
|
+
export declare class MetricsRegistry implements MetricsPort {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(options?: MetricsRegistryOptions);
|
|
39
|
+
increment(name: string, value?: number, labels?: MetricLabels): void;
|
|
40
|
+
gauge(name: string, value: number, labels?: MetricLabels): void;
|
|
41
|
+
observe(name: string, value: number, labels?: MetricLabels): void;
|
|
42
|
+
/** Every series currently held, for tests and for a JSON endpoint. */
|
|
43
|
+
snapshot(): readonly MetricSample[];
|
|
44
|
+
/** Samples dropped because a metric exceeded its series budget. */
|
|
45
|
+
get droppedSeries(): number;
|
|
46
|
+
reset(): void;
|
|
47
|
+
/** Prometheus text exposition, ready to serve from `/metrics`. */
|
|
48
|
+
render(): string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A metrics port that discards everything; the default when metrics are off.
|
|
52
|
+
*
|
|
53
|
+
* The parameters are declared even though they are unused, so a caller written
|
|
54
|
+
* against `MetricsPort` type-checks against this implementation too.
|
|
55
|
+
*/
|
|
56
|
+
export declare class NoopMetrics implements MetricsPort {
|
|
57
|
+
increment(_name: string, _value?: number, _labels?: MetricLabels): void;
|
|
58
|
+
gauge(_name: string, _value: number, _labels?: MetricLabels): void;
|
|
59
|
+
observe(_name: string, _value: number, _labels?: MetricLabels): void;
|
|
60
|
+
}
|
|
61
|
+
/** Prometheus label syntax, with values escaped. */
|
|
62
|
+
export declare function renderLabels(labels: MetricLabels): string;
|
|
63
|
+
//# sourceMappingURL=metrics.d.ts.map
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* An in-process metrics registry with Prometheus text exposition
|
|
4
|
+
* (PLAN.md §21).
|
|
5
|
+
*
|
|
6
|
+
* Small on purpose. A metrics client is not where a framework should spend a
|
|
7
|
+
* dependency: the exposition format is a dozen lines, and everything harder
|
|
8
|
+
* about metrics — cardinality, retention, aggregation — belongs to whatever
|
|
9
|
+
* scrapes them.
|
|
10
|
+
*
|
|
11
|
+
* The one thing this file takes seriously is **cardinality**. A label whose
|
|
12
|
+
* value comes from a request path or a user id creates a new time series per
|
|
13
|
+
* value, and a metrics endpoint that grows without bound will eventually take
|
|
14
|
+
* the scraper down with it. The registry caps series per metric and reports the
|
|
15
|
+
* overflow rather than silently dropping or silently growing.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.NoopMetrics = exports.MetricsRegistry = exports.DEFAULT_BUCKETS = void 0;
|
|
19
|
+
exports.renderLabels = renderLabels;
|
|
20
|
+
/** Latency buckets in milliseconds, spanning "fast" to "something is wrong". */
|
|
21
|
+
exports.DEFAULT_BUCKETS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
|
|
22
|
+
const DEFAULT_MAX_SERIES = 1000;
|
|
23
|
+
class MetricsRegistry {
|
|
24
|
+
#series = new Map();
|
|
25
|
+
#seriesPerMetric = new Map();
|
|
26
|
+
#maxSeriesPerMetric;
|
|
27
|
+
#buckets;
|
|
28
|
+
#dropped = 0;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.#maxSeriesPerMetric = options.maxSeriesPerMetric ?? DEFAULT_MAX_SERIES;
|
|
31
|
+
this.#buckets = options.buckets ?? exports.DEFAULT_BUCKETS;
|
|
32
|
+
}
|
|
33
|
+
increment(name, value = 1, labels = {}) {
|
|
34
|
+
const series = this.#series0(name, 'counter', labels);
|
|
35
|
+
if (series === undefined)
|
|
36
|
+
return;
|
|
37
|
+
// A counter only ever goes up; a negative increment is a bug in the caller
|
|
38
|
+
// that would otherwise corrupt every rate computed from it.
|
|
39
|
+
series.value += Math.max(0, value);
|
|
40
|
+
}
|
|
41
|
+
gauge(name, value, labels = {}) {
|
|
42
|
+
const series = this.#series0(name, 'gauge', labels);
|
|
43
|
+
if (series === undefined)
|
|
44
|
+
return;
|
|
45
|
+
series.value = value;
|
|
46
|
+
}
|
|
47
|
+
observe(name, value, labels = {}) {
|
|
48
|
+
const series = this.#series0(name, 'histogram', labels);
|
|
49
|
+
if (series === undefined)
|
|
50
|
+
return;
|
|
51
|
+
series.count += 1;
|
|
52
|
+
series.sum += value;
|
|
53
|
+
for (const boundary of this.#buckets) {
|
|
54
|
+
if (value <= boundary) {
|
|
55
|
+
series.buckets.set(boundary, (series.buckets.get(boundary) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Every series currently held, for tests and for a JSON endpoint. */
|
|
60
|
+
snapshot() {
|
|
61
|
+
return [...this.#series.entries()].map(([key, series]) => ({
|
|
62
|
+
name: key.includes('{') ? key.slice(0, key.indexOf('{')) : key,
|
|
63
|
+
type: series.type,
|
|
64
|
+
labels: series.labels,
|
|
65
|
+
value: series.type === 'histogram' ? series.sum : series.value,
|
|
66
|
+
...(series.type === 'histogram'
|
|
67
|
+
? {
|
|
68
|
+
count: series.count,
|
|
69
|
+
sum: series.sum,
|
|
70
|
+
buckets: Object.fromEntries([...series.buckets].map(([boundary, count]) => [String(boundary), count])),
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/** Samples dropped because a metric exceeded its series budget. */
|
|
76
|
+
get droppedSeries() {
|
|
77
|
+
return this.#dropped;
|
|
78
|
+
}
|
|
79
|
+
reset() {
|
|
80
|
+
this.#series.clear();
|
|
81
|
+
this.#seriesPerMetric.clear();
|
|
82
|
+
this.#dropped = 0;
|
|
83
|
+
}
|
|
84
|
+
/** Prometheus text exposition, ready to serve from `/metrics`. */
|
|
85
|
+
render() {
|
|
86
|
+
const lines = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
for (const [key, series] of this.#series) {
|
|
89
|
+
const name = key.includes('{') ? key.slice(0, key.indexOf('{')) : key;
|
|
90
|
+
if (!seen.has(name)) {
|
|
91
|
+
seen.add(name);
|
|
92
|
+
lines.push(`# TYPE ${name} ${series.type}`);
|
|
93
|
+
}
|
|
94
|
+
const labels = renderLabels(series.labels);
|
|
95
|
+
if (series.type === 'histogram') {
|
|
96
|
+
for (const boundary of this.#buckets) {
|
|
97
|
+
const bucketLabels = renderLabels({ ...series.labels, le: String(boundary) });
|
|
98
|
+
lines.push(`${name}_bucket${bucketLabels} ${String(series.buckets.get(boundary) ?? 0)}`);
|
|
99
|
+
}
|
|
100
|
+
// `+Inf` is required by the format and equals the total observation count.
|
|
101
|
+
lines.push(`${name}_bucket${renderLabels({ ...series.labels, le: '+Inf' })} ${String(series.count)}`);
|
|
102
|
+
lines.push(`${name}_sum${labels} ${String(series.sum)}`);
|
|
103
|
+
lines.push(`${name}_count${labels} ${String(series.count)}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
lines.push(`${name}${labels} ${String(series.value)}`);
|
|
107
|
+
}
|
|
108
|
+
if (this.#dropped > 0) {
|
|
109
|
+
lines.push('# TYPE nage_metrics_dropped_total counter');
|
|
110
|
+
lines.push(`nage_metrics_dropped_total ${String(this.#dropped)}`);
|
|
111
|
+
}
|
|
112
|
+
return `${lines.join('\n')}\n`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Find or create a series, refusing to exceed the per-metric budget.
|
|
116
|
+
*
|
|
117
|
+
* Named with a trailing `0` because `#series` is the map; this returns one
|
|
118
|
+
* entry from it.
|
|
119
|
+
*/
|
|
120
|
+
#series0(name, type, labels) {
|
|
121
|
+
const key = `${name}${renderLabels(labels)}`;
|
|
122
|
+
const existing = this.#series.get(key);
|
|
123
|
+
if (existing !== undefined)
|
|
124
|
+
return existing;
|
|
125
|
+
const count = this.#seriesPerMetric.get(name) ?? 0;
|
|
126
|
+
if (count >= this.#maxSeriesPerMetric) {
|
|
127
|
+
// Reported, not silent: a metrics endpoint that grows without bound takes
|
|
128
|
+
// the scraper down with it, and the operator should see why.
|
|
129
|
+
this.#dropped += 1;
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
const series = { type, labels, value: 0, count: 0, sum: 0, buckets: new Map() };
|
|
133
|
+
this.#series.set(key, series);
|
|
134
|
+
this.#seriesPerMetric.set(name, count + 1);
|
|
135
|
+
return series;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
exports.MetricsRegistry = MetricsRegistry;
|
|
139
|
+
/**
|
|
140
|
+
* A metrics port that discards everything; the default when metrics are off.
|
|
141
|
+
*
|
|
142
|
+
* The parameters are declared even though they are unused, so a caller written
|
|
143
|
+
* against `MetricsPort` type-checks against this implementation too.
|
|
144
|
+
*/
|
|
145
|
+
class NoopMetrics {
|
|
146
|
+
increment(_name, _value, _labels) {
|
|
147
|
+
// Intentionally empty.
|
|
148
|
+
}
|
|
149
|
+
gauge(_name, _value, _labels) {
|
|
150
|
+
// Intentionally empty.
|
|
151
|
+
}
|
|
152
|
+
observe(_name, _value, _labels) {
|
|
153
|
+
// Intentionally empty.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
exports.NoopMetrics = NoopMetrics;
|
|
157
|
+
/** Prometheus label syntax, with values escaped. */
|
|
158
|
+
function renderLabels(labels) {
|
|
159
|
+
const entries = Object.entries(labels).sort(([left], [right]) => left.localeCompare(right));
|
|
160
|
+
if (entries.length === 0)
|
|
161
|
+
return '';
|
|
162
|
+
const rendered = entries.map(([key, value]) => `${key}="${escapeLabelValue(value)}"`);
|
|
163
|
+
return `{${rendered.join(',')}}`;
|
|
164
|
+
}
|
|
165
|
+
function escapeLabelValue(value) {
|
|
166
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=metrics.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NageObservabilityModule.forRoot(config)` (PLAN.md §11.1 item 6, §21).
|
|
3
|
+
*
|
|
4
|
+
* Each of the three concerns is independently switchable, and each contributes
|
|
5
|
+
* **no provider** when it is off — a metrics registry nobody scrapes is still a
|
|
6
|
+
* map that grows.
|
|
7
|
+
*
|
|
8
|
+
* Health is the exception: it is always registered, because liveness and
|
|
9
|
+
* readiness are not optional in a deployed system, and a probe endpoint that
|
|
10
|
+
* exists only when a feature flag says so is a probe that will be missing on
|
|
11
|
+
* the day it matters.
|
|
12
|
+
*/
|
|
13
|
+
import { type DynamicModule } from '@nestjs/common';
|
|
14
|
+
import type { ObservabilityConfig } from '@nage-api/contracts';
|
|
15
|
+
import type { Clock, HealthCheck, MetricsPort, TracerPort } from './ports.js';
|
|
16
|
+
export interface NageObservabilityModuleOptions {
|
|
17
|
+
readonly observability?: ObservabilityConfig;
|
|
18
|
+
/** Bind a real tracer — an OpenTelemetry adapter, say. */
|
|
19
|
+
readonly tracer?: TracerPort;
|
|
20
|
+
/** Bind a different metrics sink; the built-in registry is the default. */
|
|
21
|
+
readonly metrics?: MetricsPort;
|
|
22
|
+
/** Readiness probes contributed by the application and its drivers. */
|
|
23
|
+
readonly checks?: readonly HealthCheck[];
|
|
24
|
+
/** Per-probe timeout; a check that has not answered by then is down. */
|
|
25
|
+
readonly healthTimeoutMs?: number;
|
|
26
|
+
/** Include each failing check's message in `/health/ready`. Off by default. */
|
|
27
|
+
readonly exposeHealthDetail?: boolean;
|
|
28
|
+
readonly clock?: Clock;
|
|
29
|
+
}
|
|
30
|
+
export declare class NageObservabilityModule {
|
|
31
|
+
static forRoot(options?: NageObservabilityModuleOptions): DynamicModule;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=observability.module.d.ts.map
|