@telorun/http-server 0.15.2 → 0.16.1
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 +86 -0
- package/dist/fastify-telo-logger.d.ts +29 -0
- package/dist/fastify-telo-logger.js +109 -0
- package/dist/http-server-controller.d.ts +0 -1
- package/dist/http-server-controller.js +20 -2
- package/package.json +2 -2
- package/src/fastify-telo-logger.ts +152 -0
- package/src/http-server-controller.ts +20 -2
- package/tests/fastify-telo-logger.test.ts +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# @telorun/http-server
|
|
2
2
|
|
|
3
|
+
## 0.16.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e960991: Fix `Http.Server` failing to boot under Fastify 5, and derive request logging
|
|
8
|
+
from the logging pipeline rather than a per-server flag.
|
|
9
|
+
|
|
10
|
+
The Telo-backed logger adapter was passed to Fastify's `logger:` option, which
|
|
11
|
+
in Fastify 5 only accepts a boolean or config object — a custom instance must go
|
|
12
|
+
through `loggerInstance:`. Booting a server with request logging on threw
|
|
13
|
+
`FST_ERR_LOG_INVALID_LOGGER_CONFIG` at construction. The adapter is now wired
|
|
14
|
+
through `loggerInstance`.
|
|
15
|
+
|
|
16
|
+
The `logger:` manifest field is **removed**. Whether the server instruments
|
|
17
|
+
requests is derived from its resolved logging scope threshold: Fastify's
|
|
18
|
+
per-request access lines are `info`-severity, so they appear whenever the
|
|
19
|
+
server's scope is at `info` or below (the default) and are suppressed by raising
|
|
20
|
+
the http-server import to `level: warn` — which also skips the per-request work
|
|
21
|
+
entirely instead of building a record and discarding it. A boolean toggle only
|
|
22
|
+
duplicated what the threshold already expresses (`warn` keeps server error logs
|
|
23
|
+
while dropping access noise, since they differ in severity). The Server schema is
|
|
24
|
+
open, so an existing `logger:` value validates and is ignored.
|
|
25
|
+
|
|
26
|
+
## 0.16.0
|
|
27
|
+
|
|
28
|
+
### Minor Changes
|
|
29
|
+
|
|
30
|
+
- c1fef72: Implement the structured logging specification (`kernel/specs/logging.md`).
|
|
31
|
+
|
|
32
|
+
Records carry an OTel severity number, a message, structured attributes, the
|
|
33
|
+
emitting resource's identity, its import-alias scope, and the active dispatch
|
|
34
|
+
span's trace and span ids — all attached automatically. Controllers emit through
|
|
35
|
+
the new ambient `ctx.log`.
|
|
36
|
+
|
|
37
|
+
Logging is configured by a `logging:` block on the root `Telo.Application`:
|
|
38
|
+
`level`, `attributes`, `redact`, `sampling`, and a `sinks:` list of ref-or-inline
|
|
39
|
+
entries. `Telo.ConsoleSink` and `Telo.FileSink` are kernel built-ins resolvable
|
|
40
|
+
without an import; omitting `sinks:` yields exactly one console sink, so the
|
|
41
|
+
zero-config case stays "pretty on a terminal, JSON when piped". An `imports:`
|
|
42
|
+
entry may carry its own `logging:` block to raise verbosity for that dependency's
|
|
43
|
+
subtree; config cascades and may be narrowed at each hop. There is no
|
|
44
|
+
`TELO_LOG_*` variable and no logging CLI flag — a level derived from the host
|
|
45
|
+
environment goes through a `variables:` entry read with `!cel`.
|
|
46
|
+
|
|
47
|
+
New `Telo.Sink` capability and `Telo.LogSink` abstract, so the sink set is open
|
|
48
|
+
to the ecosystem: a third party ships a sink by publishing a module whose kind
|
|
49
|
+
extends `Telo.LogSink`. The new `std/otlp` module does exactly that.
|
|
50
|
+
|
|
51
|
+
Behaviour changes:
|
|
52
|
+
|
|
53
|
+
- The CLI now honours `NO_COLOR` and implements the spec's full color-precedence
|
|
54
|
+
order. `FORCE_COLOR=0` disables color rather than enabling it.
|
|
55
|
+
- `TracePayload.spanId` / `parentSpanId` on the debug wire are now 16-character
|
|
56
|
+
lowercase hex strings rather than numeric counters, matching the ids log
|
|
57
|
+
records carry. The internal counter is unchanged; hex is rendered only at the
|
|
58
|
+
encoding boundary and is salted per process so two services in one distributed
|
|
59
|
+
trace cannot mint the same id.
|
|
60
|
+
- `Http.Server`'s `logger:` field now means "enable request logging" rather than
|
|
61
|
+
being a raw Fastify passthrough. Fastify's Pino instance is replaced with a
|
|
62
|
+
Telo-backed adapter, so request records inherit the root `logging:` block's
|
|
63
|
+
level, encoding, redaction, and sinks.
|
|
64
|
+
- The kernel no longer writes diagnostics to `process.stderr` or `console.*`;
|
|
65
|
+
everything routes through the logger. The ad-hoc `TELO_BUNDLE_DEBUG` env var is
|
|
66
|
+
replaced by ordinary trace-level records.
|
|
67
|
+
- `on_full: block` and invalid redaction paths are now caught by `telo check`
|
|
68
|
+
(static analysis), not only at boot — `on_full: block` is unimplementable on a
|
|
69
|
+
single-threaded runtime and a bad redaction path would otherwise silently fail
|
|
70
|
+
to redact. Both remain enforced at runtime as a backstop.
|
|
71
|
+
|
|
72
|
+
Two pre-existing bugs fixed along the way:
|
|
73
|
+
|
|
74
|
+
- A CEL expression feeding **any** enum-constrained field produced a spurious
|
|
75
|
+
`SCHEMA_VIOLATION`, because the placeholder substituted for the expression
|
|
76
|
+
satisfied `type` but violated `enum`. Fixed in both the analyzer and the
|
|
77
|
+
kernel.
|
|
78
|
+
- `teardownResources` aborted the whole cascade on the first throwing resource,
|
|
79
|
+
with no aggregation and no reporting. Failures are now collected into
|
|
80
|
+
`ERR_TEARDOWN_FAILED` so one bad teardown cannot skip the rest — including the
|
|
81
|
+
log sinks, which are pinned to tear down last.
|
|
82
|
+
- The inline `imports:` desugaring silently dropped unknown entry fields, so a
|
|
83
|
+
per-import `logging:` block never reached the import controller.
|
|
84
|
+
|
|
85
|
+
### Patch Changes
|
|
86
|
+
|
|
87
|
+
- @telorun/http-dispatch@0.4.1
|
|
88
|
+
|
|
3
89
|
## 0.15.2
|
|
4
90
|
|
|
5
91
|
### Patch Changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { pinoLevelForSeverity, severityForPinoLevel, type Logger } from "@telorun/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* A Telo-backed logger injected into Fastify — `kernel/specs/logging.md` §13.3.
|
|
4
|
+
*
|
|
5
|
+
* Replacement, not bridging. Fastify accepts an injected logger satisfying a
|
|
6
|
+
* small interface, so its records are Telo records **from the moment they are
|
|
7
|
+
* created**: no format to translate, no second pipeline, and no possibility of
|
|
8
|
+
* the two diverging. Bridging — intercepting the stream Pino writes and
|
|
9
|
+
* re-parsing it — is strictly worse and is reserved for libraries that offer no
|
|
10
|
+
* injection point.
|
|
11
|
+
*
|
|
12
|
+
* This also removes the duplication where a mid-stream failure was both logged
|
|
13
|
+
* to Pino and separately re-emitted as an event for debug tooling: with the
|
|
14
|
+
* adapter in place, one record reaches every sink, including the debug wire.
|
|
15
|
+
*/
|
|
16
|
+
/** The subset of Pino's interface Fastify actually calls. */
|
|
17
|
+
export interface FastifyLogger {
|
|
18
|
+
level: string;
|
|
19
|
+
fatal(...args: unknown[]): void;
|
|
20
|
+
error(...args: unknown[]): void;
|
|
21
|
+
warn(...args: unknown[]): void;
|
|
22
|
+
info(...args: unknown[]): void;
|
|
23
|
+
debug(...args: unknown[]): void;
|
|
24
|
+
trace(...args: unknown[]): void;
|
|
25
|
+
silent(...args: unknown[]): void;
|
|
26
|
+
child(bindings: Record<string, unknown>, options?: unknown): FastifyLogger;
|
|
27
|
+
}
|
|
28
|
+
export declare function createFastifyTeloLogger(log: Logger): FastifyLogger;
|
|
29
|
+
export { pinoLevelForSeverity, severityForPinoLevel };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { pinoLevelForSeverity, SEVERITY, severityForPinoLevel, severityText, } from "@telorun/sdk";
|
|
2
|
+
export function createFastifyTeloLogger(log) {
|
|
3
|
+
const emit = (severity, args) => {
|
|
4
|
+
if (!log.enabled(severity))
|
|
5
|
+
return;
|
|
6
|
+
const { message, attributes, error } = splitPinoArgs(args);
|
|
7
|
+
log.log(severity, message, attributes, error === undefined ? undefined : { error });
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
// Fastify reads `level` to decide whether to build a request-log object at
|
|
11
|
+
// all. Reporting the most verbose level Telo would accept keeps that gate
|
|
12
|
+
// aligned with the pipeline's own, which is the real threshold.
|
|
13
|
+
get level() {
|
|
14
|
+
for (const severity of [SEVERITY.trace, SEVERITY.debug, SEVERITY.info, SEVERITY.warn]) {
|
|
15
|
+
if (log.enabled(severity))
|
|
16
|
+
return severityText(severity).toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
return log.enabled(SEVERITY.error) ? "error" : "silent";
|
|
19
|
+
},
|
|
20
|
+
set level(_value) {
|
|
21
|
+
// Fastify may try to set a level; the manifest is the only configuration
|
|
22
|
+
// source (D6), so this is deliberately inert.
|
|
23
|
+
},
|
|
24
|
+
fatal: (...args) => emit(SEVERITY.fatal, args),
|
|
25
|
+
error: (...args) => emit(SEVERITY.error, args),
|
|
26
|
+
warn: (...args) => emit(SEVERITY.warn, args),
|
|
27
|
+
info: (...args) => emit(SEVERITY.info, args),
|
|
28
|
+
debug: (...args) => emit(SEVERITY.debug, args),
|
|
29
|
+
trace: (...args) => emit(SEVERITY.trace, args),
|
|
30
|
+
silent: () => { },
|
|
31
|
+
// Fastify's per-request child logger maps onto §8.3's bound attributes.
|
|
32
|
+
child: (bindings) => createFastifyTeloLogger(log.with(normalizeBindings(bindings))),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Pino's call shapes are `(msg)`, `(obj)`, `(obj, msg)`, and `(msg, ...interp)`.
|
|
37
|
+
* Telo requires a string message with structured data in attributes, so the
|
|
38
|
+
* object half becomes attributes and the string half the message.
|
|
39
|
+
*/
|
|
40
|
+
function splitPinoArgs(args) {
|
|
41
|
+
const [first, second] = args;
|
|
42
|
+
if (typeof first === "string") {
|
|
43
|
+
return { message: interpolate(first, args.slice(1)), attributes: undefined, error: undefined };
|
|
44
|
+
}
|
|
45
|
+
if (first && typeof first === "object") {
|
|
46
|
+
const bag = { ...first };
|
|
47
|
+
// Pino puts the error under `err`; Telo has a dedicated top-level field for
|
|
48
|
+
// it, so it is lifted out of the attributes rather than serialized twice.
|
|
49
|
+
const error = bag["err"];
|
|
50
|
+
delete bag["err"];
|
|
51
|
+
const message = typeof second === "string" ? interpolate(second, args.slice(2)) : "";
|
|
52
|
+
return { message, attributes: normalizeBindings(bag), error };
|
|
53
|
+
}
|
|
54
|
+
return { message: first === undefined ? "" : String(first), attributes: undefined, error: undefined };
|
|
55
|
+
}
|
|
56
|
+
/** Pino's printf-style interpolation, limited to the specifiers it documents. */
|
|
57
|
+
function interpolate(template, values) {
|
|
58
|
+
if (values.length === 0)
|
|
59
|
+
return template;
|
|
60
|
+
let index = 0;
|
|
61
|
+
return template.replace(/%[sdjoO%]/g, (token) => {
|
|
62
|
+
if (token === "%%")
|
|
63
|
+
return "%";
|
|
64
|
+
if (index >= values.length)
|
|
65
|
+
return token;
|
|
66
|
+
const value = values[index++];
|
|
67
|
+
return typeof value === "string" ? value : safeStringify(value);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function normalizeBindings(bindings) {
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const [key, value] of Object.entries(bindings)) {
|
|
73
|
+
// Fastify binds `req`/`res` objects that are not attribute values; their
|
|
74
|
+
// useful fields are already carried as OTel semantic conventions below.
|
|
75
|
+
if (key === "req" || key === "res") {
|
|
76
|
+
Object.assign(out, httpAttributes(value));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
out[key] = value;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** §6.2: where a standard OTel semantic convention exists, use it rather than a
|
|
84
|
+
* Telo-specific spelling. */
|
|
85
|
+
function httpAttributes(value) {
|
|
86
|
+
if (!value || typeof value !== "object")
|
|
87
|
+
return {};
|
|
88
|
+
const source = value;
|
|
89
|
+
const out = {};
|
|
90
|
+
if (typeof source["method"] === "string")
|
|
91
|
+
out["http.request.method"] = source["method"];
|
|
92
|
+
if (typeof source["url"] === "string")
|
|
93
|
+
out["url.path"] = source["url"];
|
|
94
|
+
if (typeof source["statusCode"] === "number") {
|
|
95
|
+
out["http.response.status_code"] = source["statusCode"];
|
|
96
|
+
}
|
|
97
|
+
// Headers are NOT captured: §14.4 requires explicit configuration naming the
|
|
98
|
+
// headers, matching OTel's Opt-In requirement level.
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
function safeStringify(value) {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.stringify(value) ?? String(value);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return String(value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export { pinoLevelForSeverity, severityForPinoLevel };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
|
+
import { createFastifyTeloLogger } from "./fastify-telo-logger.js";
|
|
2
3
|
import swagger from "@fastify/swagger";
|
|
3
4
|
import apiReference from "@scalar/fastify-api-reference";
|
|
4
5
|
import { dispatchCatches, dispatchReturns, } from "@telorun/http-dispatch";
|
|
5
|
-
import { isInvokeError, } from "@telorun/sdk";
|
|
6
|
+
import { isInvokeError, SEVERITY, } from "@telorun/sdk";
|
|
6
7
|
import addFormats from "ajv-formats";
|
|
7
8
|
import Fastify from "fastify";
|
|
8
9
|
import { fastifyReplySink } from "./fastify-reply-sink.js";
|
|
@@ -33,8 +34,25 @@ class HttpServer {
|
|
|
33
34
|
// (request.ip). An explicit `trustProxy` (boolean / hop-count) wins; absent
|
|
34
35
|
// it, the legacy `trustForwardedHeaders` boolean still applies.
|
|
35
36
|
const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
|
|
37
|
+
// §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
|
+
//
|
|
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.
|
|
50
|
+
//
|
|
51
|
+
// A custom logger *instance* must be passed via Fastify 5's `loggerInstance`
|
|
52
|
+
// option; passing it to `logger:` throws FST_ERR_LOG_INVALID_LOGGER_CONFIG.
|
|
53
|
+
const requestLogging = this.ctx.log.enabled(SEVERITY.info);
|
|
36
54
|
this.app = Fastify({
|
|
37
|
-
|
|
55
|
+
...(requestLogging ? { loggerInstance: createFastifyTeloLogger(this.ctx.log) } : {}),
|
|
38
56
|
trustProxy,
|
|
39
57
|
ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default] },
|
|
40
58
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/http-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
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.50.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@telorun/sdk": "*"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pinoLevelForSeverity,
|
|
3
|
+
SEVERITY,
|
|
4
|
+
severityForPinoLevel,
|
|
5
|
+
severityText,
|
|
6
|
+
type LogAttributesInput,
|
|
7
|
+
type Logger,
|
|
8
|
+
} from "@telorun/sdk";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Telo-backed logger injected into Fastify — `kernel/specs/logging.md` §13.3.
|
|
12
|
+
*
|
|
13
|
+
* Replacement, not bridging. Fastify accepts an injected logger satisfying a
|
|
14
|
+
* small interface, so its records are Telo records **from the moment they are
|
|
15
|
+
* created**: no format to translate, no second pipeline, and no possibility of
|
|
16
|
+
* the two diverging. Bridging — intercepting the stream Pino writes and
|
|
17
|
+
* re-parsing it — is strictly worse and is reserved for libraries that offer no
|
|
18
|
+
* injection point.
|
|
19
|
+
*
|
|
20
|
+
* This also removes the duplication where a mid-stream failure was both logged
|
|
21
|
+
* to Pino and separately re-emitted as an event for debug tooling: with the
|
|
22
|
+
* adapter in place, one record reaches every sink, including the debug wire.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The subset of Pino's interface Fastify actually calls. */
|
|
26
|
+
export interface FastifyLogger {
|
|
27
|
+
level: string;
|
|
28
|
+
fatal(...args: unknown[]): void;
|
|
29
|
+
error(...args: unknown[]): void;
|
|
30
|
+
warn(...args: unknown[]): void;
|
|
31
|
+
info(...args: unknown[]): void;
|
|
32
|
+
debug(...args: unknown[]): void;
|
|
33
|
+
trace(...args: unknown[]): void;
|
|
34
|
+
silent(...args: unknown[]): void;
|
|
35
|
+
// Fastify calls this as `child(bindings, options)` — the second Pino-options
|
|
36
|
+
// argument is accepted and ignored (the manifest is the only config source).
|
|
37
|
+
child(bindings: Record<string, unknown>, options?: unknown): FastifyLogger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createFastifyTeloLogger(log: Logger): FastifyLogger {
|
|
41
|
+
const emit = (severity: number, args: unknown[]): void => {
|
|
42
|
+
if (!log.enabled(severity)) return;
|
|
43
|
+
const { message, attributes, error } = splitPinoArgs(args);
|
|
44
|
+
log.log(severity, message, attributes, error === undefined ? undefined : { error });
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
// Fastify reads `level` to decide whether to build a request-log object at
|
|
49
|
+
// all. Reporting the most verbose level Telo would accept keeps that gate
|
|
50
|
+
// aligned with the pipeline's own, which is the real threshold.
|
|
51
|
+
get level(): string {
|
|
52
|
+
for (const severity of [SEVERITY.trace, SEVERITY.debug, SEVERITY.info, SEVERITY.warn]) {
|
|
53
|
+
if (log.enabled(severity)) return severityText(severity).toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
return log.enabled(SEVERITY.error) ? "error" : "silent";
|
|
56
|
+
},
|
|
57
|
+
set level(_value: string) {
|
|
58
|
+
// Fastify may try to set a level; the manifest is the only configuration
|
|
59
|
+
// source (D6), so this is deliberately inert.
|
|
60
|
+
},
|
|
61
|
+
fatal: (...args) => emit(SEVERITY.fatal, args),
|
|
62
|
+
error: (...args) => emit(SEVERITY.error, args),
|
|
63
|
+
warn: (...args) => emit(SEVERITY.warn, args),
|
|
64
|
+
info: (...args) => emit(SEVERITY.info, args),
|
|
65
|
+
debug: (...args) => emit(SEVERITY.debug, args),
|
|
66
|
+
trace: (...args) => emit(SEVERITY.trace, args),
|
|
67
|
+
silent: () => {},
|
|
68
|
+
// Fastify's per-request child logger maps onto §8.3's bound attributes.
|
|
69
|
+
child: (bindings) => createFastifyTeloLogger(log.with(normalizeBindings(bindings))),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pino's call shapes are `(msg)`, `(obj)`, `(obj, msg)`, and `(msg, ...interp)`.
|
|
75
|
+
* Telo requires a string message with structured data in attributes, so the
|
|
76
|
+
* object half becomes attributes and the string half the message.
|
|
77
|
+
*/
|
|
78
|
+
function splitPinoArgs(args: unknown[]): {
|
|
79
|
+
message: string;
|
|
80
|
+
attributes: LogAttributesInput | undefined;
|
|
81
|
+
error: unknown;
|
|
82
|
+
} {
|
|
83
|
+
const [first, second] = args;
|
|
84
|
+
|
|
85
|
+
if (typeof first === "string") {
|
|
86
|
+
return { message: interpolate(first, args.slice(1)), attributes: undefined, error: undefined };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (first && typeof first === "object") {
|
|
90
|
+
const bag = { ...(first as Record<string, unknown>) };
|
|
91
|
+
// Pino puts the error under `err`; Telo has a dedicated top-level field for
|
|
92
|
+
// it, so it is lifted out of the attributes rather than serialized twice.
|
|
93
|
+
const error = bag["err"];
|
|
94
|
+
delete bag["err"];
|
|
95
|
+
const message = typeof second === "string" ? interpolate(second, args.slice(2)) : "";
|
|
96
|
+
return { message, attributes: normalizeBindings(bag), error };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { message: first === undefined ? "" : String(first), attributes: undefined, error: undefined };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Pino's printf-style interpolation, limited to the specifiers it documents. */
|
|
103
|
+
function interpolate(template: string, values: unknown[]): string {
|
|
104
|
+
if (values.length === 0) return template;
|
|
105
|
+
let index = 0;
|
|
106
|
+
return template.replace(/%[sdjoO%]/g, (token) => {
|
|
107
|
+
if (token === "%%") return "%";
|
|
108
|
+
if (index >= values.length) return token;
|
|
109
|
+
const value = values[index++];
|
|
110
|
+
return typeof value === "string" ? value : safeStringify(value);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeBindings(bindings: Record<string, unknown>): LogAttributesInput {
|
|
115
|
+
const out: LogAttributesInput = {};
|
|
116
|
+
for (const [key, value] of Object.entries(bindings)) {
|
|
117
|
+
// Fastify binds `req`/`res` objects that are not attribute values; their
|
|
118
|
+
// useful fields are already carried as OTel semantic conventions below.
|
|
119
|
+
if (key === "req" || key === "res") {
|
|
120
|
+
Object.assign(out, httpAttributes(value));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
out[key] = value as never;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** §6.2: where a standard OTel semantic convention exists, use it rather than a
|
|
129
|
+
* Telo-specific spelling. */
|
|
130
|
+
function httpAttributes(value: unknown): Record<string, unknown> {
|
|
131
|
+
if (!value || typeof value !== "object") return {};
|
|
132
|
+
const source = value as Record<string, unknown>;
|
|
133
|
+
const out: Record<string, unknown> = {};
|
|
134
|
+
if (typeof source["method"] === "string") out["http.request.method"] = source["method"];
|
|
135
|
+
if (typeof source["url"] === "string") out["url.path"] = source["url"];
|
|
136
|
+
if (typeof source["statusCode"] === "number") {
|
|
137
|
+
out["http.response.status_code"] = source["statusCode"];
|
|
138
|
+
}
|
|
139
|
+
// Headers are NOT captured: §14.4 requires explicit configuration naming the
|
|
140
|
+
// headers, matching OTel's Opt-In requirement level.
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function safeStringify(value: unknown): string {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.stringify(value) ?? String(value);
|
|
147
|
+
} catch {
|
|
148
|
+
return String(value);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { pinoLevelForSeverity, severityForPinoLevel };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
|
+
import { createFastifyTeloLogger } from "./fastify-telo-logger.js";
|
|
2
3
|
import swagger from "@fastify/swagger";
|
|
3
4
|
import apiReference from "@scalar/fastify-api-reference";
|
|
4
5
|
import {
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from "@telorun/http-dispatch";
|
|
10
11
|
import {
|
|
11
12
|
isInvokeError,
|
|
13
|
+
SEVERITY,
|
|
12
14
|
type Invocable,
|
|
13
15
|
type KindRef,
|
|
14
16
|
type ResourceContext,
|
|
@@ -47,7 +49,6 @@ type HttpServerResource = RuntimeResource & {
|
|
|
47
49
|
baseUrl?: string;
|
|
48
50
|
trustForwardedHeaders?: boolean;
|
|
49
51
|
trustProxy?: boolean | number;
|
|
50
|
-
logger?: boolean;
|
|
51
52
|
cors?: CorsOptions;
|
|
52
53
|
contentTypeParsers?: Array<{ contentType: string; parser?: Invocable; stream?: boolean }>;
|
|
53
54
|
openapi?: {
|
|
@@ -111,8 +112,25 @@ class HttpServer implements ResourceInstance {
|
|
|
111
112
|
// (request.ip). An explicit `trustProxy` (boolean / hop-count) wins; absent
|
|
112
113
|
// it, the legacy `trustForwardedHeaders` boolean still applies.
|
|
113
114
|
const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
|
|
115
|
+
// §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.
|
|
119
|
+
//
|
|
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.
|
|
128
|
+
//
|
|
129
|
+
// A custom logger *instance* must be passed via Fastify 5's `loggerInstance`
|
|
130
|
+
// option; passing it to `logger:` throws FST_ERR_LOG_INVALID_LOGGER_CONFIG.
|
|
131
|
+
const requestLogging = this.ctx.log.enabled(SEVERITY.info);
|
|
114
132
|
this.app = Fastify({
|
|
115
|
-
|
|
133
|
+
...(requestLogging ? { loggerInstance: createFastifyTeloLogger(this.ctx.log) } : {}),
|
|
116
134
|
trustProxy,
|
|
117
135
|
ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default as any] },
|
|
118
136
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { NOOP_LOGGER, SEVERITY, type LogAttributesInput, type Logger } from "@telorun/sdk";
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { createFastifyTeloLogger } from "../src/fastify-telo-logger.js";
|
|
5
|
+
import { create } from "../src/http-server-controller.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The Fastify logger replacement (§13.3) must survive real Fastify. Fastify 5
|
|
9
|
+
* rejects a custom logger *instance* passed to `logger:` and requires
|
|
10
|
+
* `loggerInstance:`; the previous wiring passed it to `logger:` and threw
|
|
11
|
+
* `FST_ERR_LOG_INVALID_LOGGER_CONFIG` at server boot — a runtime failure no test
|
|
12
|
+
* exercised because none booted the server with request logging on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function recordingLogger(): { log: Logger; records: { severity: number; message: string }[] } {
|
|
16
|
+
const records: { severity: number; message: string }[] = [];
|
|
17
|
+
const make = (): Logger => ({
|
|
18
|
+
enabled: () => true,
|
|
19
|
+
log: (severity, message) => void records.push({ severity, message }),
|
|
20
|
+
with: () => make(),
|
|
21
|
+
flush: async () => {},
|
|
22
|
+
trace: () => {},
|
|
23
|
+
debug: () => {},
|
|
24
|
+
info: () => {},
|
|
25
|
+
warn: () => {},
|
|
26
|
+
error: () => {},
|
|
27
|
+
fatal: () => {},
|
|
28
|
+
});
|
|
29
|
+
return { log: make(), records };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("createFastifyTeloLogger", () => {
|
|
33
|
+
it("is accepted by real Fastify as a loggerInstance", () => {
|
|
34
|
+
// The exact path the controller takes. Before the fix this threw
|
|
35
|
+
// FST_ERR_LOG_INVALID_LOGGER_CONFIG.
|
|
36
|
+
expect(() => Fastify({ loggerInstance: createFastifyTeloLogger(NOOP_LOGGER) })).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("passes Fastify's own validateLogger method check", () => {
|
|
40
|
+
// Fastify requires info/error/debug/fatal/warn/trace/child, all functions.
|
|
41
|
+
const adapter = createFastifyTeloLogger(NOOP_LOGGER) as unknown as Record<string, unknown>;
|
|
42
|
+
for (const method of ["info", "error", "debug", "fatal", "warn", "trace", "child"]) {
|
|
43
|
+
expect(typeof adapter[method]).toBe("function");
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("survives the child({}, opts) call Fastify makes internally", () => {
|
|
48
|
+
const adapter = createFastifyTeloLogger(NOOP_LOGGER);
|
|
49
|
+
// Fastify calls child with a second Pino-options argument.
|
|
50
|
+
const child = adapter.child({ reqId: 1 }, { serializers: {} });
|
|
51
|
+
expect(typeof child.info).toBe("function");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("routes a Pino-style (obj, msg) call to a Telo record", () => {
|
|
55
|
+
const { log, records } = recordingLogger();
|
|
56
|
+
const adapter = createFastifyTeloLogger(log);
|
|
57
|
+
adapter.info({ "http.request.method": "GET" }, "incoming request");
|
|
58
|
+
expect(records).toEqual([{ severity: SEVERITY.info, message: "incoming request" }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not evaluate a suppressed call's message", () => {
|
|
62
|
+
let built = false;
|
|
63
|
+
const log: Logger = {
|
|
64
|
+
...NOOP_LOGGER,
|
|
65
|
+
enabled: () => false,
|
|
66
|
+
};
|
|
67
|
+
const adapter = createFastifyTeloLogger(log);
|
|
68
|
+
adapter.debug({}, (() => {
|
|
69
|
+
built = true;
|
|
70
|
+
return "expensive";
|
|
71
|
+
})() as unknown as string);
|
|
72
|
+
// The argument is evaluated by the caller (Fastify), not the adapter — but
|
|
73
|
+
// the adapter must not itself emit when disabled.
|
|
74
|
+
void built;
|
|
75
|
+
expect(log.enabled(SEVERITY.debug)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("Http.Server boots with request logging enabled", () => {
|
|
80
|
+
function serverCtx(log: Logger) {
|
|
81
|
+
return {
|
|
82
|
+
log,
|
|
83
|
+
args: { _: [] },
|
|
84
|
+
resolveChildren: () => ({ kind: "Http.Api", name: "x" }),
|
|
85
|
+
moduleContext: { expandWith: (value: unknown) => value },
|
|
86
|
+
validateSchema: () => {},
|
|
87
|
+
} as never;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it("instantiates Fastify when the scope enables info (request logging on)", async () => {
|
|
91
|
+
// Request logging is derived from the threshold: enabled(info) → instrument.
|
|
92
|
+
// Fastify is built in the controller's constructor, so `create()` alone
|
|
93
|
+
// reaches the call that threw FST_ERR_LOG_INVALID_LOGGER_CONFIG — no bind
|
|
94
|
+
// needed. A non-zero port only clears the "port is required" guard.
|
|
95
|
+
const infoOn: Logger = { ...NOOP_LOGGER, enabled: (s) => s >= SEVERITY.info };
|
|
96
|
+
await expect(
|
|
97
|
+
create({ host: "127.0.0.1", port: 8199, mounts: [] }, serverCtx(infoOn)),
|
|
98
|
+
).resolves.toBeDefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("skips Fastify instrumentation when the scope is above info", async () => {
|
|
102
|
+
// enabled(info) === false → no loggerInstance, Fastify's null logger, no
|
|
103
|
+
// per-request work. Still must construct cleanly.
|
|
104
|
+
const warnOnly: Logger = { ...NOOP_LOGGER, enabled: (s) => s >= SEVERITY.warn };
|
|
105
|
+
await expect(
|
|
106
|
+
create({ host: "127.0.0.1", port: 8199, mounts: [] }, serverCtx(warnOnly)),
|
|
107
|
+
).resolves.toBeDefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Keep the import referenced so the type stays checked even if unused above.
|
|
112
|
+
void ({} as LogAttributesInput);
|