§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:
@@ -8,6 +8,7 @@ import { after, before, test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp, type AppOptions } from "./app.ts";
|
||||
import { readFormBody } from "./body.ts";
|
||||
import { createLogger } from "./logger.ts";
|
||||
import { createDenylist } from "./denylist.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||
@@ -68,6 +69,31 @@ test("renders branding from the menu config into the shell: logo + default theme
|
||||
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
|
||||
});
|
||||
|
||||
test("emits a structured access-log line per request (the injected §9 logger)", async (t) => {
|
||||
const lines: string[] = [];
|
||||
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`);
|
||||
assert.equal(res.status, 200);
|
||||
await res.text(); // consume the body so the connection closes (the access line emits on close)
|
||||
|
||||
// The line is emitted on connection close (after the body is sent) — poll briefly for it.
|
||||
let line: string | undefined;
|
||||
for (let i = 0; i < 50 && !line; i++) {
|
||||
line = lines.find((l) => l.includes('"msg":"request"'));
|
||||
if (!line) await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
assert.ok(line, "an access line is logged for the request");
|
||||
const rec = JSON.parse(line!);
|
||||
assert.equal(rec.method, "GET");
|
||||
assert.equal(rec.path, "/"); // pathname only — the ?q=… query is dropped (may carry tokens)
|
||||
assert.equal(rec.status, 200);
|
||||
assert.equal(rec["service.name"], "plainpages");
|
||||
assert.equal(typeof rec.ms, "number");
|
||||
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
|
||||
});
|
||||
|
||||
test("static serving: GET sends body + content-type, HEAD headers only, unsafe paths → 403", async () => {
|
||||
const get = await fetch(base + "/public/css/styles.css");
|
||||
assert.equal(get.status, 200);
|
||||
|
||||
Reference in New Issue
Block a user