@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 +23 -0
- package/README.md +27 -0
- package/dist/fastify-telo-logger.d.ts +12 -0
- package/dist/fastify-telo-logger.js +0 -0
- package/dist/http-server-controller.d.ts +4 -1
- package/dist/http-server-controller.js +145 -16
- package/package.json +2 -2
- package/src/fastify-telo-logger.ts +0 -0
- package/src/http-server-controller.ts +170 -16
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
|
|
39
|
-
//
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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.
|
|
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
|
|
117
|
-
//
|
|
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
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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
|
-
|
|
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({
|
|
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
|
|