@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 +44 -0
- package/README.md +29 -2
- package/dist/fastify-telo-logger.d.ts +12 -0
- package/dist/fastify-telo-logger.js +0 -0
- package/dist/http-api-controller.js +4 -1
- package/dist/http-server-controller.d.ts +4 -1
- package/dist/http-server-controller.js +156 -18
- package/package.json +2 -2
- package/src/fastify-telo-logger.ts +0 -0
- package/src/http-api-controller.ts +4 -1
- package/src/http-server-controller.ts +181 -18
- package/tests/request-cancel.test.ts +12 -1
- package/tests/request-span.test.ts +5 -1
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:
|
|
31
|
-
JS:
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
@@ -159,7 +277,12 @@ class HttpServer implements ResourceInstance {
|
|
|
159
277
|
{ parseAs: "string" },
|
|
160
278
|
async (_req, body, done) => {
|
|
161
279
|
try {
|
|
162
|
-
|
|
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
|
-
|
|
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({
|
|
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 {
|
|
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: () => {},
|