@spinajs/telemetry 2.0.491 → 2.0.494

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/README.md CHANGED
@@ -1,482 +1,482 @@
1
- # `@spinajs/telemetry`
2
-
3
- Observability for SpinaJS, derived from the swagger-stats design but adapted to
4
- SpinaJS's DI + middleware conventions. It provides:
5
-
6
- - **`Metrics`** — a `@Singleton()` wrapper over a private `prom-client` `Registry`
7
- (isolated from the global default registry) with a declarative metric factory
8
- (`defineMetrics`), default process metrics (`collectDefault`), and async
9
- Prometheus text rendering (`render` / `contentType`).
10
- - **`TelemetryMiddleware`** — an `@Injectable(ServerMiddleware)` that times every
11
- HTTP request and records a duration **histogram**, a request **counter**, and
12
- an in-flight **gauge** (keyed by `method` / `route` / `status`), and feeds the
13
- shared `TelemetryStore`. All of it runs from a `res.on('finish')` handler, and
14
- telemetry errors never break the request.
15
- - **`TelemetryStore`** — the `@Singleton()` that owns the collected aggregates
16
- (`RequestStats`, `Timeline`, `RouteStats`). The middleware is its only writer;
17
- the JSON endpoints are readers. It exists because a `ServerMiddleware` is not a
18
- singleton, so writer and reader cannot be the same object.
19
- - **`PromMetricSink`** — an `@Injectable(PerfSink)` that bridges the
20
- `@spinajs/log` **`Perf`** facade to Prometheus: every `Perf.measure` /
21
- `@Measure` / `Perf.count` measurement (including the ORM `orm.query` spans and
22
- HTTP per-request rollups) is exported as a `perf_*` metric with **no extra
23
- wiring**.
24
- - **`RequestStats`** — pure status-class counters (1xx..5xx), error rate, request
25
- rate, response-time min/max/avg, and **Apdex**.
26
- - **`Timeline`** — a rolling ring of `RequestStats` buckets (default 60 x 1 min).
27
- - **`RouteStats`** — a bounded per method+route breakdown.
28
- - **controllers** — `/metrics` (Prometheus exposition) and a `/telemetry/*` JSON
29
- API, each guarded by a policy named in configuration. Registered automatically;
30
- the response DTOs carry schemas, so they show up in the generated OpenAPI
31
- document when `@spinajs/http-swagger` is installed.
32
- - **`HealthCheck`** — the abstract readiness probe backing `/telemetry/ready`.
33
- The package ships no concrete checks; you register your own.
34
- - **endpoint handlers** — `metricsHandler(metrics)` and `statsHandler(store)`,
35
- plain `(req, res)` handlers for apps that do not use the SpinaJS controller
36
- stack. Not needed otherwise; the controllers above cover it.
37
-
38
- The registry is **isolated** (not `prom-client`'s global default), so multiple
39
- SpinaJS apps in one process — and repeated init in tests — stay independent.
40
-
41
- > Replacing `@spinajs/metrics`? Read
42
- > [`docs/migrations/2026-07-23-metrics-to-telemetry.md`](../../docs/migrations/2026-07-23-metrics-to-telemetry.md)
43
- > first — the http metric series are renamed **and** their unit changed from
44
- > seconds to milliseconds, which takes dashboards down silently.
45
-
46
- ---
47
-
48
- ## Quick start
49
-
50
- Install the package. The controllers, middleware, perf bridge and default
51
- process metrics are wired automatically; set the auth token and you are done.
52
-
53
- ```ts
54
- // configuration
55
- {
56
- telemetry: {
57
- auth: { token: process.env.METRICS_TOKEN },
58
- },
59
- }
60
- ```
61
-
62
- | Endpoint | Returns | Default access |
63
- | --- | --- | --- |
64
- | `GET /metrics` | Prometheus exposition text | token |
65
- | `GET /telemetry/stats` | `{ all, timeline }` — lifetime stats + rolling timeline | token |
66
- | `GET /telemetry/timeline?buckets=N` | The timeline alone, buckets annotated with their window | token |
67
- | `GET /telemetry/routes` | Per method+route request breakdown | token |
68
- | `GET /telemetry/perf` | Aggregated `Perf` spans and events | token |
69
- | `GET /telemetry/health` | Liveness — uptime, pid, node version, optional app version | public |
70
- | `GET /telemetry/ready` | Readiness — runs every registered `HealthCheck`, 503 when any is down | public |
71
-
72
- Guarded endpoints expect the token on the `x-metrics-token` header:
73
-
74
- ```bash
75
- curl -H "x-metrics-token: $METRICS_TOKEN" http://localhost:8080/metrics
76
- ```
77
-
78
- `TelemetryTokenPolicy` is bypassed entirely when `configuration.isDevelopment`
79
- is set, so a local run needs no token at all.
80
-
81
- Every endpoint's policy is a config key, so access can be changed without code:
82
-
83
- ```ts
84
- telemetry: {
85
- auth: {
86
- policies: {
87
- metrics: 'TelemetryTokenPolicy',
88
- health: 'PublicPolicy', // probes cannot carry a token
89
- },
90
- },
91
- }
92
- ```
93
-
94
- `/telemetry/health` and `/telemetry/ready` are public by default because kubelet
95
- probes and load balancers cannot send a header. They expose uptime, pid, the node
96
- version and — if you set `telemetry.health.version` — the app version. Point them
97
- at `TelemetryTokenPolicy` if that is more than you want to publish.
98
-
99
- ### Readiness checks
100
-
101
- Telemetry ships no concrete checks — a database check belongs where the database
102
- dependency already is. Register your own:
103
-
104
- ```ts
105
- import { Injectable } from '@spinajs/di';
106
- import { HealthCheck, IHealthResult } from '@spinajs/telemetry';
107
-
108
- @Injectable(HealthCheck)
109
- export class DatabaseCheck extends HealthCheck {
110
- public Name = 'database';
111
-
112
- public async check(): Promise<IHealthResult> {
113
- try {
114
- await db.raw('select 1');
115
- return { status: 'up' };
116
- } catch (err) {
117
- return { status: 'down', message: (err as Error).message };
118
- }
119
- }
120
- }
121
- ```
122
-
123
- `status` is `'up' | 'degraded' | 'down'`; a check may also return a `data` bag.
124
- Each check is raced against `telemetry.health.timeoutMs` ( default 2000 ), so a
125
- hung dependency cannot hang the probe — a timed-out check, or one that throws,
126
- counts as `down`. The overall status is the worst of them, and `/ready` answers
127
- 503 when it is `down` ( or `degraded`, with `telemetry.health.failOnDegraded` ).
128
-
129
- ### Mounting on a bare express app
130
-
131
- `metricsHandler( metrics )` and `statsHandler( store )` are exported for apps
132
- that do not use the SpinaJS controller stack:
133
-
134
- ```ts
135
- import { DI } from '@spinajs/di';
136
- import { Metrics, TelemetryStore, metricsHandler, statsHandler } from '@spinajs/telemetry';
137
-
138
- router.get('/metrics', metricsHandler(await DI.resolve(Metrics)));
139
- router.get('/telemetry/stats', statsHandler(await DI.resolve(TelemetryStore)));
140
- ```
141
-
142
- These are unguarded — the policy lives on the controllers, so an app wiring the
143
- handlers by hand owns its own auth. `statsHandler` also reports `req_rate` /
144
- `err_rate` as whatever the last `/telemetry/stats` controller call left behind
145
- ( `0` if nobody has called it ), because only the controller derives them.
146
-
147
- ---
148
-
149
- ## HTTP request metrics
150
-
151
- Once `TelemetryMiddleware` is active, every request is timed (with
152
- `process.hrtime.bigint()`) and recorded against the shared registry:
153
-
154
- | Series | Type | Labels |
155
- | --- | --- | --- |
156
- | `http_requests_total` | counter | `method`, `route`, `status` |
157
- | `http_request_duration_ms` | histogram | `method`, `route`, `status` |
158
- | `http_requests_in_flight` | gauge | — |
159
-
160
- The `route` label prefers the **matched** route path (`req.route.path`) over the
161
- raw URL, keeping cardinality bounded. The prefix (`http`) and duration buckets
162
- are **configuration**, not code — do not subclass just to change them:
163
-
164
- ```ts
165
- // configuration
166
- {
167
- telemetry: {
168
- prefix: 'api', // -> api_requests_total, api_request_duration_ms, api_requests_in_flight
169
- buckets: [5, 25, 100, 500, 2500],
170
- },
171
- }
172
- ```
173
-
174
- > **Do not subclass `TelemetryMiddleware` to re-prefix.** Decorating a subclass
175
- > with `@Injectable(ServerMiddleware)` **adds** a middleware, it does not replace
176
- > the base one — see [Replacing the middleware](#replacing-the-middleware). You
177
- > would get `http_*` **and** `api_*` for every request, plus double counts in
178
- > `/telemetry/stats`.
179
-
180
- ---
181
-
182
- ## Custom application metrics
183
-
184
- Use `Metrics.defineMetrics(prefix, defs)` to declare your own metrics on the same
185
- isolated registry — they show up in the same `/metrics` scrape. It is
186
- **idempotent** (a duplicate name is removed and recreated), so re-defining on a
187
- test re-init won't throw.
188
-
189
- ```ts
190
- import { DI } from '@spinajs/di';
191
- import { Metrics } from '@spinajs/telemetry';
192
- import type { Counter, Histogram } from 'prom-client';
193
-
194
- const metrics = await DI.resolve(Metrics);
195
-
196
- const map = metrics.defineMetrics('orders', [
197
- { name: 'created_total', help: 'Orders created', type: 'counter', labelNames: ['channel'] },
198
- { name: 'value_eur', help: 'Order value in EUR', type: 'histogram', buckets: [10, 50, 100, 500] },
199
- { name: 'pending', help: 'Orders awaiting payment', type: 'gauge' },
200
- ]);
201
-
202
- (map['created_total'] as Counter<string>).inc({ channel: 'web' });
203
- (map['value_eur'] as Histogram<string>).observe(129.9);
204
-
205
- // Render on demand ( render() is async in prom-client 14 )
206
- const exposition = await metrics.render();
207
- ```
208
-
209
- `MetricDef.type` is `'counter' | 'gauge' | 'histogram' | 'summary'`; `labelNames`
210
- is optional, as are `buckets` (histogram only) and `percentiles` (summary only).
211
- Every name is prefixed with `${prefix}_`, so pass the base name. Keep label
212
- **values** low-cardinality — never put ids, emails, or raw URLs in a label.
213
-
214
- ---
215
-
216
- ## Performance metrics → Prometheus (the perf dual-sink)
217
-
218
- `@spinajs/log` exposes a `Perf` facade for instrumenting arbitrary code
219
- (`Perf.measure` / `Perf.start` / `Perf.count` / `Perf.value` and the `@Measure`
220
- decorator). Each measurement is fanned out to every registered `PerfSink`. This
221
- package ships **`PromMetricSink`**, so **installing `@spinajs/telemetry` alongside
222
- `@spinajs/http` automatically exports all of it to Prometheus** — you don't call
223
- prom-client yourself.
224
-
225
- ### What gets exported
226
-
227
- | Series | Type | Labels | Source |
228
- | --- | --- | --- | --- |
229
- | `perf_span_duration_ms` | histogram | `name` | every `Perf.measure` / `@Measure` / `span.end()` |
230
- | `perf_events_total` | counter | `name` | every `Perf.count` / `Perf.value` |
231
- | `perf_scope_total_ms` | histogram | `name` | per-request rollup totals (e.g. total DB time per request) |
232
-
233
- The `name` label is the measurement name (e.g. `orm.query`, `http.request`, or
234
- your own). **It must be low-cardinality** — use a fixed vocabulary of names, not
235
- per-request unique strings.
236
-
237
- ### Instrument your code — it lands in Prometheus for free
238
-
239
- ```ts
240
- import { Perf, Measure } from '@spinajs/log';
241
-
242
- // wrap a block ( async or sync )
243
- await Perf.measure('report.build', () => buildReport(customerId));
244
- // -> perf_span_duration_ms{name="report.build"}
245
-
246
- // count events
247
- Perf.count('cache.miss');
248
- // -> perf_events_total{name="cache.miss"}
249
-
250
- // decorate a method
251
- class ReportService {
252
- @Measure('report.render')
253
- async render() { /* ... */ }
254
- // -> perf_span_duration_ms{name="report.render"}
255
- }
256
- ```
257
-
258
- Out of the box you also get, with zero extra code:
259
-
260
- - **`perf_span_duration_ms{name="orm.query"}`** — every SQL query, timed by the
261
- ORM (`SqlDriver.execute`).
262
- - **`perf_scope_total_ms{name="orm.query"}`** — total DB time *per HTTP request*
263
- (emitted by the HTTP `PerfRollup` middleware at request end).
264
-
265
- Example Prometheus queries:
266
-
267
- ```promql
268
- # p95 query latency
269
- histogram_quantile(0.95, sum by (le) (rate(perf_span_duration_ms_bucket{name="orm.query"}[5m])))
270
-
271
- # average DB time contributed per request
272
- rate(perf_scope_total_ms_sum{name="orm.query"}[5m]) / rate(perf_scope_total_ms_count{name="orm.query"}[5m])
273
- ```
274
-
275
- ### Log thresholds vs. prom export
276
-
277
- The default **log** sink (`LogMetricSink` in `@spinajs/log`) only writes a line
278
- when a span exceeds its `logger.perf.thresholds` (slow → `warn`, fast → `trace`).
279
- `PromMetricSink` is independent — it records **every** measurement regardless of
280
- threshold, so your histograms are complete. Tune log noise with `logger.perf.*`
281
- without affecting the metrics.
282
-
283
- ### Registration timing
284
-
285
- `TelemetryBootstrapper` builds both sinks and calls `Perf.refreshSinks()` at
286
- startup, so the bridge is live for every app that has this package installed —
287
- with or without `@spinajs/http`. Nothing to wire.
288
-
289
- It builds them through `Array.ofType(PerfSink)` rather than
290
- `DI.resolve(PromMetricSink)`, and that detail is load-bearing: the container
291
- caches a directly-resolved instance under its own type name only, and a later
292
- `Array.ofType(PerfSink)` returns that cached instance **without** adding it to
293
- the `PerfSink` list — leaving `Perf` blind to the sink it just handed back. If
294
- you add a sink of your own, resolve it the same way.
295
-
296
- ### Write your own sink
297
-
298
- `PerfSink` is a plain registerable abstract class — add another destination
299
- (StatsD, OTLP, a DB) without touching the producers. Register it with
300
- `@Injectable(PerfSink)` and `Perf` fans measurements to it too:
301
-
302
- ```ts
303
- import { Injectable } from '@spinajs/di';
304
- import { PerfSink, IPerfMetric, IPerfRollup } from '@spinajs/log';
305
-
306
- @Injectable(PerfSink)
307
- export class StatsdSink extends PerfSink {
308
- public collect(m: IPerfMetric): void {
309
- if (m.kind === 'span') statsd.timing(m.name, m.durationMs ?? 0);
310
- else statsd.increment(m.name, m.value ?? 1);
311
- }
312
- public onScopeEnd(rollup: IPerfRollup): void {
313
- for (const [name, e] of Object.entries(rollup.byName)) statsd.timing(`${name}.request`, e.totalMs);
314
- }
315
- }
316
- ```
317
-
318
- A sink's `collect` / `onScopeEnd` must never throw meaningfully — the `Perf`
319
- facade already guards every call so one bad sink can't break measured code or the
320
- other sinks.
321
-
322
- ---
323
-
324
- ## JSON stats endpoint
325
-
326
- `statsHandler(store)` writes `{ all, timeline }` from the shared store's lifetime
327
- `RequestStats` and rolling `Timeline`:
328
-
329
- ```jsonc
330
- {
331
- "all": {
332
- "requests": 1284, "responses": 1284, "errors": 12,
333
- "info": 0, "success": 1201, "redirect": 60, "client_error": 11, "server_error": 1,
334
- "total_time": 48210, "max_time": 812, "min_time": 1, "avg_time": 37.5,
335
- "apdex_satisfied": 1180, "apdex_tolerated": 40, "apdex_score": 0.94,
336
- "req_rate": 0, "err_rate": 0
337
- },
338
- "timeline": {
339
- "29014823": { "requests": 42, "responses": 42, "avg_time": 33.1, "apdex_score": 0.95 /* ... */ }
340
- }
341
- }
342
- ```
343
-
344
- - **`RequestStats`** accumulates status-class counters, response-time
345
- aggregates, and an **Apdex** score (`(satisfied + tolerated/2) / responses`;
346
- default satisfied threshold 25 ms, tolerated up to 4×).
347
- - **`Timeline`** keeps a rolling ring of per-bucket `RequestStats` (default
348
- 60 buckets × 60 s), keyed by `floor(timestamp / bucketMs)`.
349
-
350
- Both are pure and take the timestamp in, so they're deterministic under test.
351
-
352
- ---
353
-
354
- ## Metrics reference
355
-
356
- | Series | Type | Labels | Emitted by |
357
- | --- | --- | --- | --- |
358
- | `http_requests_total` | counter | `method`, `route`, `status` | `TelemetryMiddleware` |
359
- | `http_request_duration_ms` | histogram | `method`, `route`, `status` | `TelemetryMiddleware` |
360
- | `http_requests_in_flight` | gauge | — | `TelemetryMiddleware` |
361
- | `perf_span_duration_ms` | histogram | `name` | `PromMetricSink` (from `Perf.measure`/`@Measure`) |
362
- | `perf_events_total` | counter | `name` | `PromMetricSink` (from `Perf.count`/`Perf.value`) |
363
- | `perf_scope_total_ms` | histogram | `name` | `PromMetricSink` (from per-request rollups) |
364
- | `process_*` / `nodejs_*` | various | — | `metrics.collectDefault()`, called at bootstrap unless `telemetry.collectDefaultMetrics` is `false` |
365
-
366
- Duration histogram buckets (ms): `http_request_duration_ms` uses
367
- `DURATION_BUCKETS_MS` = `[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]`;
368
- the `perf_*` histograms use `PERF_DURATION_BUCKETS_MS` (same, plus a leading `1`).
369
-
370
- ---
371
-
372
- ## Configuration reference
373
-
374
- | Key | Default | Meaning |
375
- | --- | --- | --- |
376
- | `telemetry.auth.token` | `''` | Expected value of the `x-metrics-token` header |
377
- | `telemetry.auth.policies.<endpoint>` | see below | Policy class name per endpoint |
378
- | `telemetry.collectDefaultMetrics` | `true` | Register `process_*` / `nodejs_*` metrics at bootstrap |
379
- | `telemetry.prefix` | `'http'` | Metric name prefix for the http metrics |
380
- | `telemetry.buckets` | `[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]` | Duration histogram buckets ( ms ) |
381
- | `telemetry.apdexThresholdMs` | `25` | Apdex satisfied threshold; tolerated is 4x |
382
- | `telemetry.timeline.length` | `60` | Buckets retained in the timeline ring |
383
- | `telemetry.timeline.bucketMs` | `60000` | Timeline bucket width ( ms ) |
384
- | `telemetry.routes.enabled` | `true` | Collect the per-route breakdown |
385
- | `telemetry.routes.maxEntries` | `500` | Cap on distinct method+route keys |
386
- | `telemetry.perf.enabled` | `true` | Collect the in-memory perf aggregate behind `/telemetry/perf` |
387
- | `telemetry.perf.maxNames` | `200` | Cap on distinct perf measurement names |
388
- | `telemetry.health.timeoutMs` | `2000` | Per-check timeout for `/telemetry/ready` |
389
- | `telemetry.health.failOnDegraded` | `false` | Serve 503 for a degraded overall status |
390
- | `telemetry.health.version` | unset | Version string reported by `/telemetry/health`; omitted from the response when unset |
391
-
392
- The `<endpoint>` keys are `metrics`, `stats`, `timeline`, `routes`, `perf`,
393
- `health` and `ready`. Policy defaults: `TelemetryTokenPolicy` for `metrics`,
394
- `stats`, `timeline`, `routes` and `perf`; `PublicPolicy` for `health` and
395
- `ready`.
396
-
397
- `telemetry.perf.enabled` and `telemetry.perf.maxNames` bound the **JSON** view
398
- only ( `InMemoryPerfSink` ). The `perf_*` Prometheus series come from a separate
399
- sink and are unaffected by either.
400
-
401
- ### Cardinality
402
-
403
- Both the `route` label and the perf `name` label are meant for bounded
404
- vocabularies. The `maxEntries` / `maxNames` caps bound the JSON views, but the
405
- Prometheus histograms have no such cap — the `route` label falls back to the raw
406
- request path when no route matched, so a 404-scanning bot inflates the series
407
- count. Put telemetry behind a router that 404s unmatched paths early, or subclass
408
- `TelemetryMiddleware` and override `routeLabel()` to collapse unmatched requests
409
- to a constant:
410
-
411
- ```ts
412
- import { DI, Injectable } from '@spinajs/di';
413
- import { ServerMiddleware, Request as sRequest } from '@spinajs/http';
414
- import { TelemetryMiddleware } from '@spinajs/telemetry';
415
-
416
- @Injectable(ServerMiddleware)
417
- export class BoundedTelemetry extends TelemetryMiddleware {
418
- protected routeLabel(req: sRequest): string {
419
- const matched = (req as any).route?.path ?? (req.storage as any)?.route;
420
- // anything the router never matched collapses to one series
421
- return typeof matched === 'string' && matched.length > 0 ? matched : '__unmatched__';
422
- }
423
- }
424
-
425
- // REQUIRED — drop the base registration, otherwise BOTH middlewares run.
426
- // See "Replacing the middleware" below.
427
- DI.unregister(TelemetryMiddleware);
428
- ```
429
-
430
- ### Replacing the middleware
431
-
432
- `@Injectable(ServerMiddleware)` **appends** to the `ServerMiddleware` registration
433
- list — it does not displace the base class. `TelemetryMiddleware` registers itself
434
- when `@spinajs/telemetry` is imported, so a subclass that carries its own
435
- `@Injectable(ServerMiddleware)` leaves **two** telemetry middlewares registered.
436
- `HttpServer` resolves all of them (`@Autoinject(ServerMiddleware)`), so both run
437
- on every request:
438
-
439
- - both write to the same singleton `TelemetryStore`, so `/telemetry/stats`,
440
- `/telemetry/timeline` and `/telemetry/routes` report **double** the real
441
- counts;
442
- - both call `ensureMetrics()` with the same prefix, and `defineMetrics()`
443
- removes-and-recreates a duplicate name, so one of the two instances ends up
444
- holding **deregistered** metric objects — whose `routeLabel()` reaches the live
445
- histogram then depends on middleware ordering.
446
-
447
- Remove the base registration with `DI.unregister()`:
448
-
449
- ```ts
450
- import { DI } from '@spinajs/di';
451
- import { TelemetryMiddleware } from '@spinajs/telemetry';
452
- import { BoundedTelemetry } from './BoundedTelemetry.js'; // must be imported, so its @Injectable has run
453
-
454
- DI.unregister(TelemetryMiddleware);
455
- ```
456
-
457
- Two rules:
458
-
459
- 1. Run it **after** both modules are imported ( the decorators register at import
460
- time ) and **before** `HttpServer` is resolved — the middleware list is read
461
- once, when the server is resolved.
462
- 2. `unregister` matches by **type name**, so it removes only
463
- `TelemetryMiddleware`; your subclass stays registered.
464
-
465
- If you only need a different prefix or different buckets, do **not** subclass at
466
- all — use the `telemetry.prefix` / `telemetry.buckets` config keys.
467
-
468
- ---
469
-
470
- ## Notes
471
-
472
- - **Isolated registry.** `Metrics` never touches prom-client's global default
473
- registry, so tests and multiple apps in one process stay independent. Get the
474
- raw registry with `metrics.getRegistry()` if you must.
475
- - **Error-safe.** All middleware telemetry and all sink calls are guarded — a
476
- telemetry failure can never break a request or the measured code.
477
- - **Cardinality.** Both the `route` label and the perf `name` label are meant for
478
- bounded vocabularies. Never emit per-request/per-entity unique label values —
479
- see [Cardinality](#cardinality) for the unmatched-path case, which is the one
480
- that bites without you doing anything wrong.
481
- - **Migrating from `@spinajs/metrics`?** See
482
- [`docs/migrations/2026-07-23-metrics-to-telemetry.md`](../../docs/migrations/2026-07-23-metrics-to-telemetry.md).
1
+ # `@spinajs/telemetry`
2
+
3
+ Observability for SpinaJS, derived from the swagger-stats design but adapted to
4
+ SpinaJS's DI + middleware conventions. It provides:
5
+
6
+ - **`Metrics`** — a `@Singleton()` wrapper over a private `prom-client` `Registry`
7
+ (isolated from the global default registry) with a declarative metric factory
8
+ (`defineMetrics`), default process metrics (`collectDefault`), and async
9
+ Prometheus text rendering (`render` / `contentType`).
10
+ - **`TelemetryMiddleware`** — an `@Injectable(ServerMiddleware)` that times every
11
+ HTTP request and records a duration **histogram**, a request **counter**, and
12
+ an in-flight **gauge** (keyed by `method` / `route` / `status`), and feeds the
13
+ shared `TelemetryStore`. All of it runs from a `res.on('finish')` handler, and
14
+ telemetry errors never break the request.
15
+ - **`TelemetryStore`** — the `@Singleton()` that owns the collected aggregates
16
+ (`RequestStats`, `Timeline`, `RouteStats`). The middleware is its only writer;
17
+ the JSON endpoints are readers. It exists because a `ServerMiddleware` is not a
18
+ singleton, so writer and reader cannot be the same object.
19
+ - **`PromMetricSink`** — an `@Injectable(PerfSink)` that bridges the
20
+ `@spinajs/log` **`Perf`** facade to Prometheus: every `Perf.measure` /
21
+ `@Measure` / `Perf.count` measurement (including the ORM `orm.query` spans and
22
+ HTTP per-request rollups) is exported as a `perf_*` metric with **no extra
23
+ wiring**.
24
+ - **`RequestStats`** — pure status-class counters (1xx..5xx), error rate, request
25
+ rate, response-time min/max/avg, and **Apdex**.
26
+ - **`Timeline`** — a rolling ring of `RequestStats` buckets (default 60 x 1 min).
27
+ - **`RouteStats`** — a bounded per method+route breakdown.
28
+ - **controllers** — `/metrics` (Prometheus exposition) and a `/telemetry/*` JSON
29
+ API, each guarded by a policy named in configuration. Registered automatically;
30
+ the response DTOs carry schemas, so they show up in the generated OpenAPI
31
+ document when `@spinajs/http-swagger` is installed.
32
+ - **`HealthCheck`** — the abstract readiness probe backing `/telemetry/ready`.
33
+ The package ships no concrete checks; you register your own.
34
+ - **endpoint handlers** — `metricsHandler(metrics)` and `statsHandler(store)`,
35
+ plain `(req, res)` handlers for apps that do not use the SpinaJS controller
36
+ stack. Not needed otherwise; the controllers above cover it.
37
+
38
+ The registry is **isolated** (not `prom-client`'s global default), so multiple
39
+ SpinaJS apps in one process — and repeated init in tests — stay independent.
40
+
41
+ > Replacing `@spinajs/metrics`? Read
42
+ > [`docs/migrations/2026-07-23-metrics-to-telemetry.md`](../../docs/migrations/2026-07-23-metrics-to-telemetry.md)
43
+ > first — the http metric series are renamed **and** their unit changed from
44
+ > seconds to milliseconds, which takes dashboards down silently.
45
+
46
+ ---
47
+
48
+ ## Quick start
49
+
50
+ Install the package. The controllers, middleware, perf bridge and default
51
+ process metrics are wired automatically; set the auth token and you are done.
52
+
53
+ ```ts
54
+ // configuration
55
+ {
56
+ telemetry: {
57
+ auth: { token: process.env.METRICS_TOKEN },
58
+ },
59
+ }
60
+ ```
61
+
62
+ | Endpoint | Returns | Default access |
63
+ | --- | --- | --- |
64
+ | `GET /metrics` | Prometheus exposition text | token |
65
+ | `GET /telemetry/stats` | `{ all, timeline }` — lifetime stats + rolling timeline | token |
66
+ | `GET /telemetry/timeline?buckets=N` | The timeline alone, buckets annotated with their window | token |
67
+ | `GET /telemetry/routes` | Per method+route request breakdown | token |
68
+ | `GET /telemetry/perf` | Aggregated `Perf` spans and events | token |
69
+ | `GET /telemetry/health` | Liveness — uptime, pid, node version, optional app version | public |
70
+ | `GET /telemetry/ready` | Readiness — runs every registered `HealthCheck`, 503 when any is down | public |
71
+
72
+ Guarded endpoints expect the token on the `x-metrics-token` header:
73
+
74
+ ```bash
75
+ curl -H "x-metrics-token: $METRICS_TOKEN" http://localhost:8080/metrics
76
+ ```
77
+
78
+ `TelemetryTokenPolicy` is bypassed entirely when `configuration.isDevelopment`
79
+ is set, so a local run needs no token at all.
80
+
81
+ Every endpoint's policy is a config key, so access can be changed without code:
82
+
83
+ ```ts
84
+ telemetry: {
85
+ auth: {
86
+ policies: {
87
+ metrics: 'TelemetryTokenPolicy',
88
+ health: 'PublicPolicy', // probes cannot carry a token
89
+ },
90
+ },
91
+ }
92
+ ```
93
+
94
+ `/telemetry/health` and `/telemetry/ready` are public by default because kubelet
95
+ probes and load balancers cannot send a header. They expose uptime, pid, the node
96
+ version and — if you set `telemetry.health.version` — the app version. Point them
97
+ at `TelemetryTokenPolicy` if that is more than you want to publish.
98
+
99
+ ### Readiness checks
100
+
101
+ Telemetry ships no concrete checks — a database check belongs where the database
102
+ dependency already is. Register your own:
103
+
104
+ ```ts
105
+ import { Injectable } from '@spinajs/di';
106
+ import { HealthCheck, IHealthResult } from '@spinajs/telemetry';
107
+
108
+ @Injectable(HealthCheck)
109
+ export class DatabaseCheck extends HealthCheck {
110
+ public Name = 'database';
111
+
112
+ public async check(): Promise<IHealthResult> {
113
+ try {
114
+ await db.raw('select 1');
115
+ return { status: 'up' };
116
+ } catch (err) {
117
+ return { status: 'down', message: (err as Error).message };
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ `status` is `'up' | 'degraded' | 'down'`; a check may also return a `data` bag.
124
+ Each check is raced against `telemetry.health.timeoutMs` ( default 2000 ), so a
125
+ hung dependency cannot hang the probe — a timed-out check, or one that throws,
126
+ counts as `down`. The overall status is the worst of them, and `/ready` answers
127
+ 503 when it is `down` ( or `degraded`, with `telemetry.health.failOnDegraded` ).
128
+
129
+ ### Mounting on a bare express app
130
+
131
+ `metricsHandler( metrics )` and `statsHandler( store )` are exported for apps
132
+ that do not use the SpinaJS controller stack:
133
+
134
+ ```ts
135
+ import { DI } from '@spinajs/di';
136
+ import { Metrics, TelemetryStore, metricsHandler, statsHandler } from '@spinajs/telemetry';
137
+
138
+ router.get('/metrics', metricsHandler(await DI.resolve(Metrics)));
139
+ router.get('/telemetry/stats', statsHandler(await DI.resolve(TelemetryStore)));
140
+ ```
141
+
142
+ These are unguarded — the policy lives on the controllers, so an app wiring the
143
+ handlers by hand owns its own auth. `statsHandler` also reports `req_rate` /
144
+ `err_rate` as whatever the last `/telemetry/stats` controller call left behind
145
+ ( `0` if nobody has called it ), because only the controller derives them.
146
+
147
+ ---
148
+
149
+ ## HTTP request metrics
150
+
151
+ Once `TelemetryMiddleware` is active, every request is timed (with
152
+ `process.hrtime.bigint()`) and recorded against the shared registry:
153
+
154
+ | Series | Type | Labels |
155
+ | --- | --- | --- |
156
+ | `http_requests_total` | counter | `method`, `route`, `status` |
157
+ | `http_request_duration_ms` | histogram | `method`, `route`, `status` |
158
+ | `http_requests_in_flight` | gauge | — |
159
+
160
+ The `route` label prefers the **matched** route path (`req.route.path`) over the
161
+ raw URL, keeping cardinality bounded. The prefix (`http`) and duration buckets
162
+ are **configuration**, not code — do not subclass just to change them:
163
+
164
+ ```ts
165
+ // configuration
166
+ {
167
+ telemetry: {
168
+ prefix: 'api', // -> api_requests_total, api_request_duration_ms, api_requests_in_flight
169
+ buckets: [5, 25, 100, 500, 2500],
170
+ },
171
+ }
172
+ ```
173
+
174
+ > **Do not subclass `TelemetryMiddleware` to re-prefix.** Decorating a subclass
175
+ > with `@Injectable(ServerMiddleware)` **adds** a middleware, it does not replace
176
+ > the base one — see [Replacing the middleware](#replacing-the-middleware). You
177
+ > would get `http_*` **and** `api_*` for every request, plus double counts in
178
+ > `/telemetry/stats`.
179
+
180
+ ---
181
+
182
+ ## Custom application metrics
183
+
184
+ Use `Metrics.defineMetrics(prefix, defs)` to declare your own metrics on the same
185
+ isolated registry — they show up in the same `/metrics` scrape. It is
186
+ **idempotent** (a duplicate name is removed and recreated), so re-defining on a
187
+ test re-init won't throw.
188
+
189
+ ```ts
190
+ import { DI } from '@spinajs/di';
191
+ import { Metrics } from '@spinajs/telemetry';
192
+ import type { Counter, Histogram } from 'prom-client';
193
+
194
+ const metrics = await DI.resolve(Metrics);
195
+
196
+ const map = metrics.defineMetrics('orders', [
197
+ { name: 'created_total', help: 'Orders created', type: 'counter', labelNames: ['channel'] },
198
+ { name: 'value_eur', help: 'Order value in EUR', type: 'histogram', buckets: [10, 50, 100, 500] },
199
+ { name: 'pending', help: 'Orders awaiting payment', type: 'gauge' },
200
+ ]);
201
+
202
+ (map['created_total'] as Counter<string>).inc({ channel: 'web' });
203
+ (map['value_eur'] as Histogram<string>).observe(129.9);
204
+
205
+ // Render on demand ( render() is async in prom-client 14 )
206
+ const exposition = await metrics.render();
207
+ ```
208
+
209
+ `MetricDef.type` is `'counter' | 'gauge' | 'histogram' | 'summary'`; `labelNames`
210
+ is optional, as are `buckets` (histogram only) and `percentiles` (summary only).
211
+ Every name is prefixed with `${prefix}_`, so pass the base name. Keep label
212
+ **values** low-cardinality — never put ids, emails, or raw URLs in a label.
213
+
214
+ ---
215
+
216
+ ## Performance metrics → Prometheus (the perf dual-sink)
217
+
218
+ `@spinajs/log` exposes a `Perf` facade for instrumenting arbitrary code
219
+ (`Perf.measure` / `Perf.start` / `Perf.count` / `Perf.value` and the `@Measure`
220
+ decorator). Each measurement is fanned out to every registered `PerfSink`. This
221
+ package ships **`PromMetricSink`**, so **installing `@spinajs/telemetry` alongside
222
+ `@spinajs/http` automatically exports all of it to Prometheus** — you don't call
223
+ prom-client yourself.
224
+
225
+ ### What gets exported
226
+
227
+ | Series | Type | Labels | Source |
228
+ | --- | --- | --- | --- |
229
+ | `perf_span_duration_ms` | histogram | `name` | every `Perf.measure` / `@Measure` / `span.end()` |
230
+ | `perf_events_total` | counter | `name` | every `Perf.count` / `Perf.value` |
231
+ | `perf_scope_total_ms` | histogram | `name` | per-request rollup totals (e.g. total DB time per request) |
232
+
233
+ The `name` label is the measurement name (e.g. `orm.query`, `http.request`, or
234
+ your own). **It must be low-cardinality** — use a fixed vocabulary of names, not
235
+ per-request unique strings.
236
+
237
+ ### Instrument your code — it lands in Prometheus for free
238
+
239
+ ```ts
240
+ import { Perf, Measure } from '@spinajs/log';
241
+
242
+ // wrap a block ( async or sync )
243
+ await Perf.measure('report.build', () => buildReport(customerId));
244
+ // -> perf_span_duration_ms{name="report.build"}
245
+
246
+ // count events
247
+ Perf.count('cache.miss');
248
+ // -> perf_events_total{name="cache.miss"}
249
+
250
+ // decorate a method
251
+ class ReportService {
252
+ @Measure('report.render')
253
+ async render() { /* ... */ }
254
+ // -> perf_span_duration_ms{name="report.render"}
255
+ }
256
+ ```
257
+
258
+ Out of the box you also get, with zero extra code:
259
+
260
+ - **`perf_span_duration_ms{name="orm.query"}`** — every SQL query, timed by the
261
+ ORM (`SqlDriver.execute`).
262
+ - **`perf_scope_total_ms{name="orm.query"}`** — total DB time *per HTTP request*
263
+ (emitted by the HTTP `PerfRollup` middleware at request end).
264
+
265
+ Example Prometheus queries:
266
+
267
+ ```promql
268
+ # p95 query latency
269
+ histogram_quantile(0.95, sum by (le) (rate(perf_span_duration_ms_bucket{name="orm.query"}[5m])))
270
+
271
+ # average DB time contributed per request
272
+ rate(perf_scope_total_ms_sum{name="orm.query"}[5m]) / rate(perf_scope_total_ms_count{name="orm.query"}[5m])
273
+ ```
274
+
275
+ ### Log thresholds vs. prom export
276
+
277
+ The default **log** sink (`LogMetricSink` in `@spinajs/log`) only writes a line
278
+ when a span exceeds its `logger.perf.thresholds` (slow → `warn`, fast → `trace`).
279
+ `PromMetricSink` is independent — it records **every** measurement regardless of
280
+ threshold, so your histograms are complete. Tune log noise with `logger.perf.*`
281
+ without affecting the metrics.
282
+
283
+ ### Registration timing
284
+
285
+ `TelemetryBootstrapper` builds both sinks and calls `Perf.refreshSinks()` at
286
+ startup, so the bridge is live for every app that has this package installed —
287
+ with or without `@spinajs/http`. Nothing to wire.
288
+
289
+ It builds them through `Array.ofType(PerfSink)` rather than
290
+ `DI.resolve(PromMetricSink)`, and that detail is load-bearing: the container
291
+ caches a directly-resolved instance under its own type name only, and a later
292
+ `Array.ofType(PerfSink)` returns that cached instance **without** adding it to
293
+ the `PerfSink` list — leaving `Perf` blind to the sink it just handed back. If
294
+ you add a sink of your own, resolve it the same way.
295
+
296
+ ### Write your own sink
297
+
298
+ `PerfSink` is a plain registerable abstract class — add another destination
299
+ (StatsD, OTLP, a DB) without touching the producers. Register it with
300
+ `@Injectable(PerfSink)` and `Perf` fans measurements to it too:
301
+
302
+ ```ts
303
+ import { Injectable } from '@spinajs/di';
304
+ import { PerfSink, IPerfMetric, IPerfRollup } from '@spinajs/log';
305
+
306
+ @Injectable(PerfSink)
307
+ export class StatsdSink extends PerfSink {
308
+ public collect(m: IPerfMetric): void {
309
+ if (m.kind === 'span') statsd.timing(m.name, m.durationMs ?? 0);
310
+ else statsd.increment(m.name, m.value ?? 1);
311
+ }
312
+ public onScopeEnd(rollup: IPerfRollup): void {
313
+ for (const [name, e] of Object.entries(rollup.byName)) statsd.timing(`${name}.request`, e.totalMs);
314
+ }
315
+ }
316
+ ```
317
+
318
+ A sink's `collect` / `onScopeEnd` must never throw meaningfully — the `Perf`
319
+ facade already guards every call so one bad sink can't break measured code or the
320
+ other sinks.
321
+
322
+ ---
323
+
324
+ ## JSON stats endpoint
325
+
326
+ `statsHandler(store)` writes `{ all, timeline }` from the shared store's lifetime
327
+ `RequestStats` and rolling `Timeline`:
328
+
329
+ ```jsonc
330
+ {
331
+ "all": {
332
+ "requests": 1284, "responses": 1284, "errors": 12,
333
+ "info": 0, "success": 1201, "redirect": 60, "client_error": 11, "server_error": 1,
334
+ "total_time": 48210, "max_time": 812, "min_time": 1, "avg_time": 37.5,
335
+ "apdex_satisfied": 1180, "apdex_tolerated": 40, "apdex_score": 0.94,
336
+ "req_rate": 0, "err_rate": 0
337
+ },
338
+ "timeline": {
339
+ "29014823": { "requests": 42, "responses": 42, "avg_time": 33.1, "apdex_score": 0.95 /* ... */ }
340
+ }
341
+ }
342
+ ```
343
+
344
+ - **`RequestStats`** accumulates status-class counters, response-time
345
+ aggregates, and an **Apdex** score (`(satisfied + tolerated/2) / responses`;
346
+ default satisfied threshold 25 ms, tolerated up to 4×).
347
+ - **`Timeline`** keeps a rolling ring of per-bucket `RequestStats` (default
348
+ 60 buckets × 60 s), keyed by `floor(timestamp / bucketMs)`.
349
+
350
+ Both are pure and take the timestamp in, so they're deterministic under test.
351
+
352
+ ---
353
+
354
+ ## Metrics reference
355
+
356
+ | Series | Type | Labels | Emitted by |
357
+ | --- | --- | --- | --- |
358
+ | `http_requests_total` | counter | `method`, `route`, `status` | `TelemetryMiddleware` |
359
+ | `http_request_duration_ms` | histogram | `method`, `route`, `status` | `TelemetryMiddleware` |
360
+ | `http_requests_in_flight` | gauge | — | `TelemetryMiddleware` |
361
+ | `perf_span_duration_ms` | histogram | `name` | `PromMetricSink` (from `Perf.measure`/`@Measure`) |
362
+ | `perf_events_total` | counter | `name` | `PromMetricSink` (from `Perf.count`/`Perf.value`) |
363
+ | `perf_scope_total_ms` | histogram | `name` | `PromMetricSink` (from per-request rollups) |
364
+ | `process_*` / `nodejs_*` | various | — | `metrics.collectDefault()`, called at bootstrap unless `telemetry.collectDefaultMetrics` is `false` |
365
+
366
+ Duration histogram buckets (ms): `http_request_duration_ms` uses
367
+ `DURATION_BUCKETS_MS` = `[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]`;
368
+ the `perf_*` histograms use `PERF_DURATION_BUCKETS_MS` (same, plus a leading `1`).
369
+
370
+ ---
371
+
372
+ ## Configuration reference
373
+
374
+ | Key | Default | Meaning |
375
+ | --- | --- | --- |
376
+ | `telemetry.auth.token` | `''` | Expected value of the `x-metrics-token` header |
377
+ | `telemetry.auth.policies.<endpoint>` | see below | Policy class name per endpoint |
378
+ | `telemetry.collectDefaultMetrics` | `true` | Register `process_*` / `nodejs_*` metrics at bootstrap |
379
+ | `telemetry.prefix` | `'http'` | Metric name prefix for the http metrics |
380
+ | `telemetry.buckets` | `[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]` | Duration histogram buckets ( ms ) |
381
+ | `telemetry.apdexThresholdMs` | `25` | Apdex satisfied threshold; tolerated is 4x |
382
+ | `telemetry.timeline.length` | `60` | Buckets retained in the timeline ring |
383
+ | `telemetry.timeline.bucketMs` | `60000` | Timeline bucket width ( ms ) |
384
+ | `telemetry.routes.enabled` | `true` | Collect the per-route breakdown |
385
+ | `telemetry.routes.maxEntries` | `500` | Cap on distinct method+route keys |
386
+ | `telemetry.perf.enabled` | `true` | Collect the in-memory perf aggregate behind `/telemetry/perf` |
387
+ | `telemetry.perf.maxNames` | `200` | Cap on distinct perf measurement names |
388
+ | `telemetry.health.timeoutMs` | `2000` | Per-check timeout for `/telemetry/ready` |
389
+ | `telemetry.health.failOnDegraded` | `false` | Serve 503 for a degraded overall status |
390
+ | `telemetry.health.version` | unset | Version string reported by `/telemetry/health`; omitted from the response when unset |
391
+
392
+ The `<endpoint>` keys are `metrics`, `stats`, `timeline`, `routes`, `perf`,
393
+ `health` and `ready`. Policy defaults: `TelemetryTokenPolicy` for `metrics`,
394
+ `stats`, `timeline`, `routes` and `perf`; `PublicPolicy` for `health` and
395
+ `ready`.
396
+
397
+ `telemetry.perf.enabled` and `telemetry.perf.maxNames` bound the **JSON** view
398
+ only ( `InMemoryPerfSink` ). The `perf_*` Prometheus series come from a separate
399
+ sink and are unaffected by either.
400
+
401
+ ### Cardinality
402
+
403
+ Both the `route` label and the perf `name` label are meant for bounded
404
+ vocabularies. The `maxEntries` / `maxNames` caps bound the JSON views, but the
405
+ Prometheus histograms have no such cap — the `route` label falls back to the raw
406
+ request path when no route matched, so a 404-scanning bot inflates the series
407
+ count. Put telemetry behind a router that 404s unmatched paths early, or subclass
408
+ `TelemetryMiddleware` and override `routeLabel()` to collapse unmatched requests
409
+ to a constant:
410
+
411
+ ```ts
412
+ import { DI, Injectable } from '@spinajs/di';
413
+ import { ServerMiddleware, Request as sRequest } from '@spinajs/http';
414
+ import { TelemetryMiddleware } from '@spinajs/telemetry';
415
+
416
+ @Injectable(ServerMiddleware)
417
+ export class BoundedTelemetry extends TelemetryMiddleware {
418
+ protected routeLabel(req: sRequest): string {
419
+ const matched = (req as any).route?.path ?? (req.storage as any)?.route;
420
+ // anything the router never matched collapses to one series
421
+ return typeof matched === 'string' && matched.length > 0 ? matched : '__unmatched__';
422
+ }
423
+ }
424
+
425
+ // REQUIRED — drop the base registration, otherwise BOTH middlewares run.
426
+ // See "Replacing the middleware" below.
427
+ DI.unregister(TelemetryMiddleware);
428
+ ```
429
+
430
+ ### Replacing the middleware
431
+
432
+ `@Injectable(ServerMiddleware)` **appends** to the `ServerMiddleware` registration
433
+ list — it does not displace the base class. `TelemetryMiddleware` registers itself
434
+ when `@spinajs/telemetry` is imported, so a subclass that carries its own
435
+ `@Injectable(ServerMiddleware)` leaves **two** telemetry middlewares registered.
436
+ `HttpServer` resolves all of them (`@Autoinject(ServerMiddleware)`), so both run
437
+ on every request:
438
+
439
+ - both write to the same singleton `TelemetryStore`, so `/telemetry/stats`,
440
+ `/telemetry/timeline` and `/telemetry/routes` report **double** the real
441
+ counts;
442
+ - both call `ensureMetrics()` with the same prefix, and `defineMetrics()`
443
+ removes-and-recreates a duplicate name, so one of the two instances ends up
444
+ holding **deregistered** metric objects — whose `routeLabel()` reaches the live
445
+ histogram then depends on middleware ordering.
446
+
447
+ Remove the base registration with `DI.unregister()`:
448
+
449
+ ```ts
450
+ import { DI } from '@spinajs/di';
451
+ import { TelemetryMiddleware } from '@spinajs/telemetry';
452
+ import { BoundedTelemetry } from './BoundedTelemetry.js'; // must be imported, so its @Injectable has run
453
+
454
+ DI.unregister(TelemetryMiddleware);
455
+ ```
456
+
457
+ Two rules:
458
+
459
+ 1. Run it **after** both modules are imported ( the decorators register at import
460
+ time ) and **before** `HttpServer` is resolved — the middleware list is read
461
+ once, when the server is resolved.
462
+ 2. `unregister` matches by **type name**, so it removes only
463
+ `TelemetryMiddleware`; your subclass stays registered.
464
+
465
+ If you only need a different prefix or different buckets, do **not** subclass at
466
+ all — use the `telemetry.prefix` / `telemetry.buckets` config keys.
467
+
468
+ ---
469
+
470
+ ## Notes
471
+
472
+ - **Isolated registry.** `Metrics` never touches prom-client's global default
473
+ registry, so tests and multiple apps in one process stay independent. Get the
474
+ raw registry with `metrics.getRegistry()` if you must.
475
+ - **Error-safe.** All middleware telemetry and all sink calls are guarded — a
476
+ telemetry failure can never break a request or the measured code.
477
+ - **Cardinality.** Both the `route` label and the perf `name` label are meant for
478
+ bounded vocabularies. Never emit per-request/per-entity unique label values —
479
+ see [Cardinality](#cardinality) for the unmatched-path case, which is the one
480
+ that bites without you doing anything wrong.
481
+ - **Migrating from `@spinajs/metrics`?** See
482
+ [`docs/migrations/2026-07-23-metrics-to-telemetry.md`](../../docs/migrations/2026-07-23-metrics-to-telemetry.md).