@telorun/http-server 0.18.0 → 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,49 @@
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
+
26
+ ## 0.18.1
27
+
28
+ ### Patch Changes
29
+
30
+ - 8a9b494: Inbound dispatch now goes through `ctx.rootContext()` — the route handler, the
31
+ `notFoundHandler`, and a `contentTypeParsers` parser all receive a context
32
+ minted for the request rather than whatever was ambient when the route was
33
+ registered.
34
+
35
+ No behaviour changes for an app today: a route handler already ran on a
36
+ per-request cancellation context, and the other two are dispatched from socket
37
+ callbacks where the ambient is empty anyway. What changes is that the guarantee
38
+ is now _stated_ rather than incidental. Execution zones (`kernel/specs/execution-zones.md`
39
+ §7) make it a conformance obligation on every inbound registrant, and the
40
+ analyzer's hard error on `trigger.inbound` edges — a zone requirement reaching
41
+ an HTTP route is `ZONE_REQUIREMENT_UNSATISFIED` — rests on it holding. Before,
42
+ it held because every shipped inbound kind happens to be a `Telo.Service`,
43
+ which is a property of those kinds rather than of the edge.
44
+
45
+ - @telorun/http-dispatch@0.4.2
46
+
3
47
  ## 0.18.0
4
48
 
5
49
  ### Minor Changes
package/README.md CHANGED
@@ -27,8 +27,8 @@ Language- and framework-agnostic HTTP server for Telo. Declarative routes, schem
27
27
  kind: Telo.Application
28
28
  metadata: { name: hello-http, version: 1.0.0 }
29
29
  imports:
30
- Http: std/http-server@0.19.1
31
- JS: std/javascript@0.7.0
30
+ Http: oci://ghcr.io/telorun/http-server@0.19.1
31
+ JS: oci://ghcr.io/telorun/javascript@0.7.0
32
32
  targets: [ !ref Server ]
33
33
  ---
34
34
  kind: Http.Server
@@ -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
@@ -139,7 +139,10 @@ export class HttpServerApi {
139
139
  // Open a request span rooting this request's own trace: the handler (and
140
140
  // its nested invokes) nest under it, and it's labelled with the route so
141
141
  // the trace shows the actual method+path, attributed to this Http.Api.
142
- const span = await this.ctx.openSpan(cancellation.context, {
142
+ // rootContext: an inbound registrant dispatches with a context that
143
+ // inherits nothing ambient — no zones, no trace parent, no caller token
144
+ // (the conformance obligation in kernel/specs/execution-zones.md §7).
145
+ const span = await this.ctx.openSpan(this.ctx.rootContext({ cancellation }), {
143
146
  ref: { kind: "Http.Api", name: this.apiName },
144
147
  label: `${route.request.method} ${route.request.path}`,
145
148
  attributes: { method: route.request.method, path: route.request.path },
@@ -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
@@ -77,7 +178,12 @@ class HttpServer {
77
178
  else if (parser) {
78
179
  this.app.addContentTypeParser(contentType, { parseAs: "string" }, async (_req, body, done) => {
79
180
  try {
80
- done(null, await parser.invoke({ body }));
181
+ // The bound entry point forwards every argument, so the root
182
+ // context rides in as the InvokeContext — §7's obligation for an
183
+ // inbound registrant. (This path still calls the instance
184
+ // directly rather than going through `invokeResolved`, so it is
185
+ // untraced; that predates zones and is tracked separately.)
186
+ done(null, await parser.invoke({ body }, this.ctx.rootContext()));
81
187
  }
82
188
  catch (err) {
83
189
  done(err, undefined);
@@ -230,7 +336,11 @@ class HttpServer {
230
336
  };
231
337
  let result;
232
338
  try {
233
- result = await this.ctx.invoke(handler.kind, handler.name, invokeInput);
339
+ // rootContext: an inbound registrant dispatches with a context that
340
+ // inherits nothing ambient (kernel/specs/execution-zones.md §7).
341
+ result = await this.ctx.invoke(handler.kind, handler.name, invokeInput, {
342
+ ctx: this.ctx.rootContext(),
343
+ });
234
344
  }
235
345
  catch (err) {
236
346
  if (!isInvokeError(err))
@@ -252,7 +362,27 @@ class HttpServer {
252
362
  async run() {
253
363
  this.releaseHold = this.ctx.acquireHold();
254
364
  try {
255
- 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" });
256
386
  await this.ctx.emitEvent(`${this.resource.metadata.name}.Listening`, {
257
387
  port: this.port,
258
388
  host: this.host,
@@ -276,6 +406,14 @@ class HttpServer {
276
406
  this.releaseHold = null;
277
407
  }
278
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
+ }
279
417
  }
280
418
  }
281
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.0",
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.60.0"
58
+ "@telorun/sdk": "0.70.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@telorun/sdk": "*"
Binary file
@@ -174,7 +174,10 @@ export class HttpServerApi implements ResourceInstance {
174
174
  // Open a request span rooting this request's own trace: the handler (and
175
175
  // its nested invokes) nest under it, and it's labelled with the route so
176
176
  // the trace shows the actual method+path, attributed to this Http.Api.
177
- const span = await this.ctx.openSpan(cancellation.context, {
177
+ // rootContext: an inbound registrant dispatches with a context that
178
+ // inherits nothing ambient — no zones, no trace parent, no caller token
179
+ // (the conformance obligation in kernel/specs/execution-zones.md §7).
180
+ const span = await this.ctx.openSpan(this.ctx.rootContext({ cancellation }), {
178
181
  ref: { kind: "Http.Api", name: this.apiName },
179
182
  label: `${route.request.method} ${route.request.path}`,
180
183
  attributes: { method: route.request.method, path: route.request.path },
@@ -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
@@ -159,7 +277,12 @@ class HttpServer implements ResourceInstance {
159
277
  { parseAs: "string" },
160
278
  async (_req, body, done) => {
161
279
  try {
162
- done(null, await parser.invoke({ body }));
280
+ // The bound entry point forwards every argument, so the root
281
+ // context rides in as the InvokeContext — §7's obligation for an
282
+ // inbound registrant. (This path still calls the instance
283
+ // directly rather than going through `invokeResolved`, so it is
284
+ // untraced; that predates zones and is tracked separately.)
285
+ done(null, await parser.invoke({ body }, this.ctx.rootContext()));
163
286
  } catch (err) {
164
287
  done(err as Error, undefined);
165
288
  }
@@ -322,7 +445,11 @@ class HttpServer implements ResourceInstance {
322
445
 
323
446
  let result: any;
324
447
  try {
325
- result = await this.ctx.invoke(handler.kind, handler.name, invokeInput);
448
+ // rootContext: an inbound registrant dispatches with a context that
449
+ // inherits nothing ambient (kernel/specs/execution-zones.md §7).
450
+ result = await this.ctx.invoke(handler.kind, handler.name, invokeInput, {
451
+ ctx: this.ctx.rootContext(),
452
+ });
326
453
  } catch (err) {
327
454
  if (!isInvokeError(err)) throw err;
328
455
  return dispatchCatches(
@@ -362,7 +489,31 @@ class HttpServer implements ResourceInstance {
362
489
  async run(): Promise<void> {
363
490
  this.releaseHold = this.ctx.acquireHold();
364
491
  try {
365
- 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
+ );
366
517
  await this.ctx.emitEvent(`${this.resource.metadata.name}.Listening`, {
367
518
  port: this.port,
368
519
  host: this.host,
@@ -386,6 +537,18 @@ class HttpServer implements ResourceInstance {
386
537
  this.releaseHold = null;
387
538
  }
388
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
+ }
389
552
  }
390
553
  }
391
554
 
@@ -1,4 +1,9 @@
1
- import { ERR_INVOKE_CANCELLED, InvokeError, createCancellationSource } from "@telorun/sdk";
1
+ import {
2
+ ERR_INVOKE_CANCELLED,
3
+ InvokeError,
4
+ UNCANCELLABLE_CONTEXT,
5
+ createCancellationSource,
6
+ } from "@telorun/sdk";
2
7
  import Fastify from "fastify";
3
8
  import net from "node:net";
4
9
  import type { AddressInfo } from "node:net";
@@ -45,6 +50,12 @@ describe("http-server request cancellation", () => {
45
50
  ensureKindRef: () => ({ kind: "Test.Handler", name: "SlowWork" }),
46
51
  moduleContext: { expandWith: (value: unknown) => value },
47
52
  createCancellationSource: () => createCancellationSource(),
53
+ // As the kernel implements it: an inbound registrant dispatches through a
54
+ // context it minted rather than the ambient (execution-zones spec §7), and
55
+ // the request's cancellation token rides in on it — which is what makes
56
+ // the disconnect below reach the handler.
57
+ rootContext: (opts?: { cancellation?: { context: unknown } }) =>
58
+ opts?.cancellation?.context ?? UNCANCELLABLE_CONTEXT,
48
59
  invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown, c: unknown) =>
49
60
  h.invoke(input, c as { cancellation?: any }),
50
61
  emitEvent: () => {},
@@ -1,4 +1,4 @@
1
- import { createCancellationSource } from "@telorun/sdk";
1
+ import { UNCANCELLABLE_CONTEXT, createCancellationSource } from "@telorun/sdk";
2
2
  import Fastify from "fastify";
3
3
  import type { AddressInfo } from "node:net";
4
4
  import { describe, expect, it } from "vitest";
@@ -31,6 +31,10 @@ describe("http-server request span", () => {
31
31
  ensureKindRef: () => ({ kind: "JS.Script", name: "Echo" }),
32
32
  moduleContext: { expandWith: (value: unknown) => value },
33
33
  createCancellationSource: () => createCancellationSource(),
34
+ // The controller dispatches through a context it minted rather than the
35
+ // ambient — execution-zones spec §7 — so the mock must offer it.
36
+ rootContext: (opts?: { cancellation?: { context: unknown } }) =>
37
+ opts?.cancellation?.context ?? UNCANCELLABLE_CONTEXT,
34
38
  invokeResolved: (_kind: string, _name: string, h: typeof handler, input: unknown) =>
35
39
  h.invoke(input),
36
40
  emitEvent: () => {},