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