§9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, inheriting level/format/streams/OTLP) into a "request" span, adopting an inbound W3C traceparent so a request continues an upstream proxy's distributed trace (malformed ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). Config (environment-agnostic, fail-loud): LOG_LEVEL (info), LOG_FORMAT (text; prod compose → json), OTLP_ENDPOINT (unset ⇒ console-only; set ⇒ export logs + spans to an OTel Collector), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326).

This commit is contained in:
2026-06-20 02:11:10 +02:00
parent a8a018f3e5
commit a9e3dedbb4
17 changed files with 325 additions and 20 deletions
+16
View File
@@ -23,6 +23,22 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
assert.equal(c.revocationDenylist, false); // instant-revoke is opt-in (§9)
assert.equal(c.revocationTtlSec, 900); // ≥ tokenizer TTL (10m) + skew
assert.equal(c.logLevel, "info"); // §9 observability defaults
assert.equal(c.logFormat, "text"); // human-readable in dev; prod compose sets json
assert.equal(c.otlpEndpoint, undefined); // OTLP export opt-in; console-only by default
assert.equal(c.otlpProtocol, "http/json");
});
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL (§9)", () => {
assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug");
assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none");
assert.throws(() => loadConfig({ LOG_LEVEL: "trace" }), /LOG_LEVEL/);
assert.equal(loadConfig({ LOG_FORMAT: "json" }).logFormat, "json");
assert.throws(() => loadConfig({ LOG_FORMAT: "yaml" }), /LOG_FORMAT/);
assert.equal(loadConfig({ OTLP_PROTOCOL: "http/protobuf" }).otlpProtocol, "http/protobuf");
assert.throws(() => loadConfig({ OTLP_PROTOCOL: "grpc" }), /OTLP_PROTOCOL/);
assert.equal(loadConfig({ OTLP_ENDPOINT: "http://collector:4318" }).otlpEndpoint, "http://collector:4318");
assert.throws(() => loadConfig({ OTLP_ENDPOINT: "not a url" }), /OTLP_ENDPOINT/);
});
test("REVOCATION_DENYLIST: opt-in toggle (off by default) + REVOCATION_TTL_SEC must be a positive integer", () => {