@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.
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ /**
3
+ * `NageObservabilityModule.forRoot(config)` (PLAN.md §11.1 item 6, §21).
4
+ *
5
+ * Each of the three concerns is independently switchable, and each contributes
6
+ * **no provider** when it is off — a metrics registry nobody scrapes is still a
7
+ * map that grows.
8
+ *
9
+ * Health is the exception: it is always registered, because liveness and
10
+ * readiness are not optional in a deployed system, and a probe endpoint that
11
+ * exists only when a feature flag says so is a probe that will be missing on
12
+ * the day it matters.
13
+ */
14
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
15
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
16
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
17
+ 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;
18
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
19
+ };
20
+ var NageObservabilityModule_1;
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.NageObservabilityModule = void 0;
23
+ const common_1 = require("@nestjs/common");
24
+ const core_1 = require("@nestjs/core");
25
+ const health_controller_js_1 = require("./health.controller.js");
26
+ const health_js_1 = require("./health.js");
27
+ const http_interceptor_js_1 = require("./http.interceptor.js");
28
+ const metrics_js_1 = require("./metrics.js");
29
+ const tracing_js_1 = require("./tracing.js");
30
+ const tokens_js_1 = require("./tokens.js");
31
+ let NageObservabilityModule = NageObservabilityModule_1 = class NageObservabilityModule {
32
+ static forRoot(options = {}) {
33
+ const config = options.observability ?? {};
34
+ const health = new health_js_1.HealthRegistry({
35
+ ...(options.healthTimeoutMs === undefined ? {} : { timeoutMs: options.healthTimeoutMs }),
36
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
37
+ });
38
+ for (const check of options.checks ?? [])
39
+ health.register(check);
40
+ const metricsEnabled = config.metrics?.enabled === true;
41
+ const registry = metricsEnabled ? new metrics_js_1.MetricsRegistry() : undefined;
42
+ const metrics = options.metrics ?? registry ?? new metrics_js_1.NoopMetrics();
43
+ const tracingEnabled = config.tracing?.enabled === true;
44
+ // A no-op tracer is not a disabled feature: the framework's own spans are
45
+ // unconditional, and the tracer decides whether they cost anything.
46
+ const tracer = tracingEnabled
47
+ ? (options.tracer ?? new tracing_js_1.NoopTracer())
48
+ : new tracing_js_1.NoopTracer();
49
+ const providers = [
50
+ { provide: tokens_js_1.NAGE_HEALTH, useValue: health },
51
+ { provide: health_js_1.HealthRegistry, useValue: health },
52
+ { provide: tokens_js_1.NAGE_TRACER, useValue: tracer },
53
+ { provide: tokens_js_1.NAGE_METRICS, useValue: metrics },
54
+ { provide: tokens_js_1.NAGE_HEALTH_EXPOSE_DETAIL, useValue: options.exposeHealthDetail ?? false },
55
+ ];
56
+ const controllers = [health_controller_js_1.HealthController];
57
+ if (registry !== undefined) {
58
+ providers.push({ provide: tokens_js_1.NAGE_METRICS_REGISTRY, useValue: registry }, { provide: metrics_js_1.MetricsRegistry, useValue: registry });
59
+ controllers.push(health_controller_js_1.MetricsController);
60
+ }
61
+ // Tracing counts too, not only metrics: the interceptor is the one thing in
62
+ // the framework that opens a request span, so gating it on the registry
63
+ // alone left `tracing.enabled` with nothing to drive and an application's
64
+ // bound adapter silently idle.
65
+ if (registry !== undefined || tracingEnabled) {
66
+ providers.push({
67
+ provide: core_1.APP_INTERCEPTOR,
68
+ useValue: new http_interceptor_js_1.HttpObservabilityInterceptor({
69
+ metrics,
70
+ tracer,
71
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
72
+ }),
73
+ });
74
+ }
75
+ return {
76
+ module: NageObservabilityModule_1,
77
+ controllers,
78
+ providers,
79
+ exports: [
80
+ tokens_js_1.NAGE_HEALTH,
81
+ health_js_1.HealthRegistry,
82
+ tokens_js_1.NAGE_TRACER,
83
+ tokens_js_1.NAGE_METRICS,
84
+ ...(registry === undefined ? [] : [tokens_js_1.NAGE_METRICS_REGISTRY, metrics_js_1.MetricsRegistry]),
85
+ ],
86
+ };
87
+ }
88
+ };
89
+ exports.NageObservabilityModule = NageObservabilityModule;
90
+ exports.NageObservabilityModule = NageObservabilityModule = NageObservabilityModule_1 = __decorate([
91
+ (0, common_1.Module)({})
92
+ ], NageObservabilityModule);
93
+ //# sourceMappingURL=observability.module.js.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Observability ports (PLAN.md §18, §21, §25 P1).
3
+ *
4
+ * Three concerns, three ports, all of which the framework already needs
5
+ * somewhere: `LoggerPort` (already in `@nage-api/contracts`, so logging swaps
6
+ * without a caller changing), tracing, and metrics.
7
+ *
8
+ * OpenTelemetry is a large dependency with a large surface. Rather than take
9
+ * it, this package states the small part of it the framework uses and ships a
10
+ * no-op default; an application that wants real tracing binds an adapter. The
11
+ * framework's own spans then cost nothing when nobody is collecting them, which
12
+ * is the normal case in development and in tests.
13
+ */
14
+ /** A unit of work in a trace. */
15
+ export interface Span {
16
+ /** Attach structured detail; never a credential or a request body. */
17
+ setAttribute(key: string, value: string | number | boolean): void;
18
+ /** Record a failure against the span without ending it. */
19
+ recordError(error: unknown): void;
20
+ end(): void;
21
+ }
22
+ export interface SpanOptions {
23
+ readonly attributes?: Readonly<Record<string, string | number | boolean>>;
24
+ /** `server` for an inbound request, `client` for an outbound call. */
25
+ readonly kind?: 'internal' | 'server' | 'client' | 'producer' | 'consumer';
26
+ }
27
+ export interface TracerPort {
28
+ startSpan(name: string, options?: SpanOptions): Span;
29
+ /** Run `fn` inside a span, ending it — and recording a failure — either way. */
30
+ trace<TResult>(name: string, fn: (span: Span) => Promise<TResult>, options?: SpanOptions): Promise<TResult>;
31
+ }
32
+ /** Label set attached to a metric sample. Cardinality is the caller's problem. */
33
+ export type MetricLabels = Readonly<Record<string, string>>;
34
+ export interface MetricsPort {
35
+ increment(name: string, value?: number, labels?: MetricLabels): void;
36
+ /** Set an absolute value, e.g. a queue depth. */
37
+ gauge(name: string, value: number, labels?: MetricLabels): void;
38
+ /** Record an observation into a histogram, e.g. a request duration. */
39
+ observe(name: string, value: number, labels?: MetricLabels): void;
40
+ }
41
+ /** One dependency's answer to "are you ready?" (PLAN.md §21). */
42
+ export type HealthStatus = 'up' | 'down' | 'degraded';
43
+ export interface HealthCheckResult {
44
+ readonly name: string;
45
+ readonly status: HealthStatus;
46
+ /** Milliseconds the probe took; useful for spotting a slow dependency. */
47
+ readonly durationMs: number;
48
+ /** Operator-facing detail. Never returned to an unauthenticated caller. */
49
+ readonly detail?: string;
50
+ }
51
+ export interface HealthReport {
52
+ readonly status: HealthStatus;
53
+ readonly checks: readonly HealthCheckResult[];
54
+ }
55
+ /**
56
+ * A readiness probe.
57
+ *
58
+ * `critical: false` marks a dependency the application can serve without —
59
+ * a cache, say. It degrades the report rather than failing it, so a Redis
60
+ * outage does not take the whole deployment out of the load balancer.
61
+ */
62
+ export interface HealthCheck {
63
+ readonly name: string;
64
+ readonly critical?: boolean;
65
+ /** Should resolve quickly; the registry applies its own timeout regardless. */
66
+ check(): Promise<void>;
67
+ }
68
+ /** Injected so timings and TTLs are testable without waiting. */
69
+ export interface Clock {
70
+ now(): number;
71
+ }
72
+ export declare const systemClock: Clock;
73
+ //# sourceMappingURL=ports.d.ts.map
package/dist/ports.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ /**
3
+ * Observability ports (PLAN.md §18, §21, §25 P1).
4
+ *
5
+ * Three concerns, three ports, all of which the framework already needs
6
+ * somewhere: `LoggerPort` (already in `@nage-api/contracts`, so logging swaps
7
+ * without a caller changing), tracing, and metrics.
8
+ *
9
+ * OpenTelemetry is a large dependency with a large surface. Rather than take
10
+ * it, this package states the small part of it the framework uses and ships a
11
+ * no-op default; an application that wants real tracing binds an adapter. The
12
+ * framework's own spans then cost nothing when nobody is collecting them, which
13
+ * is the normal case in development and in tests.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.systemClock = void 0;
17
+ exports.systemClock = { now: () => Date.now() };
18
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1,19 @@
1
+ /** DI tokens for the observability ports (PLAN.md §7.3). */
2
+ import { type Token } from '@nage-api/core';
3
+ import type { HealthRegistry } from './health.js';
4
+ import type { MetricsPort, TracerPort } from './ports.js';
5
+ import type { MetricsRegistry } from './metrics.js';
6
+ /** What application code injects to record a metric. */
7
+ export declare const NAGE_METRICS: Token<MetricsPort>;
8
+ /** The concrete registry, for a scrape endpoint or a diagnostic dump. */
9
+ export declare const NAGE_METRICS_REGISTRY: Token<MetricsRegistry>;
10
+ export declare const NAGE_TRACER: Token<TracerPort>;
11
+ export declare const NAGE_HEALTH: Token<HealthRegistry>;
12
+ /**
13
+ * Whether `/health/ready` returns each failing check's message.
14
+ *
15
+ * A token rather than a controller constructor argument, because a probe
16
+ * endpoint is unauthenticated and this is the switch that decides what leaks.
17
+ */
18
+ export declare const NAGE_HEALTH_EXPOSE_DETAIL: Token<boolean>;
19
+ //# sourceMappingURL=tokens.d.ts.map
package/dist/tokens.js ADDED
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ /** DI tokens for the observability ports (PLAN.md §7.3). */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.NAGE_HEALTH_EXPOSE_DETAIL = exports.NAGE_HEALTH = exports.NAGE_TRACER = exports.NAGE_METRICS_REGISTRY = exports.NAGE_METRICS = void 0;
5
+ const core_1 = require("@nage-api/core");
6
+ /** What application code injects to record a metric. */
7
+ exports.NAGE_METRICS = (0, core_1.createToken)('NAGE_METRICS');
8
+ /** The concrete registry, for a scrape endpoint or a diagnostic dump. */
9
+ exports.NAGE_METRICS_REGISTRY = (0, core_1.createToken)('NAGE_METRICS_REGISTRY');
10
+ exports.NAGE_TRACER = (0, core_1.createToken)('NAGE_TRACER');
11
+ exports.NAGE_HEALTH = (0, core_1.createToken)('NAGE_HEALTH');
12
+ /**
13
+ * Whether `/health/ready` returns each failing check's message.
14
+ *
15
+ * A token rather than a controller constructor argument, because a probe
16
+ * endpoint is unauthenticated and this is the switch that decides what leaks.
17
+ */
18
+ exports.NAGE_HEALTH_EXPOSE_DETAIL = (0, core_1.createToken)('NAGE_HEALTH_EXPOSE_DETAIL');
19
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tracing behind a port, with a no-op default (PLAN.md §18).
3
+ *
4
+ * The framework's own instrumentation — a span per request, per query, per
5
+ * outbound call — should cost nothing when nobody is collecting. `NoopTracer`
6
+ * is what makes that true, and it is what runs in development and in tests.
7
+ *
8
+ * `RecordingTracer` is the same interface backed by memory: a test can assert
9
+ * that a span was opened, that it carried the right attributes, and that a
10
+ * failure was recorded on it, without an OTel collector.
11
+ */
12
+ import type { Clock, Span, SpanOptions, TracerPort } from './ports.js';
13
+ /** Discards everything. Allocation-free apart from the shared span object. */
14
+ export declare class NoopTracer implements TracerPort {
15
+ #private;
16
+ startSpan(): Span;
17
+ trace<TResult>(_name: string, fn: (span: Span) => Promise<TResult>): Promise<TResult>;
18
+ }
19
+ export interface RecordedSpan {
20
+ readonly name: string;
21
+ readonly kind: string;
22
+ readonly startedAt: number;
23
+ endedAt?: number;
24
+ readonly attributes: Record<string, string | number | boolean>;
25
+ readonly errors: string[];
26
+ }
27
+ export interface RecordingTracerOptions {
28
+ readonly clock?: Clock;
29
+ /** Keep at most this many spans; a tracer must not become a memory leak. */
30
+ readonly maxSpans?: number;
31
+ }
32
+ export declare class RecordingTracer implements TracerPort {
33
+ #private;
34
+ constructor(options?: RecordingTracerOptions);
35
+ startSpan(name: string, options?: SpanOptions): Span;
36
+ trace<TResult>(name: string, fn: (span: Span) => Promise<TResult>, options?: SpanOptions): Promise<TResult>;
37
+ get spans(): readonly RecordedSpan[];
38
+ find(name: string): RecordedSpan | undefined;
39
+ clear(): void;
40
+ }
41
+ //# sourceMappingURL=tracing.d.ts.map
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ /**
3
+ * Tracing behind a port, with a no-op default (PLAN.md §18).
4
+ *
5
+ * The framework's own instrumentation — a span per request, per query, per
6
+ * outbound call — should cost nothing when nobody is collecting. `NoopTracer`
7
+ * is what makes that true, and it is what runs in development and in tests.
8
+ *
9
+ * `RecordingTracer` is the same interface backed by memory: a test can assert
10
+ * that a span was opened, that it carried the right attributes, and that a
11
+ * failure was recorded on it, without an OTel collector.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.RecordingTracer = exports.NoopTracer = void 0;
15
+ const core_1 = require("@nage-api/core");
16
+ /** Discards everything. Allocation-free apart from the shared span object. */
17
+ class NoopTracer {
18
+ static #span = {
19
+ setAttribute: () => undefined,
20
+ recordError: () => undefined,
21
+ end: () => undefined,
22
+ };
23
+ startSpan() {
24
+ return NoopTracer.#span;
25
+ }
26
+ async trace(_name, fn) {
27
+ return fn(NoopTracer.#span);
28
+ }
29
+ }
30
+ exports.NoopTracer = NoopTracer;
31
+ const DEFAULT_MAX_SPANS = 1000;
32
+ class RecordingTracer {
33
+ #spans = [];
34
+ #clock;
35
+ #maxSpans;
36
+ constructor(options = {}) {
37
+ this.#clock = options.clock ?? { now: () => Date.now() };
38
+ this.#maxSpans = options.maxSpans ?? DEFAULT_MAX_SPANS;
39
+ }
40
+ startSpan(name, options = {}) {
41
+ const recorded = {
42
+ name,
43
+ kind: options.kind ?? 'internal',
44
+ startedAt: this.#clock.now(),
45
+ attributes: { ...options.attributes },
46
+ errors: [],
47
+ };
48
+ // The correlation id is what ties a span to the log lines and the response
49
+ // envelope for the same request, so it is attached rather than left to
50
+ // every caller to remember.
51
+ const requestId = (0, core_1.getActiveContext)()?.requestId;
52
+ if (requestId !== undefined)
53
+ recorded.attributes['request.id'] = requestId;
54
+ this.#spans.push(recorded);
55
+ if (this.#spans.length > this.#maxSpans)
56
+ this.#spans.shift();
57
+ return {
58
+ setAttribute: (key, value) => {
59
+ // Attributes reach a collector a team may not control, so the same
60
+ // redaction that guards log lines guards them.
61
+ recorded.attributes[key] = typeof value === 'string' ? redactValue(key, value) : value;
62
+ },
63
+ recordError: (error) => {
64
+ recorded.errors.push(error instanceof Error ? error.message : String(error));
65
+ },
66
+ end: () => {
67
+ recorded.endedAt = this.#clock.now();
68
+ },
69
+ };
70
+ }
71
+ async trace(name, fn, options = {}) {
72
+ const span = this.startSpan(name, options);
73
+ try {
74
+ return await fn(span);
75
+ }
76
+ catch (error) {
77
+ span.recordError(error);
78
+ throw error;
79
+ }
80
+ finally {
81
+ // In `finally`, so a thrown error still closes the span. An unclosed span
82
+ // is worse than no span: it shows as an operation that never returned.
83
+ span.end();
84
+ }
85
+ }
86
+ get spans() {
87
+ return this.#spans;
88
+ }
89
+ find(name) {
90
+ return this.#spans.find((span) => span.name === name);
91
+ }
92
+ clear() {
93
+ this.#spans.length = 0;
94
+ }
95
+ }
96
+ exports.RecordingTracer = RecordingTracer;
97
+ const REDACTED_ATTRIBUTES = (0, core_1.redactionSet)();
98
+ function redactValue(key, value) {
99
+ const redacted = (0, core_1.redact)({ [key]: value }, REDACTED_ATTRIBUTES);
100
+ const result = redacted[key];
101
+ return typeof result === 'string' ? result : value;
102
+ }
103
+ //# sourceMappingURL=tracing.js.map
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@nage-api/observability",
3
+ "version": "1.0.0-beta.2",
4
+ "description": "Logging, tracing, metrics and health for @nage-api",
5
+ "license": "Apache-2.0",
6
+ "type": "commonjs",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "!dist/.tsbuildinfo",
20
+ "!dist/**/*.map",
21
+ "README.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@nage-api/contracts": "1.0.0-beta.2",
28
+ "@nage-api/core": "1.0.0-beta.2"
29
+ },
30
+ "peerDependencies": {
31
+ "@nestjs/common": "^11.0.0",
32
+ "@nestjs/core": "^11.0.0",
33
+ "reflect-metadata": "^0.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@nestjs/common": "11.1.29",
37
+ "@nestjs/core": "11.1.29",
38
+ "@nestjs/platform-express": "11.1.29",
39
+ "@nestjs/testing": "11.1.29",
40
+ "@swc/core": "1.15.47",
41
+ "@types/node": "22.20.1",
42
+ "@types/supertest": "6.0.3",
43
+ "@vitest/coverage-v8": "4.1.10",
44
+ "reflect-metadata": "0.2.2",
45
+ "rimraf": "6.1.3",
46
+ "rxjs": "7.8.2",
47
+ "supertest": "7.1.4",
48
+ "typescript": "5.9.3",
49
+ "unplugin-swc": "1.5.11",
50
+ "vitest": "4.1.10",
51
+ "@nage-api/testing": "1.0.0-beta.2"
52
+ },
53
+ "engines": {
54
+ "node": ">=22.0.0"
55
+ },
56
+ "scripts": {
57
+ "build": "tsc -b tsconfig.build.json",
58
+ "clean": "rimraf dist .turbo",
59
+ "typecheck": "tsc -p tsconfig.json --noEmit",
60
+ "test": "vitest run"
61
+ }
62
+ }