@telorun/http-server 0.18.1 → 0.20.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,115 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c8d457b: One value-type annotation: `x-telo-type` says what a value IS, and the
8
+ vocabulary is data both runtimes read.
9
+
10
+ Three annotations answered one question — _what is the value at this slot, beyond
11
+ what JSON Schema's `type` vocabulary can say?_ — and each answered it
12
+ differently: `x-telo-type: TcpPort` (a nominal brand from a closed kernel table),
13
+ `x-telo-binary: true` (raw bytes, the one annotation that emitted validation
14
+ code), `x-telo-stream: true` (a live handle, exempt from schema walks). They
15
+ differ in _posture_ toward the JSON Schema layer — refine, replace, exempt — not
16
+ in kind, so a fourth cost eleven files across four packages, and three defects
17
+ followed from the spread: an unrecognized brand degraded silently, a byte slot's
18
+ expression typed as `dyn` because nothing consulted `x-telo-binary`, and a module
19
+ string-matched the keyword because a module may import `@telorun/sdk` and there
20
+ was nothing there to read.
21
+
22
+ - **The vocabulary is DATA; the binding to a language is not.** A type is one
23
+ JSON file under `sdk/value-types/`, copied into the SDK by its `prepare` and
24
+ embedded by Rust with `include_str!` — the `analyzer/migrations/` arrangement,
25
+ for the same reason. An entry declares `name`, `representation` (`json` + a
26
+ `base`, or `instance` + a symbolic `binding`), `live`, `parameters` and a
27
+ `description`, and nothing about any runtime. Each runtime carries its own
28
+ table mapping `binding` to its own identity; a binding with **no row is a hard
29
+ startup error**, never a skipped assertion, because a type that cannot be
30
+ asserted would silently exempt every slot declaring it.
31
+ - **`registerTeloKeywords`** replaces five drifted AJV registration sites — the
32
+ analyzer's `createAjv` and the kernel's `schema-validator`, `resource-context`,
33
+ `observed-state` and `manifest-schemas`, which registered overlapping lists of
34
+ twelve, four, one and one. Drift there is not cosmetic: a keyword that emits
35
+ code was simply missing from any instance that forgot it, so one schema
36
+ validated two ways depending on which AJV saw it.
37
+ - **Exemption is a property of the TYPE, not of a position.** The old walk
38
+ neutralized only a key it found in a `properties` map, so an array-of-streams
39
+ element was reached and left constrained even though it descended into `items`.
40
+ Reading liveness off the declared type makes an item, a union branch and a
41
+ property one case. It is exemption from **validation**, never from **typing**:
42
+ a live type's arguments still travel through every schema-typing walk.
43
+ - **Value types are generic.** An entry declares named type parameters and the
44
+ annotation's object form supplies arguments — `{ name: Telo.Stream, of:
45
+ Telo.Bytes }`. An argument is a schema node, so it nests with no new grammar,
46
+ and a bare name is sugar for a node carrying only the annotation, normalized in
47
+ the single reader. Comparison is **covariant and gradual**: an omitted argument
48
+ is _any_ in both directions, so every producer and consumer that has not
49
+ declared an element keeps checking exactly as it did. A definite conflict is
50
+ `CEL_TYPE_ARGUMENT_MISMATCH`, reported where a produced value's schema meets a
51
+ consuming slot's — a step's `inputs:` against the invoked target's contract,
52
+ which is the one place both halves are in hand.
53
+ - **A shape is named with `!ref`**, Telo's one reference grammar, and `use:
54
+ schema` has been in the `x-telo-ref` vocabulary for exactly this relation all
55
+ along. The loader normalizes it to the canonical `telo:<module>/<Type>` `$ref`
56
+ — authoring surface and internal form, the split `resolveRefSentinels` and
57
+ `resolveSchemaRefKinds` already have. Normalizing rather than inlining is what
58
+ preserves schema identity (the compiled-validator cache is keyed on it) and
59
+ leaves a recursive shape expressible; carrying the owning module is what makes
60
+ resolution alias-aware, where matching a bare `metadata.name` across a
61
+ flattened list silently dropped the alias.
62
+ - **A tag's produced type is declared by its ENGINE.** `TemplatingEngine` gains
63
+ `producedType()`; `!include-bytes` declares `Telo.Bytes` and `!include-text`
64
+ declares `type: string`, and `substituteCelFields` loses its tag-name branch —
65
+ the only place a tag's produced type was written down, written in the consumer.
66
+ - **`X_TELO_TYPE_UNKNOWN`** (Levenshtein-suggested) and
67
+ **`X_TELO_TYPE_ARGUMENT_UNKNOWN`** replace the silent degrade, on every
68
+ schema-bearing field of every manifest — not only definition docs, since an
69
+ inline `inputType:` on an ordinary resource carries a schema too.
70
+ - **The migration selector gains a schema region.** `inSchema: true` bounds a
71
+ rule to the kernel's own schema-valued keys, and only with it may `inKind` /
72
+ `under` be `["*"]`, and only for a rule keyed on an `x-telo-*` annotation. That
73
+ pairing is the containment: an annotation keyword occurs in author-written
74
+ schema fragments inside ordinary resource documents, and that set of kinds is
75
+ open, so enumerating it would be both incomplete and a violation of the
76
+ topology-driven constraint.
77
+
78
+ **Breaking:** `x-telo-binary` and `x-telo-stream` are rewritten to `x-telo-type`
79
+ at load by the `normalize-value-types` migration, so every published manifest
80
+ keeps working and `telo migrate` repairs a file in place. `binaryKeyword` /
81
+ `isBinarySlot` / `X_TELO_BINARY` are removed from `@telorun/analyzer` in favour
82
+ of `registerTeloKeywords` and the SDK accessors; `withStreamPropertiesSkipped` is
83
+ now `withLiveValuesSkipped`. Value brands are `Telo.`-qualified (`TcpPort` →
84
+ `Telo.TcpPort`), which the same migration rewrites.
85
+
86
+ ### Patch Changes
87
+
88
+ - @telorun/http-dispatch@0.4.2
89
+
90
+ ## 0.19.0
91
+
92
+ ### Minor Changes
93
+
94
+ - 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.
95
+
96
+ 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.
97
+
98
+ Behaviour changes worth knowing about:
99
+
100
+ - **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.
101
+ - **Severity follows the response** — `info`, except a 5xx which is `error`.
102
+ - **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`.
103
+ - **`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.
104
+ - **`http.server.stopped` is emitted only for a server that actually listened**, so a consumer pairing start with stop never sees an unmatched close.
105
+ - **`url.scheme` reports the socket**, not `baseUrl` — the advertised URL is routinely `https://` behind a TLS terminator while the socket is plaintext.
106
+
107
+ 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.
108
+
109
+ ### Patch Changes
110
+
111
+ - @telorun/http-dispatch@0.4.2
112
+
3
113
  ## 0.18.1
4
114
 
5
115
  ### 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,6 +1,6 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import { CatchEntry, dispatchCatches, dispatchReturns, ReturnEntry, validateNoContentTypeHeader, validateStreamWhenDoesNotReferenceResult, } from "@telorun/http-dispatch";
3
- import { InvokeError, isCancellationError, isInvokeError, Ref, Stream, } from "@telorun/sdk";
3
+ import { InvokeError, isCancellationError, isInvokeError, isLiveSlot, Ref, Stream, } from "@telorun/sdk";
4
4
  import { fastifyReplySink } from "./fastify-reply-sink.js";
5
5
  const HttpApiRouteManifest = Type.Object({
6
6
  request: Type.Object({
@@ -61,10 +61,16 @@ export class HttpServerApi {
61
61
  const handlerName = handlerRef?.name ?? "";
62
62
  const translatedPath = joinMountPath(prefix, translateOpenApiPath(route.request.path));
63
63
  const schema = { response: {} };
64
- // A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
64
+ // A live-typed body is delivered as a raw `Stream<Uint8Array>` (see the
65
65
  // server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
66
66
  // body-schema registration and wrap the raw request stream in the handler.
67
- const streamBody = route.request.schema?.body?.["x-telo-stream"] === true;
67
+ //
68
+ // Read through the SDK accessor rather than by string-matching the keyword.
69
+ // A module may import `@telorun/sdk` and nothing else, and before the value
70
+ // types were unified there was nothing on that surface to read — so this was
71
+ // a literal key comparison, the same shape as the four surfaces that
72
+ // string-matched `x-telo-ref` before `ref-slot.ts`.
73
+ const streamBody = isLiveSlot(route.request.schema?.body);
68
74
  if (route.request.schema?.query)
69
75
  schema.querystring = route.request.schema.query;
70
76
  if (route.request.schema?.params)
@@ -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.20.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.73.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@telorun/sdk": "*"
Binary file
@@ -14,6 +14,7 @@ import {
14
14
  InvokeError,
15
15
  isCancellationError,
16
16
  isInvokeError,
17
+ isLiveSlot,
17
18
  KindRef,
18
19
  Ref,
19
20
  ResourceContext,
@@ -95,10 +96,16 @@ export class HttpServerApi implements ResourceInstance {
95
96
 
96
97
  const schema: any = { response: {} };
97
98
 
98
- // A stream-marked body is delivered as a raw `Stream<Uint8Array>` (see the
99
+ // A live-typed body is delivered as a raw `Stream<Uint8Array>` (see the
99
100
  // server's `contentTypeParsers[].stream`); it is opaque to AJV, so skip
100
101
  // body-schema registration and wrap the raw request stream in the handler.
101
- const streamBody = route.request.schema?.body?.["x-telo-stream"] === true;
102
+ //
103
+ // Read through the SDK accessor rather than by string-matching the keyword.
104
+ // A module may import `@telorun/sdk` and nothing else, and before the value
105
+ // types were unified there was nothing on that surface to read — so this was
106
+ // a literal key comparison, the same shape as the four surfaces that
107
+ // string-matched `x-telo-ref` before `ref-slot.ts`.
108
+ const streamBody = isLiveSlot(route.request.schema?.body);
102
109
 
103
110
  if (route.request.schema?.query) schema.querystring = route.request.schema.query;
104
111
  if (route.request.schema?.params) schema.params = route.request.schema.params;
@@ -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