@objectstack/observability 17.0.0 → 17.2.0

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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Logger } from '@objectstack/spec/contracts';
1
+ import { IHttpServer, Logger } from '@objectstack/spec/contracts';
2
2
  export { Logger } from '@objectstack/spec/contracts';
3
3
  import { ExecutionContext } from '@objectstack/spec/kernel';
4
4
 
@@ -118,26 +118,60 @@ declare const OBSERVABILITY_ERRORS_SERVICE = "observability:errors";
118
118
  * stay in the deployment repo.
119
119
  */
120
120
  declare const SEMCONV: {
121
- /** Counter, labels: `method`, `route`, `status`. */
121
+ /**
122
+ * Counter, labels: `method`, `route`, `status`. Emitted by the TRANSPORT
123
+ * through the `IHttpServer.afterResponse` seam (#9835), so it covers
124
+ * every inbound request on the server rather than only the routes the
125
+ * runtime dispatcher registers.
126
+ */
122
127
  readonly httpRequestsTotal: "http_requests_total";
123
- /** Histogram (ms), labels: `method`, `route`. */
124
- readonly httpRequestDurationMs: "http_request_duration_ms";
125
128
  /**
126
- * Counter, labels: `method`, `route`. Incremented when an
127
- * in-flight handler throws after the response is sent.
129
+ * Histogram (ms), labels: `method`, `route`. Emitted by the TRANSPORT
130
+ * through the same seam (#9834). It measures the REQUEST as the transport
131
+ * sees it — first sight to the response existing, middleware chain and
132
+ * body parse included — not the handler's share of it.
128
133
  */
129
- readonly httpRequestErrorsTotal: "http_request_errors_total";
134
+ readonly httpRequestDurationMs: "http_request_duration_ms";
130
135
  /** Counter, labels: `adapter` (`local`|`s3`|…), `op` (`get`|`put`|`delete`|`head`), `result` (`ok`|`error`). */
131
136
  readonly storageOperationsTotal: "storage_operations_total";
132
137
  /** Histogram (ms), labels: `adapter`, `op`. */
133
138
  readonly storageOperationDurationMs: "storage_operation_duration_ms";
134
139
  /** Counter, labels: `adapter`, `op`, `errorClass`. */
135
140
  readonly storageErrorsTotal: "storage_errors_total";
136
- /** Counter, labels: `adapter` (`memory`|`redis`), `result` (`hit`|`miss`). */
141
+ /**
142
+ * Counter, labels: `adapter` (`memory`|`redis`), `result` (`hit`|`miss`).
143
+ *
144
+ * ⚠️ A flat zero means "NO CONFIGURED CONSUMER", not "no cache activity",
145
+ * and — unlike the HTTP families above — it is NOT an instrumentation gap.
146
+ * The adapters hold the host's registry and count every call they receive
147
+ * (#9832 wired that; #9951 pins it), so a zero here is TRUE. What it fails
148
+ * to communicate is WHY.
149
+ *
150
+ * The why: nothing consults the `cache` service unconditionally. Every
151
+ * production consumer is a rate-limit / budget counter store, and each is
152
+ * gated on a declaration somebody has to write — better-auth's per-IP
153
+ * counters (`rate_limit_max` / `rate_limit_window_seconds` in auth
154
+ * settings), the dispatcher's inbound limiter and its declarative
155
+ * per-endpoint buckets (an armed `rateLimit` budget; with none declared
156
+ * the dispatcher registers no limiter at all), and the per-number OTP send
157
+ * budget (an SMS send path). A default install declares none of them, so
158
+ * this counter stays at 0 while the server handles traffic normally.
159
+ *
160
+ * ⇒ Read a flat `cache_*` as a question about CONFIGURATION, never as a 0%
161
+ * hit rate or a broken adapter. Before trusting a cache hit-rate panel,
162
+ * confirm at least one consumer above is actually armed.
163
+ */
137
164
  readonly cacheLookupsTotal: "cache_lookups_total";
138
- /** Counter, labels: `adapter`, `op` (`set`|`delete`|`clear`). */
165
+ /**
166
+ * Counter, labels: `adapter`, `op` (`set`|`delete`|`clear`). Same
167
+ * "zero = no configured consumer" reading as `cacheLookupsTotal` above.
168
+ */
139
169
  readonly cacheWritesTotal: "cache_writes_total";
140
- /** Counter, labels: `adapter`, `op`, `errorClass`. */
170
+ /**
171
+ * Counter, labels: `adapter`, `op`, `errorClass`. Same "zero = no
172
+ * configured consumer" reading as `cacheLookupsTotal` above — a zero is
173
+ * "nothing was asked of the cache", not "every call succeeded".
174
+ */
141
175
  readonly cacheErrorsTotal: "cache_errors_total";
142
176
  /**
143
177
  * Counter, labels: `app`, `job`. Incremented when a DECLARED background
@@ -162,9 +196,103 @@ declare const SEMCONV: {
162
196
  declare const RUNTIME_METRICS: {
163
197
  readonly httpRequestsTotal: "http_requests_total";
164
198
  readonly httpRequestDurationMs: "http_request_duration_ms";
165
- readonly httpRequestErrorsTotal: "http_request_errors_total";
166
199
  };
167
200
 
201
+ /**
202
+ * What an `arm*` call in this module did:
203
+ *
204
+ * - `'armed'` — the emitting observer was registered on this call.
205
+ * - `'already-armed'` — some earlier caller already armed this server for
206
+ * THIS metric family; the seam emits it, and this call registered nothing
207
+ * (first-wins).
208
+ * - `'unsupported'` — the transport does not implement the
209
+ * `IHttpServer.afterResponse` seam; it reports NO HTTP metrics (#9835:
210
+ * zero there means "not instrumented", never "no traffic"), and the
211
+ * caller must decide how to degrade — the runtime dispatcher falls back
212
+ * to instrumenting its own routes.
213
+ */
214
+ type ArmHttpMetricResult = 'armed' | 'already-armed' | 'unsupported';
215
+ /**
216
+ * The name {@link armHttpRequestCounter} shipped with. Kept as an alias
217
+ * rather than renamed: the export is already in the pending release, and the
218
+ * two families arm through separate entry points anyway.
219
+ */
220
+ type ArmHttpRequestCounterResult = ArmHttpMetricResult;
221
+ /**
222
+ * Arm `http_requests_total{method,route,status}` on a transport through the
223
+ * `IHttpServer.afterResponse` observation seam (#9835) — AT MOST ONCE per
224
+ * server, whoever calls first.
225
+ *
226
+ * ## Why arming is centralized here
227
+ *
228
+ * The contract's ownership rule says a request must never be double-counted,
229
+ * and two composition layers legitimately hold both a server and a metrics
230
+ * registry: the transport's own hosting plugin (`HonoServerPlugin`, which
231
+ * the 2026-08-18 ruling on #9650 made the counter's home) and the runtime
232
+ * dispatcher (whose `observability.metrics` config is the wiring the docs
233
+ * demonstrate). When a host hands ONE registry to both — the ordinary case —
234
+ * two independently-registered observers would land every request on the
235
+ * same series twice: exactly the #9833 distortion, rebuilt one seam over.
236
+ * Routing every arming through this function makes "exactly one
237
+ * counter-emitting observer per server" structural: the first caller arms
238
+ * (in the shipped composition that is the transport plugin, in Phase 1),
239
+ * every later caller is told the seam already counts.
240
+ *
241
+ * The label shape is pinned by the contract: `route` is the transport's
242
+ * `routePattern` — the registered PATTERN, never the concrete path — and
243
+ * `status` is stringified for the label set. Emission goes through the
244
+ * transport's observer-isolation guarantee, so a throwing registry cannot
245
+ * break a response.
246
+ *
247
+ * @param server - The transport. Pass the RAW registered `http.server`
248
+ * instance, not a wrapper: the latch is per object identity, and a wrapper
249
+ * would both fork the latch and (per #5122) risk erasing the optional
250
+ * member this function feature-detects.
251
+ * @param metrics - The registry the counter lands in. First caller wins; a
252
+ * second registry offered later is NOT added (the contract's one-owner
253
+ * rule), and the result says so.
254
+ */
255
+ declare function armHttpRequestCounter(server: IHttpServer, metrics: MetricsRegistry): ArmHttpMetricResult;
256
+ /**
257
+ * Arm `http_request_duration_ms{method,route}` on a transport through the
258
+ * `IHttpServer.afterResponse` observation seam — AT MOST ONCE per server,
259
+ * whoever calls first. The duration half of #9834, built on the mechanism
260
+ * #9835 proved out for `http_requests_total`.
261
+ *
262
+ * ## Why the histogram has to move too
263
+ *
264
+ * #9835 moved only the counter, which left the docs' two derived signals
265
+ * inconsistent with each other: 5xx rate saw every inbound surface while p95
266
+ * latency still saw the dispatcher's own routes. An operator reading one
267
+ * dashboard got request volume for `/api/v1/*` beside a latency panel with no
268
+ * series for it — and the worse reading is the p95 that IS drawn, computed
269
+ * from dispatcher routes only and presented as the server's.
270
+ *
271
+ * ## ⚠️ The observation WINDOW changes with the emitter
272
+ *
273
+ * The dispatcher's per-route wrapper timed `await handler(req, res)` — handler
274
+ * latency. This seam times the transport's own `use('*')` around
275
+ * `await next()`, which is what {@link HttpResponseObservation.elapsedMs}
276
+ * means: "from the transport first seeing the request to the response
277
+ * existing". That includes the middleware chain and body parse, so the series
278
+ * can only move UP, never down. It is the number an operator's latency panel
279
+ * should have been showing — the request's latency rather than one layer's
280
+ * share of it — but it is a visible change in an existing series, so it is
281
+ * stated here, in the changeset, and in `docs/OBSERVABILITY.md` rather than
282
+ * left for a dashboard to discover.
283
+ *
284
+ * The label set is unchanged and stays the SEMCONV-declared `{method,route}`:
285
+ * `route` is the transport's `routePattern` (the registered PATTERN, never the
286
+ * concrete path), and no `status` label is added — a histogram split by status
287
+ * is a different series shape than the one the docs tell operators to graph.
288
+ *
289
+ * @param server - The transport. Pass the RAW registered `http.server`
290
+ * instance, not a wrapper — the latch is per object identity, and (per
291
+ * #5122) a wrapper risks erasing the optional member this feature-detects.
292
+ * @param metrics - The registry the histogram lands in. First caller wins.
293
+ */
294
+ declare function armHttpRequestDurationHistogram(server: IHttpServer, metrics: MetricsRegistry): ArmHttpMetricResult;
295
+
168
296
  /**
169
297
  * No-op metrics registry — the default. Discards every observation.
170
298
  * Production deployments should swap this for a real registry; tests
@@ -617,4 +745,4 @@ declare function isPerfDisclosurePrivileged(): boolean;
617
745
  */
618
746
  declare function isPerfDisclosurePrincipal(ec: ExecutionContext | undefined): boolean;
619
747
 
620
- export { type CapturedError, ConsoleErrorReporter, ConsoleLogger, ConsoleMetricsRegistry, type ErrorReporter, InMemoryErrorReporter, InMemoryMetricsRegistry, JsonLogger, LOG_LEVELS, type LogLevel, type MetricSample, type MetricsRegistry, NoopErrorReporter, NoopLogger, NoopMetricsRegistry, OBSERVABILITY_ERRORS_SERVICE, OBSERVABILITY_METRICS_SERVICE, type OtlpHttpExporterOptions, OtlpHttpMetricsRegistry, type PerfDisclosureGate, PerfTiming, RUNTIME_METRICS, SEMCONV, type ServerTimingDetail, type ServerTimingMark, allowPerfDisclosure, countServerTiming, currentPerfTiming, formatServerTiming, isPerfDisclosureAllowed, isPerfDisclosurePrincipal, isPerfDisclosurePrivileged, measureServerTiming, perfNow, recordServerTiming, recordServerTimingDetail, runWithPerfDisclosure, runWithPerfTiming, startServerTiming };
748
+ export { type ArmHttpMetricResult, type ArmHttpRequestCounterResult, type CapturedError, ConsoleErrorReporter, ConsoleLogger, ConsoleMetricsRegistry, type ErrorReporter, InMemoryErrorReporter, InMemoryMetricsRegistry, JsonLogger, LOG_LEVELS, type LogLevel, type MetricSample, type MetricsRegistry, NoopErrorReporter, NoopLogger, NoopMetricsRegistry, OBSERVABILITY_ERRORS_SERVICE, OBSERVABILITY_METRICS_SERVICE, type OtlpHttpExporterOptions, OtlpHttpMetricsRegistry, type PerfDisclosureGate, PerfTiming, RUNTIME_METRICS, SEMCONV, type ServerTimingDetail, type ServerTimingMark, allowPerfDisclosure, armHttpRequestCounter, armHttpRequestDurationHistogram, countServerTiming, currentPerfTiming, formatServerTiming, isPerfDisclosureAllowed, isPerfDisclosurePrincipal, isPerfDisclosurePrivileged, measureServerTiming, perfNow, recordServerTiming, recordServerTimingDetail, runWithPerfDisclosure, runWithPerfTiming, startServerTiming };
package/dist/index.js CHANGED
@@ -4,16 +4,35 @@ var OBSERVABILITY_ERRORS_SERVICE = "observability:errors";
4
4
 
5
5
  // src/semconv.ts
6
6
  var SEMCONV = {
7
- // ── HTTP — emitted by `@objectstack/runtime`'s instrumentRouteHandler ──
8
- /** Counter, labels: `method`, `route`, `status`. */
7
+ // ── HTTP — emitter differs per family, see each ────────────────────
8
+ /**
9
+ * Counter, labels: `method`, `route`, `status`. Emitted by the TRANSPORT
10
+ * through the `IHttpServer.afterResponse` seam (#9835), so it covers
11
+ * every inbound request on the server rather than only the routes the
12
+ * runtime dispatcher registers.
13
+ */
9
14
  httpRequestsTotal: "http_requests_total",
10
- /** Histogram (ms), labels: `method`, `route`. */
11
- httpRequestDurationMs: "http_request_duration_ms",
12
15
  /**
13
- * Counter, labels: `method`, `route`. Incremented when an
14
- * in-flight handler throws after the response is sent.
16
+ * Histogram (ms), labels: `method`, `route`. Emitted by the TRANSPORT
17
+ * through the same seam (#9834). It measures the REQUEST as the transport
18
+ * sees it — first sight to the response existing, middleware chain and
19
+ * body parse included — not the handler's share of it.
15
20
  */
16
- httpRequestErrorsTotal: "http_request_errors_total",
21
+ httpRequestDurationMs: "http_request_duration_ms",
22
+ // ⛔ RETIRED — `http_request_errors_total` was removed in
23
+ // `@objectstack/observability` 17.2.0 (#9834, ADR-0049 enforce-or-remove).
24
+ // ⛔ Do not re-add the name. It was DECLARED here as a stable server-wide
25
+ // signal and EMITTED only from `@objectstack/runtime`'s per-route wrapper,
26
+ // on a THROWN handler — so it never saw auth's `getRawApp()` mount, the
27
+ // REST data API, or any error a handler answered politely through
28
+ // `errorResponseBase` (which sets a status and does not re-throw). No
29
+ // transport-side emitter could preserve that population either: the
30
+ // `IHttpServer.afterResponse` observation carries `{method, routePattern,
31
+ // status, elapsedMs}` and no throw signal at all.
32
+ // ⇒ Read the 5xx rate from `http_requests_total{status=~"5.."}` instead.
33
+ // The transport emits that family through the seam, so it covers every
34
+ // inbound surface (#9650 / #9835 / #10004) and carries the status label
35
+ // this counter only stood in for. Maintainer ruling 2026-08-20.
17
36
  // ── Storage — emitted by `@objectstack/service-storage` adapters ──
18
37
  /** Counter, labels: `adapter` (`local`|`s3`|…), `op` (`get`|`put`|`delete`|`head`), `result` (`ok`|`error`). */
19
38
  storageOperationsTotal: "storage_operations_total",
@@ -22,11 +41,41 @@ var SEMCONV = {
22
41
  /** Counter, labels: `adapter`, `op`, `errorClass`. */
23
42
  storageErrorsTotal: "storage_errors_total",
24
43
  // ── Cache — emitted by `@objectstack/service-cache` adapters ──
25
- /** Counter, labels: `adapter` (`memory`|`redis`), `result` (`hit`|`miss`). */
44
+ // Uniform emitter; what varies is who CONSULTS the service — see below.
45
+ /**
46
+ * Counter, labels: `adapter` (`memory`|`redis`), `result` (`hit`|`miss`).
47
+ *
48
+ * ⚠️ A flat zero means "NO CONFIGURED CONSUMER", not "no cache activity",
49
+ * and — unlike the HTTP families above — it is NOT an instrumentation gap.
50
+ * The adapters hold the host's registry and count every call they receive
51
+ * (#9832 wired that; #9951 pins it), so a zero here is TRUE. What it fails
52
+ * to communicate is WHY.
53
+ *
54
+ * The why: nothing consults the `cache` service unconditionally. Every
55
+ * production consumer is a rate-limit / budget counter store, and each is
56
+ * gated on a declaration somebody has to write — better-auth's per-IP
57
+ * counters (`rate_limit_max` / `rate_limit_window_seconds` in auth
58
+ * settings), the dispatcher's inbound limiter and its declarative
59
+ * per-endpoint buckets (an armed `rateLimit` budget; with none declared
60
+ * the dispatcher registers no limiter at all), and the per-number OTP send
61
+ * budget (an SMS send path). A default install declares none of them, so
62
+ * this counter stays at 0 while the server handles traffic normally.
63
+ *
64
+ * ⇒ Read a flat `cache_*` as a question about CONFIGURATION, never as a 0%
65
+ * hit rate or a broken adapter. Before trusting a cache hit-rate panel,
66
+ * confirm at least one consumer above is actually armed.
67
+ */
26
68
  cacheLookupsTotal: "cache_lookups_total",
27
- /** Counter, labels: `adapter`, `op` (`set`|`delete`|`clear`). */
69
+ /**
70
+ * Counter, labels: `adapter`, `op` (`set`|`delete`|`clear`). Same
71
+ * "zero = no configured consumer" reading as `cacheLookupsTotal` above.
72
+ */
28
73
  cacheWritesTotal: "cache_writes_total",
29
- /** Counter, labels: `adapter`, `op`, `errorClass`. */
74
+ /**
75
+ * Counter, labels: `adapter`, `op`, `errorClass`. Same "zero = no
76
+ * configured consumer" reading as `cacheLookupsTotal` above — a zero is
77
+ * "nothing was asked of the cache", not "every call succeeded".
78
+ */
30
79
  cacheErrorsTotal: "cache_errors_total",
31
80
  // ── Background jobs — emitted by `@objectstack/runtime`'s AppPlugin ──
32
81
  /**
@@ -46,10 +95,49 @@ var SEMCONV = {
46
95
  };
47
96
  var RUNTIME_METRICS = {
48
97
  httpRequestsTotal: SEMCONV.httpRequestsTotal,
49
- httpRequestDurationMs: SEMCONV.httpRequestDurationMs,
50
- httpRequestErrorsTotal: SEMCONV.httpRequestErrorsTotal
98
+ httpRequestDurationMs: SEMCONV.httpRequestDurationMs
99
+ // `httpRequestErrorsTotal` retired with its SEMCONV declaration above
100
+ // (#9834). The alias is not a compatibility window of its own: there is no
101
+ // emitter left to read, so keeping the name here would hand callers a
102
+ // string nothing ever writes.
51
103
  };
52
104
 
105
+ // src/http-transport-metrics.ts
106
+ var HTTP_REQUEST_COUNTER_ARMED = /* @__PURE__ */ Symbol.for(
107
+ "objectstack.observability.httpRequestCounterArmed"
108
+ );
109
+ function armHttpRequestCounter(server, metrics) {
110
+ if (typeof server.afterResponse !== "function") return "unsupported";
111
+ const latched = server;
112
+ if (latched[HTTP_REQUEST_COUNTER_ARMED]) return "already-armed";
113
+ latched[HTTP_REQUEST_COUNTER_ARMED] = true;
114
+ server.afterResponse((observation) => {
115
+ metrics.counter(RUNTIME_METRICS.httpRequestsTotal, {
116
+ method: observation.method,
117
+ route: observation.routePattern,
118
+ status: String(observation.status)
119
+ });
120
+ });
121
+ return "armed";
122
+ }
123
+ var HTTP_REQUEST_DURATION_ARMED = /* @__PURE__ */ Symbol.for(
124
+ "objectstack.observability.httpRequestDurationArmed"
125
+ );
126
+ function armHttpRequestDurationHistogram(server, metrics) {
127
+ if (typeof server.afterResponse !== "function") return "unsupported";
128
+ const latched = server;
129
+ if (latched[HTTP_REQUEST_DURATION_ARMED]) return "already-armed";
130
+ latched[HTTP_REQUEST_DURATION_ARMED] = true;
131
+ server.afterResponse((observation) => {
132
+ metrics.histogram(
133
+ RUNTIME_METRICS.httpRequestDurationMs,
134
+ observation.elapsedMs,
135
+ { method: observation.method, route: observation.routePattern }
136
+ );
137
+ });
138
+ return "armed";
139
+ }
140
+
53
141
  // src/metrics-exporters.ts
54
142
  var NoopMetricsRegistry = class {
55
143
  counter() {
@@ -648,6 +736,8 @@ export {
648
736
  RUNTIME_METRICS,
649
737
  SEMCONV,
650
738
  allowPerfDisclosure,
739
+ armHttpRequestCounter,
740
+ armHttpRequestDurationHistogram,
651
741
  countServerTiming,
652
742
  currentPerfTiming,
653
743
  formatServerTiming,