@telorun/http-server 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # @telorun/http-server
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c1fef72: Implement the structured logging specification (`kernel/specs/logging.md`).
8
+
9
+ Records carry an OTel severity number, a message, structured attributes, the
10
+ emitting resource's identity, its import-alias scope, and the active dispatch
11
+ span's trace and span ids — all attached automatically. Controllers emit through
12
+ the new ambient `ctx.log`.
13
+
14
+ Logging is configured by a `logging:` block on the root `Telo.Application`:
15
+ `level`, `attributes`, `redact`, `sampling`, and a `sinks:` list of ref-or-inline
16
+ entries. `Telo.ConsoleSink` and `Telo.FileSink` are kernel built-ins resolvable
17
+ without an import; omitting `sinks:` yields exactly one console sink, so the
18
+ zero-config case stays "pretty on a terminal, JSON when piped". An `imports:`
19
+ entry may carry its own `logging:` block to raise verbosity for that dependency's
20
+ subtree; config cascades and may be narrowed at each hop. There is no
21
+ `TELO_LOG_*` variable and no logging CLI flag — a level derived from the host
22
+ environment goes through a `variables:` entry read with `!cel`.
23
+
24
+ New `Telo.Sink` capability and `Telo.LogSink` abstract, so the sink set is open
25
+ to the ecosystem: a third party ships a sink by publishing a module whose kind
26
+ extends `Telo.LogSink`. The new `std/otlp` module does exactly that.
27
+
28
+ Behaviour changes:
29
+
30
+ - The CLI now honours `NO_COLOR` and implements the spec's full color-precedence
31
+ order. `FORCE_COLOR=0` disables color rather than enabling it.
32
+ - `TracePayload.spanId` / `parentSpanId` on the debug wire are now 16-character
33
+ lowercase hex strings rather than numeric counters, matching the ids log
34
+ records carry. The internal counter is unchanged; hex is rendered only at the
35
+ encoding boundary and is salted per process so two services in one distributed
36
+ trace cannot mint the same id.
37
+ - `Http.Server`'s `logger:` field now means "enable request logging" rather than
38
+ being a raw Fastify passthrough. Fastify's Pino instance is replaced with a
39
+ Telo-backed adapter, so request records inherit the root `logging:` block's
40
+ level, encoding, redaction, and sinks.
41
+ - The kernel no longer writes diagnostics to `process.stderr` or `console.*`;
42
+ everything routes through the logger. The ad-hoc `TELO_BUNDLE_DEBUG` env var is
43
+ replaced by ordinary trace-level records.
44
+ - `on_full: block` and invalid redaction paths are now caught by `telo check`
45
+ (static analysis), not only at boot — `on_full: block` is unimplementable on a
46
+ single-threaded runtime and a bad redaction path would otherwise silently fail
47
+ to redact. Both remain enforced at runtime as a backstop.
48
+
49
+ Two pre-existing bugs fixed along the way:
50
+
51
+ - A CEL expression feeding **any** enum-constrained field produced a spurious
52
+ `SCHEMA_VIOLATION`, because the placeholder substituted for the expression
53
+ satisfied `type` but violated `enum`. Fixed in both the analyzer and the
54
+ kernel.
55
+ - `teardownResources` aborted the whole cascade on the first throwing resource,
56
+ with no aggregation and no reporting. Failures are now collected into
57
+ `ERR_TEARDOWN_FAILED` so one bad teardown cannot skip the rest — including the
58
+ log sinks, which are pinned to tear down last.
59
+ - The inline `imports:` desugaring silently dropped unknown entry fields, so a
60
+ per-import `logging:` block never reached the import controller.
61
+
62
+ ### Patch Changes
63
+
64
+ - @telorun/http-dispatch@0.4.1
65
+
3
66
  ## 0.15.2
4
67
 
5
68
  ### 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>): 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,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 { dispatchCatches, dispatchReturns, } from "@telorun/http-dispatch";
@@ -34,7 +35,12 @@ class HttpServer {
34
35
  // it, the legacy `trustForwardedHeaders` boolean still applies.
35
36
  const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
36
37
  this.app = Fastify({
37
- logger: resource.logger,
38
+ // §13.3: replacement, not bridging. Fastify's Pino instance is swapped for
39
+ // a Telo-backed adapter, so request records are Telo records at the source
40
+ // and inherit the root `logging:` block's level, encoding, redaction, and
41
+ // sinks. `logger:` now means "enable request logging" rather than being a
42
+ // raw Fastify passthrough.
43
+ logger: resource.logger ? createFastifyTeloLogger(this.ctx.log) : false,
38
44
  trustProxy,
39
45
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default] },
40
46
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/http-server",
3
- "version": "0.15.2",
3
+ "version": "0.16.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.44.0"
58
+ "@telorun/sdk": "0.50.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@telorun/sdk": "*"
@@ -0,0 +1,150 @@
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
+ child(bindings: Record<string, unknown>): FastifyLogger;
36
+ }
37
+
38
+ export function createFastifyTeloLogger(log: Logger): FastifyLogger {
39
+ const emit = (severity: number, args: unknown[]): void => {
40
+ if (!log.enabled(severity)) return;
41
+ const { message, attributes, error } = splitPinoArgs(args);
42
+ log.log(severity, message, attributes, error === undefined ? undefined : { error });
43
+ };
44
+
45
+ return {
46
+ // Fastify reads `level` to decide whether to build a request-log object at
47
+ // all. Reporting the most verbose level Telo would accept keeps that gate
48
+ // aligned with the pipeline's own, which is the real threshold.
49
+ get level(): string {
50
+ for (const severity of [SEVERITY.trace, SEVERITY.debug, SEVERITY.info, SEVERITY.warn]) {
51
+ if (log.enabled(severity)) return severityText(severity).toLowerCase();
52
+ }
53
+ return log.enabled(SEVERITY.error) ? "error" : "silent";
54
+ },
55
+ set level(_value: string) {
56
+ // Fastify may try to set a level; the manifest is the only configuration
57
+ // source (D6), so this is deliberately inert.
58
+ },
59
+ fatal: (...args) => emit(SEVERITY.fatal, args),
60
+ error: (...args) => emit(SEVERITY.error, args),
61
+ warn: (...args) => emit(SEVERITY.warn, args),
62
+ info: (...args) => emit(SEVERITY.info, args),
63
+ debug: (...args) => emit(SEVERITY.debug, args),
64
+ trace: (...args) => emit(SEVERITY.trace, args),
65
+ silent: () => {},
66
+ // Fastify's per-request child logger maps onto §8.3's bound attributes.
67
+ child: (bindings) => createFastifyTeloLogger(log.with(normalizeBindings(bindings))),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Pino's call shapes are `(msg)`, `(obj)`, `(obj, msg)`, and `(msg, ...interp)`.
73
+ * Telo requires a string message with structured data in attributes, so the
74
+ * object half becomes attributes and the string half the message.
75
+ */
76
+ function splitPinoArgs(args: unknown[]): {
77
+ message: string;
78
+ attributes: LogAttributesInput | undefined;
79
+ error: unknown;
80
+ } {
81
+ const [first, second] = args;
82
+
83
+ if (typeof first === "string") {
84
+ return { message: interpolate(first, args.slice(1)), attributes: undefined, error: undefined };
85
+ }
86
+
87
+ if (first && typeof first === "object") {
88
+ const bag = { ...(first as Record<string, unknown>) };
89
+ // Pino puts the error under `err`; Telo has a dedicated top-level field for
90
+ // it, so it is lifted out of the attributes rather than serialized twice.
91
+ const error = bag["err"];
92
+ delete bag["err"];
93
+ const message = typeof second === "string" ? interpolate(second, args.slice(2)) : "";
94
+ return { message, attributes: normalizeBindings(bag), error };
95
+ }
96
+
97
+ return { message: first === undefined ? "" : String(first), attributes: undefined, error: undefined };
98
+ }
99
+
100
+ /** Pino's printf-style interpolation, limited to the specifiers it documents. */
101
+ function interpolate(template: string, values: unknown[]): string {
102
+ if (values.length === 0) return template;
103
+ let index = 0;
104
+ return template.replace(/%[sdjoO%]/g, (token) => {
105
+ if (token === "%%") return "%";
106
+ if (index >= values.length) return token;
107
+ const value = values[index++];
108
+ return typeof value === "string" ? value : safeStringify(value);
109
+ });
110
+ }
111
+
112
+ function normalizeBindings(bindings: Record<string, unknown>): LogAttributesInput {
113
+ const out: LogAttributesInput = {};
114
+ for (const [key, value] of Object.entries(bindings)) {
115
+ // Fastify binds `req`/`res` objects that are not attribute values; their
116
+ // useful fields are already carried as OTel semantic conventions below.
117
+ if (key === "req" || key === "res") {
118
+ Object.assign(out, httpAttributes(value));
119
+ continue;
120
+ }
121
+ out[key] = value as never;
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** §6.2: where a standard OTel semantic convention exists, use it rather than a
127
+ * Telo-specific spelling. */
128
+ function httpAttributes(value: unknown): Record<string, unknown> {
129
+ if (!value || typeof value !== "object") return {};
130
+ const source = value as Record<string, unknown>;
131
+ const out: Record<string, unknown> = {};
132
+ if (typeof source["method"] === "string") out["http.request.method"] = source["method"];
133
+ if (typeof source["url"] === "string") out["url.path"] = source["url"];
134
+ if (typeof source["statusCode"] === "number") {
135
+ out["http.response.status_code"] = source["statusCode"];
136
+ }
137
+ // Headers are NOT captured: §14.4 requires explicit configuration naming the
138
+ // headers, matching OTel's Opt-In requirement level.
139
+ return out;
140
+ }
141
+
142
+ function safeStringify(value: unknown): string {
143
+ try {
144
+ return JSON.stringify(value) ?? String(value);
145
+ } catch {
146
+ return String(value);
147
+ }
148
+ }
149
+
150
+ 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 {
@@ -112,7 +113,12 @@ class HttpServer implements ResourceInstance {
112
113
  // it, the legacy `trustForwardedHeaders` boolean still applies.
113
114
  const trustProxy = resource.trustProxy ?? this.trustForwardedHeaders;
114
115
  this.app = Fastify({
115
- logger: resource.logger,
116
+ // §13.3: replacement, not bridging. Fastify's Pino instance is swapped for
117
+ // a Telo-backed adapter, so request records are Telo records at the source
118
+ // and inherit the root `logging:` block's level, encoding, redaction, and
119
+ // sinks. `logger:` now means "enable request logging" rather than being a
120
+ // raw Fastify passthrough.
121
+ logger: resource.logger ? createFastifyTeloLogger(this.ctx.log) : false,
116
122
  trustProxy,
117
123
  ajv: { customOptions: { useDefaults: true }, plugins: [addFormats.default as any] },
118
124
  });