@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/CHANGELOG.md CHANGED
@@ -1,5 +1,220 @@
1
1
  # @objectstack/observability
2
2
 
3
+ ## 17.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 899052a: docs(observability): record what a zero on `cache_*` MEANS — "no configured consumer", not "no cache activity" (#9954)
8
+
9
+ `SEMCONV` declares the `cache_*` families as a stable namespace explicitly so
10
+ hosts can wire alerts and dashboards against it. An operator who does that gets
11
+ a flat zero on `cache_lookups_total` in a default install — and nothing in the
12
+ declaration or the operator docs said why, so the only available readings were
13
+ "0% hit rate" or "the adapter is broken". Both are wrong.
14
+
15
+ **Nothing about emission changes.** The path is proven working: #9832 wired the
16
+ cache adapter to the host's registry and #9951 pins a real lookup observing
17
+ `cache_lookups_total{adapter=memory,result=miss}`. The zero is *true*; what it
18
+ failed to communicate is its cause.
19
+
20
+ The cause, re-measured on `origin/main` rather than taken from the card: no
21
+ consumer of the `cache` service is unconditional. Every production consumer is
22
+ a rate-limit or budget counter store, and each is gated on a declaration
23
+ somebody has to write:
24
+
25
+ - `packages/plugins/plugin-auth/src/auth-plugin.ts` — better-auth's per-IP
26
+ counters, reached only when `rate_limit_max` or `rate_limit_window_seconds`
27
+ is explicitly supplied in auth settings.
28
+ - `packages/runtime/src/dispatcher-plugin.ts` — the inbound rate limiter and
29
+ the declarative per-endpoint buckets. Both register *nothing at all* when no
30
+ budget is declared (`createInboundRateLimitMiddleware` returns `null`;
31
+ `limiterFor` returns `null` on an endpoint with no armed `rateLimit`), so an
32
+ unmetered deployment never reaches the cache.
33
+ - the per-number OTP send budget, reached only on an SMS send path.
34
+
35
+ Declare none of them — the default slate — and the family sits at 0 while the
36
+ server handles traffic normally.
37
+
38
+ So the annotation states the invariant rather than a roster: *every* consumer
39
+ is an explicitly-declared rate-limit/budget counter store. That sentence stays
40
+ true when another conditional consumer is added, and goes false exactly when an
41
+ unconditional one appears — which is when it should be revisited.
42
+
43
+ This is the mirror image of the HTTP note that landed alongside it: there a
44
+ zero means "not instrumented"; here a zero is a true count of a service nothing
45
+ asked anything of. The two are deliberately worded so they cannot be read as
46
+ the same statement.
47
+
48
+ The note is attached as per-family JSDoc rather than a `//` group header, and
49
+ that placement is load-bearing: `tsup`'s dts generation drops line comments, so
50
+ only the JSDoc form reaches `dist/index.d.ts` — i.e. the operator's IDE hover,
51
+ which is where somebody wiring a `cache_lookups_total` panel actually meets the
52
+ declaration. Verified in the built artifact, not assumed.
53
+ - 1e050a5: fix(observability): emit `http_request_duration_ms` from the transport seam, so
54
+ p95 latency sees every inbound surface instead of dispatcher routes only
55
+ (#9834)
56
+
57
+ #9835 moved `http_requests_total` to the `IHttpServer.afterResponse` seam and
58
+ stopped there, which left the two derived signals the operator guidance names
59
+ inconsistent with each other: 5xx rate covered auth's `getRawApp()` mount and
60
+ the REST data API, while p95 latency still saw only the routes the dispatcher's
61
+ own `Proxy` wrapped. A missing latency panel is at least loud; the worse
62
+ reading is the p95 that IS drawn, computed from dispatcher routes only and
63
+ presented as the server's.
64
+
65
+ - `@objectstack/observability`: new `armHttpRequestDurationHistogram(server,
66
+ metrics)`, the duration family's counterpart to `armHttpRequestCounter` —
67
+ same `afterResponse` seam, its own `Symbol.for` first-wins latch, so a host
68
+ that armed one family can still arm the other. `ArmHttpMetricResult` is the
69
+ family-neutral spelling of the result union; `ArmHttpRequestCounterResult`
70
+ stays as an alias.
71
+ - `@objectstack/plugin-hono-server`: `installHttpMetricsSeam` arms both
72
+ families, so the transport owns every transport-observable HTTP metric in the
73
+ shipped composition rather than splitting ownership across layers.
74
+ - `@objectstack/runtime`: the dispatcher offers its registry to the histogram
75
+ seam as well, and `instrumentRouteHandler` gains
76
+ `emitHttpRequestDurationMs` (default `true`) — passed `false` exactly when
77
+ the transport implements the seam, which is what keeps the dispatcher's own
78
+ routes at ONE observation instead of reintroducing the #9833 double count one
79
+ family over.
80
+
81
+ **⚠️ The observation window changes with the emitter.** The per-route wrapper
82
+ timed `await handler(req, res)` — handler latency. The transport times from
83
+ first seeing the request to the response existing, so the middleware chain and
84
+ body parse are now included and samples can only move UP. This is the number a
85
+ latency panel should show (the request's latency, not one layer's share of it),
86
+ but it is a visible shift in an existing series: compare p95 across the upgrade
87
+ boundary deliberately. Documented in `docs/OBSERVABILITY.md` and the
88
+ production-readiness guide.
89
+
90
+ `http_request_errors_total` is deliberately NOT moved. The observation carries
91
+ no throw signal — only `{method, routePattern, status, elapsedMs}` — so a
92
+ transport-side emitter would have to key off a status class, which counts a
93
+ different population than "the handler threw". That is a semantics decision,
94
+ recorded on #9834 rather than guessed at here; the counter stays ungated on
95
+ every transport and keeps its documented meaning.
96
+ - 7ff3975: feat(spec): `IHttpServer` gains an optional `afterResponse` response-observing
97
+ hook so HTTP metrics are transport-agnostic instead of Hono-only (#9835)
98
+
99
+ The contract addition (additive — a new optional member plus the
100
+ `HttpResponseObservation` / `HttpResponseObserver` types and the reserved
101
+ `UNMATCHED_ROUTE_PATTERN` label): a transport invokes each registered observer
102
+ exactly once per answered request with `{ method, routePattern, status,
103
+ elapsedMs }`, after the response exists — the observation point the `use()`
104
+ middleware contract cannot express (it runs before dispatch and never sees a
105
+ status). `routePattern` is REQUIRED to be the registered route pattern
106
+ (`/api/v1/data/:id`), never the concrete path, so no adapter re-decides metric
107
+ cardinality. Optionality is feature-detected runtime-real
108
+ (`typeof server.afterResponse === 'function'`); a transport that does not
109
+ implement the seam reports **no** HTTP metrics — zero there means "not
110
+ instrumented", never "no traffic".
111
+
112
+ Implementations and consumers in the same change:
113
+
114
+ - `@objectstack/plugin-hono-server`: `HonoHttpServer` implements the seam (the
115
+ ruled #9650 raw-app middleware becomes its delivery path — same reach,
116
+ including `getRawApp()` mounts and middleware-refused 429s); unrouted
117
+ requests are now labelled with the reserved `unmatched` pattern (previously
118
+ they could surface as `/*`).
119
+ - `@objectstack/observability`: new `armHttpRequestCounter(server, metrics)`
120
+ arms the `http_requests_total` counter through the seam at most once per
121
+ server (first caller wins), which is what makes "exactly one counter per
122
+ server" structural.
123
+ - `@objectstack/runtime`: the dispatcher offers its `observability.metrics`
124
+ registry to the seam (a host that wires only the dispatcher now counts every
125
+ inbound surface) and suppresses its own per-route copy of
126
+ `http_requests_total` when the transport implements the seam — retiring the
127
+ #9833 double count. Request-id echo, the duration histogram, the error
128
+ counter and the error reporter are unchanged.
129
+ - `@objectstack/http-conformance`: `NodeHttpServer` implements the seam, and a
130
+ new cross-adapter conformance suite locks the semantics for both adapters.
131
+ - `@objectstack/core`: re-exports the new contract types/constant.
132
+ - Updated dependencies [56656aa]
133
+ - Updated dependencies [07e630e]
134
+ - Updated dependencies [2f65b1b]
135
+ - Updated dependencies [720ee95]
136
+ - Updated dependencies [f287435]
137
+ - Updated dependencies [9aa8890]
138
+ - Updated dependencies [7c9c1dd]
139
+ - Updated dependencies [75b7c24]
140
+ - Updated dependencies [d5552ca]
141
+ - Updated dependencies [d9813a9]
142
+ - Updated dependencies [8640fb2]
143
+ - Updated dependencies [2420641]
144
+ - Updated dependencies [2ad91c3]
145
+ - Updated dependencies [f57fb38]
146
+ - Updated dependencies [00777a0]
147
+ - Updated dependencies [d491625]
148
+ - Updated dependencies [420804d]
149
+ - Updated dependencies [716ac9b]
150
+ - Updated dependencies [62b1427]
151
+ - Updated dependencies [7ea1372]
152
+ - Updated dependencies [23abe27]
153
+ - Updated dependencies [985a9cd]
154
+ - Updated dependencies [a8189ae]
155
+ - Updated dependencies [26e70fb]
156
+ - Updated dependencies [42b05af]
157
+ - Updated dependencies [2b292ce]
158
+ - Updated dependencies [abcf853]
159
+ - Updated dependencies [8b9eba5]
160
+ - Updated dependencies [d575779]
161
+ - Updated dependencies [94f7ef8]
162
+ - Updated dependencies [c5ac5e4]
163
+ - Updated dependencies [a777944]
164
+ - Updated dependencies [dd88e1c]
165
+ - Updated dependencies [856527c]
166
+ - Updated dependencies [870f710]
167
+ - Updated dependencies [79c46da]
168
+ - Updated dependencies [7ff3975]
169
+ - Updated dependencies [29d055b]
170
+ - Updated dependencies [65589d6]
171
+ - Updated dependencies [2c86fe3]
172
+ - Updated dependencies [e196c6a]
173
+ - Updated dependencies [4ab7523]
174
+ - Updated dependencies [19539b4]
175
+ - Updated dependencies [11b779e]
176
+ - Updated dependencies [739fe5b]
177
+ - Updated dependencies [4bfe1a5]
178
+ - Updated dependencies [2065e31]
179
+ - Updated dependencies [b69d0f5]
180
+ - Updated dependencies [4d47afe]
181
+ - Updated dependencies [e4e5c6e]
182
+ - Updated dependencies [9a56784]
183
+ - Updated dependencies [d00d2f6]
184
+ - Updated dependencies [df0c12d]
185
+ - Updated dependencies [d31785f]
186
+ - Updated dependencies [c308a4f]
187
+ - Updated dependencies [e2899f6]
188
+ - Updated dependencies [3851f87]
189
+ - Updated dependencies [2a29caa]
190
+ - Updated dependencies [09a6eee]
191
+ - Updated dependencies [1a7f907]
192
+ - Updated dependencies [cd455c8]
193
+ - Updated dependencies [30d3752]
194
+ - Updated dependencies [c80e7ae]
195
+ - Updated dependencies [09a9a8a]
196
+ - Updated dependencies [07026cf]
197
+ - Updated dependencies [5d4f3d5]
198
+ - Updated dependencies [4d80e8b]
199
+ - Updated dependencies [30b1c63]
200
+ - Updated dependencies [079b457]
201
+ - Updated dependencies [e43b211]
202
+ - Updated dependencies [890b38f]
203
+ - Updated dependencies [8bee54b]
204
+ - Updated dependencies [7a537ce]
205
+ - Updated dependencies [593c4bf]
206
+ - Updated dependencies [ff08691]
207
+ - Updated dependencies [60e0f90]
208
+ - Updated dependencies [90c5285]
209
+ - Updated dependencies [7901b2d]
210
+ - Updated dependencies [56bca91]
211
+ - Updated dependencies [79394d7]
212
+ - Updated dependencies [730fd9a]
213
+ - Updated dependencies [44bc51d]
214
+ - Updated dependencies [73cfddf]
215
+ - Updated dependencies [d634e66]
216
+ - @objectstack/spec@17.1.0
217
+
3
218
  ## 17.0.0
4
219
 
5
220
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -37,6 +37,8 @@ __export(index_exports, {
37
37
  RUNTIME_METRICS: () => RUNTIME_METRICS,
38
38
  SEMCONV: () => SEMCONV,
39
39
  allowPerfDisclosure: () => allowPerfDisclosure,
40
+ armHttpRequestCounter: () => armHttpRequestCounter,
41
+ armHttpRequestDurationHistogram: () => armHttpRequestDurationHistogram,
40
42
  countServerTiming: () => countServerTiming,
41
43
  currentPerfTiming: () => currentPerfTiming,
42
44
  formatServerTiming: () => formatServerTiming,
@@ -59,14 +61,28 @@ var OBSERVABILITY_ERRORS_SERVICE = "observability:errors";
59
61
 
60
62
  // src/semconv.ts
61
63
  var SEMCONV = {
62
- // ── HTTP — emitted by `@objectstack/runtime`'s instrumentRouteHandler ──
63
- /** Counter, labels: `method`, `route`, `status`. */
64
+ // ── HTTP — emitter differs per family, see each ────────────────────
65
+ /**
66
+ * Counter, labels: `method`, `route`, `status`. Emitted by the TRANSPORT
67
+ * through the `IHttpServer.afterResponse` seam (#9835), so it covers
68
+ * every inbound request on the server rather than only the routes the
69
+ * runtime dispatcher registers.
70
+ */
64
71
  httpRequestsTotal: "http_requests_total",
65
- /** Histogram (ms), labels: `method`, `route`. */
72
+ /**
73
+ * Histogram (ms), labels: `method`, `route`. Emitted by the TRANSPORT
74
+ * through the same seam (#9834). It measures the REQUEST as the transport
75
+ * sees it — first sight to the response existing, middleware chain and
76
+ * body parse included — not the handler's share of it.
77
+ */
66
78
  httpRequestDurationMs: "http_request_duration_ms",
67
79
  /**
68
- * Counter, labels: `method`, `route`. Incremented when an
69
- * in-flight handler throws after the response is sent.
80
+ * Counter, labels: `method`, `route`. Incremented when an in-flight
81
+ * handler throws after the response is sent. Emitted by
82
+ * `@objectstack/runtime`'s `instrumentRouteHandler`, and NOT movable to
83
+ * the seam above as-is: the observation carries a status but no throw
84
+ * signal, so a transport-side emitter would count a different population
85
+ * (#9834 records the fork).
70
86
  */
71
87
  httpRequestErrorsTotal: "http_request_errors_total",
72
88
  // ── Storage — emitted by `@objectstack/service-storage` adapters ──
@@ -77,11 +93,41 @@ var SEMCONV = {
77
93
  /** Counter, labels: `adapter`, `op`, `errorClass`. */
78
94
  storageErrorsTotal: "storage_errors_total",
79
95
  // ── Cache — emitted by `@objectstack/service-cache` adapters ──
80
- /** Counter, labels: `adapter` (`memory`|`redis`), `result` (`hit`|`miss`). */
96
+ // Uniform emitter; what varies is who CONSULTS the service — see below.
97
+ /**
98
+ * Counter, labels: `adapter` (`memory`|`redis`), `result` (`hit`|`miss`).
99
+ *
100
+ * ⚠️ A flat zero means "NO CONFIGURED CONSUMER", not "no cache activity",
101
+ * and — unlike the HTTP families above — it is NOT an instrumentation gap.
102
+ * The adapters hold the host's registry and count every call they receive
103
+ * (#9832 wired that; #9951 pins it), so a zero here is TRUE. What it fails
104
+ * to communicate is WHY.
105
+ *
106
+ * The why: nothing consults the `cache` service unconditionally. Every
107
+ * production consumer is a rate-limit / budget counter store, and each is
108
+ * gated on a declaration somebody has to write — better-auth's per-IP
109
+ * counters (`rate_limit_max` / `rate_limit_window_seconds` in auth
110
+ * settings), the dispatcher's inbound limiter and its declarative
111
+ * per-endpoint buckets (an armed `rateLimit` budget; with none declared
112
+ * the dispatcher registers no limiter at all), and the per-number OTP send
113
+ * budget (an SMS send path). A default install declares none of them, so
114
+ * this counter stays at 0 while the server handles traffic normally.
115
+ *
116
+ * ⇒ Read a flat `cache_*` as a question about CONFIGURATION, never as a 0%
117
+ * hit rate or a broken adapter. Before trusting a cache hit-rate panel,
118
+ * confirm at least one consumer above is actually armed.
119
+ */
81
120
  cacheLookupsTotal: "cache_lookups_total",
82
- /** Counter, labels: `adapter`, `op` (`set`|`delete`|`clear`). */
121
+ /**
122
+ * Counter, labels: `adapter`, `op` (`set`|`delete`|`clear`). Same
123
+ * "zero = no configured consumer" reading as `cacheLookupsTotal` above.
124
+ */
83
125
  cacheWritesTotal: "cache_writes_total",
84
- /** Counter, labels: `adapter`, `op`, `errorClass`. */
126
+ /**
127
+ * Counter, labels: `adapter`, `op`, `errorClass`. Same "zero = no
128
+ * configured consumer" reading as `cacheLookupsTotal` above — a zero is
129
+ * "nothing was asked of the cache", not "every call succeeded".
130
+ */
85
131
  cacheErrorsTotal: "cache_errors_total",
86
132
  // ── Background jobs — emitted by `@objectstack/runtime`'s AppPlugin ──
87
133
  /**
@@ -105,6 +151,42 @@ var RUNTIME_METRICS = {
105
151
  httpRequestErrorsTotal: SEMCONV.httpRequestErrorsTotal
106
152
  };
107
153
 
154
+ // src/http-transport-metrics.ts
155
+ var HTTP_REQUEST_COUNTER_ARMED = /* @__PURE__ */ Symbol.for(
156
+ "objectstack.observability.httpRequestCounterArmed"
157
+ );
158
+ function armHttpRequestCounter(server, metrics) {
159
+ if (typeof server.afterResponse !== "function") return "unsupported";
160
+ const latched = server;
161
+ if (latched[HTTP_REQUEST_COUNTER_ARMED]) return "already-armed";
162
+ latched[HTTP_REQUEST_COUNTER_ARMED] = true;
163
+ server.afterResponse((observation) => {
164
+ metrics.counter(RUNTIME_METRICS.httpRequestsTotal, {
165
+ method: observation.method,
166
+ route: observation.routePattern,
167
+ status: String(observation.status)
168
+ });
169
+ });
170
+ return "armed";
171
+ }
172
+ var HTTP_REQUEST_DURATION_ARMED = /* @__PURE__ */ Symbol.for(
173
+ "objectstack.observability.httpRequestDurationArmed"
174
+ );
175
+ function armHttpRequestDurationHistogram(server, metrics) {
176
+ if (typeof server.afterResponse !== "function") return "unsupported";
177
+ const latched = server;
178
+ if (latched[HTTP_REQUEST_DURATION_ARMED]) return "already-armed";
179
+ latched[HTTP_REQUEST_DURATION_ARMED] = true;
180
+ server.afterResponse((observation) => {
181
+ metrics.histogram(
182
+ RUNTIME_METRICS.httpRequestDurationMs,
183
+ observation.elapsedMs,
184
+ { method: observation.method, route: observation.routePattern }
185
+ );
186
+ });
187
+ return "armed";
188
+ }
189
+
108
190
  // src/metrics-exporters.ts
109
191
  var NoopMetricsRegistry = class {
110
192
  counter() {
@@ -704,6 +786,8 @@ function isPerfDisclosurePrincipal(ec) {
704
786
  RUNTIME_METRICS,
705
787
  SEMCONV,
706
788
  allowPerfDisclosure,
789
+ armHttpRequestCounter,
790
+ armHttpRequestDurationHistogram,
707
791
  countServerTiming,
708
792
  currentPerfTiming,
709
793
  formatServerTiming,