@telorun/http-server 0.18.1 → 0.19.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,28 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 07fca98: `Http.Server` emits its own access record instead of passing Fastify's through, so the log is the kind's contract rather than one framework's prose — which is what lets a Rust or Go implementation of the kind produce records a single consumer can read.
8
+
9
+ Each record carries an `event_name` (`http.server.started`, `http.server.request`, `http.server.request.started`, `http.server.stopped`); consumers key on that, never on the message text. Attributes follow OpenTelemetry conventions: `http.route` (the low-cardinality matched template) rather than `url.path`, and `http.server.request.duration` in seconds.
10
+
11
+ Behaviour changes worth knowing about:
12
+
13
+ - **One `info` record per request** on completion, instead of Fastify's two. The received-side record moves to `debug`, where it still catches a request that hangs and never completes.
14
+ - **Severity follows the response** — `info`, except a 5xx which is `error`.
15
+ - **A mount entry may carry `logging.level`**, so a health endpoint polled every second, or a static mount serving a built SPA, can go quiet (`level: warn`) while the rest of the server keeps logging. The import-scoped threshold cannot express this: one server is a single resource in a single scope. A quietened mount still reports its own 500, because that is logged at `error`.
16
+ - **`http.route` is omitted when no route matched.** Falling back to the concrete URL let an unauthenticated 404 scan write unbounded cardinality into the `info`-level attribute dashboards group on; OpenTelemetry requires omission when there is no match.
17
+ - **`http.server.stopped` is emitted only for a server that actually listened**, so a consumer pairing start with stop never sees an unmatched close.
18
+ - **`url.scheme` reports the socket**, not `baseUrl` — the advertised URL is routinely `https://` behind a TLS terminator while the socket is plaintext.
19
+
20
+ The Telo-backed logger adapter is now injected unconditionally. Gating it on `info` handed Fastify its null logger at `level: warn` and silently dropped every diagnostic Fastify owns — its error-handler failures, reply-send failures and aborted-request hooks — which are exactly the records `warn` is meant to keep.
21
+
22
+ ### Patch Changes
23
+
24
+ - @telorun/http-dispatch@0.4.2
25
+
3
26
  ## 0.18.1
4
27
 
5
28
  ### Patch Changes
package/README.md CHANGED
@@ -81,6 +81,7 @@ code: |
81
81
 
82
82
  - [`Http.Server` / `Http.Api` returns & catches](docs/returns-and-catches.md) — outcome lists, MIME negotiation, stream mode.
83
83
  - [Serving static files & frontends](docs/static-files.md) — `Http.Static`, manifest-relative roots, SPA fallback, asset caching.
84
+ - [Log events](docs/log-events.md) — the `event_name` and attributes every implementation of this kind emits, and how to turn request logging off.
84
85
 
85
86
  ## Implementation Contract
86
87
 
@@ -236,3 +237,29 @@ never a framework's proxy-config object:
236
237
  (a client with direct network access could otherwise spoof the headers).
237
238
  - When `trustForwardedHeaders` is set, the request protocol/host exposed to handlers
238
239
  MUST also reflect the forwarded headers.
240
+
241
+ ### 6. Log events
242
+
243
+ Every implementation emits the same log events — `http.server.started`,
244
+ `http.server.request.started`, `http.server.request`, `http.server.stopped` —
245
+ with the same OpenTelemetry attributes. See [log events](docs/log-events.md) for
246
+ the table.
247
+
248
+ The `event_name` and the attributes are the contract; the message text is not.
249
+ Message strings come from whatever framework is underneath and differ per
250
+ runtime, so **a consumer MUST key on `event_name`, never on the message**.
251
+
252
+ Severity follows the response: `info`, except a **5xx**, which is `error`. A
253
+ mount entry MAY carry `logging.level` to set its own floor — `warn` silences a
254
+ health check polled every second, while its 5xx still surfaces because that is
255
+ logged at `error`.
256
+
257
+ Two consequences for an implementer:
258
+
259
+ - **Disable the framework's own request logging** and emit from middleware
260
+ (Fastify's `onResponse`, a `tower` layer, an `http.Handler` wrapper). Passing a
261
+ framework's own access lines through is how a runtime ends up shipping Pino's
262
+ or `tower-http`'s record shape instead of this kind's.
263
+ - **One `info` record per request, on completion.** The received-side record is
264
+ `debug`; it exists only so a request that hangs and never completes still
265
+ leaves a trace.
@@ -25,5 +25,17 @@ export interface FastifyLogger {
25
25
  silent(...args: unknown[]): void;
26
26
  child(bindings: Record<string, unknown>, options?: unknown): FastifyLogger;
27
27
  }
28
+ /**
29
+ * The text the controller substitutes for Fastify's listen announcement, via
30
+ * `listenTextResolver` on `app.listen()`. The controller emits
31
+ * `http.server.started` with the address in attributes, so this line is dropped
32
+ * rather than shipped beside it.
33
+ *
34
+ * A constant this module PRODUCES, not a pattern matched against Fastify's
35
+ * wording. That distinction is the whole point: this kind's contract says a
36
+ * message is prose and nothing should match on it, so suppressing by matching
37
+ * Fastify's own sentence would break the rule it exists to state.
38
+ */
39
+ export declare const LISTEN_SUPERSEDED = "\0telo:listen-superseded";
28
40
  export declare function createFastifyTeloLogger(log: Logger): FastifyLogger;
29
41
  export { pinoLevelForSeverity, severityForPinoLevel };
Binary file
@@ -1,5 +1,5 @@
1
1
  import { CatchEntry, ReturnEntry } from "@telorun/http-dispatch";
2
- import { type Invocable, type KindRef, type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
2
+ import { type Invocable, type KindRef, type LevelName, type ResourceContext, type ResourceInstance, type RuntimeResource } from "@telorun/sdk";
3
3
  import { FastifyInstance } from "fastify";
4
4
  /** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
5
5
  * live instance into a mount's `mount` slot (x-telo-ref `Telo.Mount`) — cross-module refs
@@ -42,6 +42,9 @@ type HttpServerResource = RuntimeResource & {
42
42
  mounts?: Array<{
43
43
  path?: string;
44
44
  mount?: Mountable;
45
+ logging?: {
46
+ level?: LevelName;
47
+ };
45
48
  }>;
46
49
  notFoundHandler?: {
47
50
  invoke: KindRef<Invocable>;
@@ -1,14 +1,17 @@
1
1
  import cors from "@fastify/cors";
2
- import { createFastifyTeloLogger } from "./fastify-telo-logger.js";
2
+ import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
3
3
  import swagger from "@fastify/swagger";
4
4
  import apiReference from "@scalar/fastify-api-reference";
5
5
  import { dispatchCatches, dispatchReturns, } from "@telorun/http-dispatch";
6
- import { isInvokeError, SEVERITY, } from "@telorun/sdk";
6
+ import { isInvokeError, SEVERITY, severityForLevel, } from "@telorun/sdk";
7
7
  import addFormats from "ajv-formats";
8
8
  import Fastify from "fastify";
9
9
  import { fastifyReplySink } from "./fastify-reply-sink.js";
10
10
  class HttpServer {
11
11
  releaseHold = null;
12
+ /** Whether a socket actually opened, so `http.server.stopped` is only emitted
13
+ * for a server that emitted `http.server.started`. */
14
+ listening = false;
12
15
  pluginsInitialized = false;
13
16
  app;
14
17
  host;
@@ -35,24 +38,28 @@ class HttpServer {
35
38
  // it, the legacy `trustForwardedHeaders` boolean still applies.
36
39
  const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
37
40
  // §13.3: replacement, not bridging — Fastify's Pino instance is swapped for
38
- // a Telo-backed adapter, so request records are Telo records at the source
39
- // and inherit the root `logging:` block's level, encoding, redaction, and
40
- // sinks.
41
+ // a Telo-backed adapter, so its records are Telo records at the source and
42
+ // inherit the root `logging:` block's level, encoding, redaction, and sinks.
41
43
  //
42
- // Whether to instrument requests at all is derived from the resolved scope
43
- // threshold, not a manifest flag: Fastify's per-request access lines are
44
- // `info`-severity, so we instrument iff `info` is enabled for this server's
45
- // scope. Raise the http-server import to `level: warn` and the per-request
46
- // work is skipped entirely (Fastify's null logger) rather than built and
47
- // discarded per request. This is a construction-time decision — a *runtime*
48
- // threshold change (§12.4) still gates output through the adapter, but does
49
- // not re-instrument a server booted with logging off.
44
+ // The adapter is injected UNCONDITIONALLY. It used to be gated on `info`
45
+ // being enabled, because that was what avoided building a per-request record
46
+ // at a raised threshold but `disableRequestLogging` now removes that cost
47
+ // outright, so the gate's only remaining effect was to hand Fastify its null
48
+ // logger at `level: warn` and silently drop every diagnostic it owns: the
49
+ // error handler's own failures, reply-send failures, aborted-request hooks.
50
+ // Those are exactly the records `warn` is supposed to KEEP. The adapter's
51
+ // `emit` short-circuits on `log.enabled`, so a quiet server pays one
52
+ // predicate per suppressed record and keeps its errors.
50
53
  //
51
54
  // A custom logger *instance* must be passed via Fastify 5's `loggerInstance`
52
55
  // option; passing it to `logger:` throws FST_ERR_LOG_INVALID_LOGGER_CONFIG.
53
- const requestLogging = this.ctx.log.enabled(SEVERITY.info);
54
56
  this.app = Fastify({
55
- ...(requestLogging ? { loggerInstance: createFastifyTeloLogger(this.ctx.log) } : {}),
57
+ loggerInstance: createFastifyTeloLogger(this.ctx.log),
58
+ // Fastify's own per-request lines are off: this kind emits its own from
59
+ // `onRequest` / `onResponse` instead (see `installRequestLogging`), so the
60
+ // access record's shape is this KIND's contract rather than Pino's prose —
61
+ // which is what lets a Rust or Go implementation emit the same thing.
62
+ disableRequestLogging: true,
56
63
  trustProxy,
57
64
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default] },
58
65
  });
@@ -64,7 +71,101 @@ class HttpServer {
64
71
  }
65
72
  this.setupRoutes();
66
73
  }
74
+ /**
75
+ * The access log, emitted by this kind rather than by Fastify.
76
+ *
77
+ * The contract is `event_name` plus OTel attributes — never the message text,
78
+ * which is prose and differs per framework. That is what makes the record
79
+ * readable across runtimes: an axum or net/http implementation registers the
80
+ * equivalent middleware and emits the same `http.server.request`.
81
+ *
82
+ * One `info` record per request, on completion — the convention every access
83
+ * log follows (nginx, Caddy, tower-http). The received-side record is `debug`
84
+ * because it carries no outcome; its one real use is a request that HANGS and
85
+ * never completes, which otherwise leaves no trace at all.
86
+ */
87
+ /**
88
+ * Per-mount access-log floors, longest prefix first so the match is the first
89
+ * hit. Built once at init rather than per request — the mount set is fixed.
90
+ *
91
+ * A mount's `logging.level` exists because the import-scoped threshold (§12.2)
92
+ * governs a whole module instance: one `Http.Server` is one resource in one
93
+ * scope, so it cannot quieten `/health` while leaving `/api` alone. This can.
94
+ */
95
+ mountLogFloors() {
96
+ return (this.resource.mounts ?? [])
97
+ .flatMap((mount) => {
98
+ const level = mount.logging?.level;
99
+ if (!level)
100
+ return [];
101
+ return [{ prefix: mount.path || "", floor: severityForLevel(level) }];
102
+ })
103
+ .sort((a, b) => b.prefix.length - a.prefix.length);
104
+ }
105
+ installRequestLogging() {
106
+ const log = this.ctx.log;
107
+ const floors = this.mountLogFloors();
108
+ /**
109
+ * `http.route` is the matched TEMPLATE (`/todos/:id`), never the concrete
110
+ * path: low-cardinality, which is what an access log is aggregated on.
111
+ *
112
+ * When nothing matched — every 404 — there IS no template, and the key is
113
+ * omitted rather than filled with the concrete URL. Falling back would let
114
+ * an unauthenticated caller write arbitrary strings into the `info`-level
115
+ * attribute a dashboard groups on: one 404 scan, unbounded cardinality.
116
+ * Omission is also what OTel requires when there is no match.
117
+ *
118
+ * `routeOptions` is a getter that rebuilds an options object on every access,
119
+ * so it is read once per hook and passed around as the resolved value.
120
+ */
121
+ const routeAttribute = (route) => route === undefined ? {} : { "http.route": route };
122
+ /** The floor this request must clear: its mount's, or none. Matched on the
123
+ * concrete URL, since that is what a mount prefix attaches to. */
124
+ const floorFor = (request) => {
125
+ for (const { prefix, floor } of floors) {
126
+ if (prefix === "" || request.url === prefix || request.url.startsWith(`${prefix}/`)) {
127
+ return floor;
128
+ }
129
+ }
130
+ return 0;
131
+ };
132
+ /** A 5xx is not the same class of event as a 200, and both were `info`.
133
+ * Deriving severity from the status is also what makes a quietened mount
134
+ * safe: `level: warn` on a health check still surfaces it returning 500,
135
+ * rather than going blind on the path that matters most.
136
+ *
137
+ * 4xx stays `info` deliberately — a 404 or a 401 is ordinary traffic, and
138
+ * promoting it would make a scanner walking random URLs read as an incident. */
139
+ const severityForStatus = (status) => status >= 500 ? SEVERITY.error : SEVERITY.info;
140
+ this.app.addHook("onRequest", async (request) => {
141
+ if (!log.enabled(SEVERITY.debug) || SEVERITY.debug < floorFor(request))
142
+ return;
143
+ log.debug("Request received", {
144
+ "http.request.method": request.method,
145
+ ...routeAttribute(request.routeOptions?.url),
146
+ "url.path": request.url,
147
+ "httpserver.request_id": String(request.id),
148
+ }, { eventName: "http.server.request.started" });
149
+ });
150
+ this.app.addHook("onResponse", async (request, reply) => {
151
+ const severity = severityForStatus(reply.statusCode);
152
+ if (!log.enabled(severity) || severity < floorFor(request))
153
+ return;
154
+ log.log(severity, "Request completed", {
155
+ "http.request.method": request.method,
156
+ ...routeAttribute(request.routeOptions?.url),
157
+ "http.response.status_code": reply.statusCode,
158
+ // OTel's name in OTel's unit: seconds, as a double. Fastify measures in
159
+ // milliseconds at full `hrtime` precision, so this is rounded to
160
+ // microseconds — finer than anything an access log needs, and without
161
+ // it the division prints seventeen digits of float noise.
162
+ "http.server.request.duration": Math.round(reply.elapsedTime * 1000) / 1e6,
163
+ "httpserver.request_id": String(request.id),
164
+ }, { eventName: "http.server.request" });
165
+ });
166
+ }
67
167
  async setupPlugins() {
168
+ this.installRequestLogging();
68
169
  for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
69
170
  if (stream) {
70
171
  // Raw passthrough: omit `parseAs` so Fastify hands the handler the
@@ -261,7 +362,27 @@ class HttpServer {
261
362
  async run() {
262
363
  this.releaseHold = this.ctx.acquireHold();
263
364
  try {
264
- await this.app.listen({ host: this.host, port: this.port });
365
+ await this.app.listen({
366
+ host: this.host,
367
+ port: this.port,
368
+ // Fastify announces "Server listening at http://…" through the injected
369
+ // logger, interpolating the address into prose — unparseable, and exactly
370
+ // what §4.1 routes into attributes. Replacing the text with a constant
371
+ // this module owns lets the adapter drop it without pattern-matching
372
+ // Fastify's wording, which is the thing this kind's own contract forbids.
373
+ listenTextResolver: () => LISTEN_SUPERSEDED,
374
+ });
375
+ this.listening = true;
376
+ this.ctx.log.info("Listening", {
377
+ "server.address": this.host,
378
+ "server.port": this.port,
379
+ // The SOCKET's scheme, which this kind only ever opens as plain HTTP —
380
+ // there is no TLS field on `Http.Server`. `baseUrl` is the ADVERTISED
381
+ // url and is routinely `https://` behind a terminator, so deriving from
382
+ // it would claim TLS for a plaintext socket on the most common
383
+ // production deployment there is.
384
+ "url.scheme": "http",
385
+ }, { eventName: "http.server.started" });
265
386
  await this.ctx.emitEvent(`${this.resource.metadata.name}.Listening`, {
266
387
  port: this.port,
267
388
  host: this.host,
@@ -285,6 +406,14 @@ class HttpServer {
285
406
  this.releaseHold = null;
286
407
  }
287
408
  await this.app.close();
409
+ // Only if a socket actually opened. A server that initialized but was never
410
+ // listed in `targets:`, or whose `listen()` threw, would otherwise report a
411
+ // close for something that never started — and a consumer pairing the two
412
+ // events for uptime or leak detection sees an unmatched close.
413
+ if (this.listening) {
414
+ this.listening = false;
415
+ this.ctx.log.info("Stopped listening", { "server.address": this.host, "server.port": this.port }, { eventName: "http.server.stopped" });
416
+ }
288
417
  }
289
418
  }
290
419
  export async function create(resource, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Telo HTTP Server module - HTTP server and API resource kinds for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -55,7 +55,7 @@
55
55
  "@types/node": "^20.0.0",
56
56
  "typescript": "^5.0.0",
57
57
  "vitest": "^2.1.8",
58
- "@telorun/sdk": "0.67.0"
58
+ "@telorun/sdk": "0.70.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@telorun/sdk": "*"
Binary file
@@ -1,5 +1,5 @@
1
1
  import cors from "@fastify/cors";
2
- import { createFastifyTeloLogger } from "./fastify-telo-logger.js";
2
+ import { createFastifyTeloLogger, LISTEN_SUPERSEDED } from "./fastify-telo-logger.js";
3
3
  import swagger from "@fastify/swagger";
4
4
  import apiReference from "@scalar/fastify-api-reference";
5
5
  import {
@@ -11,14 +11,16 @@ import {
11
11
  import {
12
12
  isInvokeError,
13
13
  SEVERITY,
14
+ severityForLevel,
14
15
  type Invocable,
15
16
  type KindRef,
17
+ type LevelName,
16
18
  type ResourceContext,
17
19
  type ResourceInstance,
18
20
  type RuntimeResource,
19
21
  } from "@telorun/sdk";
20
22
  import addFormats from "ajv-formats";
21
- import Fastify, { FastifyInstance } from "fastify";
23
+ import Fastify, { FastifyInstance, type FastifyRequest } from "fastify";
22
24
  import { fastifyReplySink } from "./fastify-reply-sink.js";
23
25
 
24
26
  /** A mounted Telo.Mount instance (Http.Api, Mcp.HttpEndpoint, …). The kernel injects the
@@ -62,6 +64,7 @@ type HttpServerResource = RuntimeResource & {
62
64
  // x-telo-ref `Telo.Mount`: Phase 5 replaces this slot with the live mounted
63
65
  // instance (Http.Api, Mcp.HttpEndpoint, …), local or imported.
64
66
  mount?: Mountable;
67
+ logging?: { level?: LevelName };
65
68
  }>;
66
69
  notFoundHandler?: {
67
70
  invoke: KindRef<Invocable>;
@@ -81,6 +84,9 @@ type ResolvedHandler = {
81
84
 
82
85
  class HttpServer implements ResourceInstance {
83
86
  private releaseHold: (() => void) | null = null;
87
+ /** Whether a socket actually opened, so `http.server.stopped` is only emitted
88
+ * for a server that emitted `http.server.started`. */
89
+ private listening = false;
84
90
  private pluginsInitialized = false;
85
91
  private readonly app: FastifyInstance;
86
92
  private readonly host: string;
@@ -113,24 +119,28 @@ class HttpServer implements ResourceInstance {
113
119
  // it, the legacy `trustForwardedHeaders` boolean still applies.
114
120
  const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
115
121
  // §13.3: replacement, not bridging — Fastify's Pino instance is swapped for
116
- // a Telo-backed adapter, so request records are Telo records at the source
117
- // and inherit the root `logging:` block's level, encoding, redaction, and
118
- // sinks.
122
+ // a Telo-backed adapter, so its records are Telo records at the source and
123
+ // inherit the root `logging:` block's level, encoding, redaction, and sinks.
119
124
  //
120
- // Whether to instrument requests at all is derived from the resolved scope
121
- // threshold, not a manifest flag: Fastify's per-request access lines are
122
- // `info`-severity, so we instrument iff `info` is enabled for this server's
123
- // scope. Raise the http-server import to `level: warn` and the per-request
124
- // work is skipped entirely (Fastify's null logger) rather than built and
125
- // discarded per request. This is a construction-time decision — a *runtime*
126
- // threshold change (§12.4) still gates output through the adapter, but does
127
- // not re-instrument a server booted with logging off.
125
+ // The adapter is injected UNCONDITIONALLY. It used to be gated on `info`
126
+ // being enabled, because that was what avoided building a per-request record
127
+ // at a raised threshold but `disableRequestLogging` now removes that cost
128
+ // outright, so the gate's only remaining effect was to hand Fastify its null
129
+ // logger at `level: warn` and silently drop every diagnostic it owns: the
130
+ // error handler's own failures, reply-send failures, aborted-request hooks.
131
+ // Those are exactly the records `warn` is supposed to KEEP. The adapter's
132
+ // `emit` short-circuits on `log.enabled`, so a quiet server pays one
133
+ // predicate per suppressed record and keeps its errors.
128
134
  //
129
135
  // A custom logger *instance* must be passed via Fastify 5's `loggerInstance`
130
136
  // option; passing it to `logger:` throws FST_ERR_LOG_INVALID_LOGGER_CONFIG.
131
- const requestLogging = this.ctx.log.enabled(SEVERITY.info);
132
137
  this.app = Fastify({
133
- ...(requestLogging ? { loggerInstance: createFastifyTeloLogger(this.ctx.log) } : {}),
138
+ loggerInstance: createFastifyTeloLogger(this.ctx.log),
139
+ // Fastify's own per-request lines are off: this kind emits its own from
140
+ // `onRequest` / `onResponse` instead (see `installRequestLogging`), so the
141
+ // access record's shape is this KIND's contract rather than Pino's prose —
142
+ // which is what lets a Rust or Go implementation emit the same thing.
143
+ disableRequestLogging: true,
134
144
  trustProxy,
135
145
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default as any] },
136
146
  });
@@ -144,7 +154,115 @@ class HttpServer implements ResourceInstance {
144
154
  this.setupRoutes();
145
155
  }
146
156
 
157
+ /**
158
+ * The access log, emitted by this kind rather than by Fastify.
159
+ *
160
+ * The contract is `event_name` plus OTel attributes — never the message text,
161
+ * which is prose and differs per framework. That is what makes the record
162
+ * readable across runtimes: an axum or net/http implementation registers the
163
+ * equivalent middleware and emits the same `http.server.request`.
164
+ *
165
+ * One `info` record per request, on completion — the convention every access
166
+ * log follows (nginx, Caddy, tower-http). The received-side record is `debug`
167
+ * because it carries no outcome; its one real use is a request that HANGS and
168
+ * never completes, which otherwise leaves no trace at all.
169
+ */
170
+ /**
171
+ * Per-mount access-log floors, longest prefix first so the match is the first
172
+ * hit. Built once at init rather than per request — the mount set is fixed.
173
+ *
174
+ * A mount's `logging.level` exists because the import-scoped threshold (§12.2)
175
+ * governs a whole module instance: one `Http.Server` is one resource in one
176
+ * scope, so it cannot quieten `/health` while leaving `/api` alone. This can.
177
+ */
178
+ private mountLogFloors(): ReadonlyArray<{ prefix: string; floor: number }> {
179
+ return (this.resource.mounts ?? [])
180
+ .flatMap((mount) => {
181
+ const level = mount.logging?.level;
182
+ if (!level) return [];
183
+ return [{ prefix: mount.path || "", floor: severityForLevel(level) }];
184
+ })
185
+ .sort((a, b) => b.prefix.length - a.prefix.length);
186
+ }
187
+
188
+ private installRequestLogging() {
189
+ const log = this.ctx.log;
190
+ const floors = this.mountLogFloors();
191
+ /**
192
+ * `http.route` is the matched TEMPLATE (`/todos/:id`), never the concrete
193
+ * path: low-cardinality, which is what an access log is aggregated on.
194
+ *
195
+ * When nothing matched — every 404 — there IS no template, and the key is
196
+ * omitted rather than filled with the concrete URL. Falling back would let
197
+ * an unauthenticated caller write arbitrary strings into the `info`-level
198
+ * attribute a dashboard groups on: one 404 scan, unbounded cardinality.
199
+ * Omission is also what OTel requires when there is no match.
200
+ *
201
+ * `routeOptions` is a getter that rebuilds an options object on every access,
202
+ * so it is read once per hook and passed around as the resolved value.
203
+ */
204
+ const routeAttribute = (route: string | undefined): { "http.route"?: string } =>
205
+ route === undefined ? {} : { "http.route": route };
206
+
207
+ /** The floor this request must clear: its mount's, or none. Matched on the
208
+ * concrete URL, since that is what a mount prefix attaches to. */
209
+ const floorFor = (request: FastifyRequest): number => {
210
+ for (const { prefix, floor } of floors) {
211
+ if (prefix === "" || request.url === prefix || request.url.startsWith(`${prefix}/`)) {
212
+ return floor;
213
+ }
214
+ }
215
+ return 0;
216
+ };
217
+
218
+ /** A 5xx is not the same class of event as a 200, and both were `info`.
219
+ * Deriving severity from the status is also what makes a quietened mount
220
+ * safe: `level: warn` on a health check still surfaces it returning 500,
221
+ * rather than going blind on the path that matters most.
222
+ *
223
+ * 4xx stays `info` deliberately — a 404 or a 401 is ordinary traffic, and
224
+ * promoting it would make a scanner walking random URLs read as an incident. */
225
+ const severityForStatus = (status: number): number =>
226
+ status >= 500 ? SEVERITY.error : SEVERITY.info;
227
+
228
+ this.app.addHook("onRequest", async (request) => {
229
+ if (!log.enabled(SEVERITY.debug) || SEVERITY.debug < floorFor(request)) return;
230
+ log.debug(
231
+ "Request received",
232
+ {
233
+ "http.request.method": request.method,
234
+ ...routeAttribute(request.routeOptions?.url),
235
+ "url.path": request.url,
236
+ "httpserver.request_id": String(request.id),
237
+ },
238
+ { eventName: "http.server.request.started" },
239
+ );
240
+ });
241
+
242
+ this.app.addHook("onResponse", async (request, reply) => {
243
+ const severity = severityForStatus(reply.statusCode);
244
+ if (!log.enabled(severity) || severity < floorFor(request)) return;
245
+ log.log(
246
+ severity,
247
+ "Request completed",
248
+ {
249
+ "http.request.method": request.method,
250
+ ...routeAttribute(request.routeOptions?.url),
251
+ "http.response.status_code": reply.statusCode,
252
+ // OTel's name in OTel's unit: seconds, as a double. Fastify measures in
253
+ // milliseconds at full `hrtime` precision, so this is rounded to
254
+ // microseconds — finer than anything an access log needs, and without
255
+ // it the division prints seventeen digits of float noise.
256
+ "http.server.request.duration": Math.round(reply.elapsedTime * 1000) / 1e6,
257
+ "httpserver.request_id": String(request.id),
258
+ },
259
+ { eventName: "http.server.request" },
260
+ );
261
+ });
262
+ }
263
+
147
264
  private async setupPlugins() {
265
+ this.installRequestLogging();
148
266
  for (const { contentType, parser, stream } of this.resource.contentTypeParsers ?? []) {
149
267
  if (stream) {
150
268
  // Raw passthrough: omit `parseAs` so Fastify hands the handler the
@@ -371,7 +489,31 @@ class HttpServer implements ResourceInstance {
371
489
  async run(): Promise<void> {
372
490
  this.releaseHold = this.ctx.acquireHold();
373
491
  try {
374
- await this.app.listen({ host: this.host, port: this.port });
492
+ await this.app.listen({
493
+ host: this.host,
494
+ port: this.port,
495
+ // Fastify announces "Server listening at http://…" through the injected
496
+ // logger, interpolating the address into prose — unparseable, and exactly
497
+ // what §4.1 routes into attributes. Replacing the text with a constant
498
+ // this module owns lets the adapter drop it without pattern-matching
499
+ // Fastify's wording, which is the thing this kind's own contract forbids.
500
+ listenTextResolver: () => LISTEN_SUPERSEDED,
501
+ });
502
+ this.listening = true;
503
+ this.ctx.log.info(
504
+ "Listening",
505
+ {
506
+ "server.address": this.host,
507
+ "server.port": this.port,
508
+ // The SOCKET's scheme, which this kind only ever opens as plain HTTP —
509
+ // there is no TLS field on `Http.Server`. `baseUrl` is the ADVERTISED
510
+ // url and is routinely `https://` behind a terminator, so deriving from
511
+ // it would claim TLS for a plaintext socket on the most common
512
+ // production deployment there is.
513
+ "url.scheme": "http",
514
+ },
515
+ { eventName: "http.server.started" },
516
+ );
375
517
  await this.ctx.emitEvent(`${this.resource.metadata.name}.Listening`, {
376
518
  port: this.port,
377
519
  host: this.host,
@@ -395,6 +537,18 @@ class HttpServer implements ResourceInstance {
395
537
  this.releaseHold = null;
396
538
  }
397
539
  await this.app.close();
540
+ // Only if a socket actually opened. A server that initialized but was never
541
+ // listed in `targets:`, or whose `listen()` threw, would otherwise report a
542
+ // close for something that never started — and a consumer pairing the two
543
+ // events for uptime or leak detection sees an unmatched close.
544
+ if (this.listening) {
545
+ this.listening = false;
546
+ this.ctx.log.info(
547
+ "Stopped listening",
548
+ { "server.address": this.host, "server.port": this.port },
549
+ { eventName: "http.server.stopped" },
550
+ );
551
+ }
398
552
  }
399
553
  }
400
554