§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

View File

@@ -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);

View File

@@ -1,4 +1,4 @@
import { randomBytes } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { createServer, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -24,6 +24,7 @@ import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { createLogger, type Log, requestLogger } from "./logger.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
import { resolveLoginChallenge } from "./oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
@@ -48,6 +49,7 @@ export interface AppOptions {
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
log?: Log; // app-level logger (§9); per-request access log + trace span. Default: silent (tests)
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
@@ -71,6 +73,8 @@ export function createApp(options: AppOptions = {}): Server {
const keto = options.keto;
const kratos = options.kratos;
const kratosAdmin = options.kratosAdmin;
// Silent default so unit/integration tests stay quiet; server.ts injects the configured logger.
const log = options.log ?? createLogger({ level: "none" });
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
@@ -107,6 +111,23 @@ export function createApp(options: AppOptions = {}): Server {
};
return createServer(async (req, res) => {
// Per-request log + trace span (§9): a "request" span, continuing an upstream W3C traceparent
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
// completed response and a premature disconnect/abort, so an aborted or truncated request is
// still logged and its span flushed; it fires once. Logging must never crash a served request,
// so the access line is guarded too — then end() exports the span (a no-op when OTLP is off).
const startMs = Date.now();
const reqLog = requestLogger(log, {
requestId: randomUUID(),
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
});
res.on("close", () => {
try {
// path only (no query — it may carry tokens); method/status are header-safe here.
reqLog.info("request", { method: req.method ?? "GET", ms: Date.now() - startMs, path: (req.url ?? "/").split("?", 1)[0] ?? "/", status: res.statusCode });
} catch { /* never let logging crash a served request */ }
void reqLog.end().catch(() => {}); // never let a flaky OTLP collector crash a served request
});
try {
const method = req.method ?? "GET";
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
@@ -119,7 +140,7 @@ export function createApp(options: AppOptions = {}): Server {
// /public/<id>/… serves a plugin's public/; everything else the core public/.
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
await serveStatic(dir, subPath, res, method === "HEAD");
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
return;
}
@@ -140,7 +161,7 @@ export function createApp(options: AppOptions = {}): Server {
} catch (err) {
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers.
console.error("session re-mint failed (Ory unreachable?):", err);
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
}
}
}
@@ -415,14 +436,14 @@ export function createApp(options: AppOptions = {}): Server {
if (err.location) return void res.writeHead(303, { location: err.location }).end();
return void sendHtml(res, err.status, await render("403", { title: "Forbidden" }));
}
console.error(err);
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
try {
// Render before writing: if the 500 page itself throws, headers stay unsent
// and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await render("500", { title: "Server error" }));
} catch (renderErr) {
console.error(renderErr);
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
}
}

View File

@@ -10,6 +10,7 @@ import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { discoverPlugins } from "./discovery.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger } from "./logger.ts";
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -116,7 +117,7 @@ async function findIdentityId(http: typeof fetch, adminUrl: string, email: strin
// --- First-run banner ----------------------------------------------------------------
// Loud, scannable block in the compose logs: where to log in + the seeded demo creds +
// the "change before production" warning. Pure so it's testable; main() console.logs it.
// the "change before production" warning. Pure so it's testable; main() prints it verbatim.
export function firstRunBanner(opts: { appUrl: string; email: string; password: string }): string {
const rule = "─".repeat(58);
return [
@@ -133,7 +134,9 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
async function main() {
const env = process.env;
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key");
// Structured like the web app (§9) so prod logs stay uniform; honour LOG_FORMAT, default text.
const log = createLogger({ format: env["LOG_FORMAT"] === "json" ? "json" : "text" });
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
@@ -148,7 +151,8 @@ async function main() {
password,
roles,
});
console.log(`bootstrap: admin ${result.created ? "created" : "already present"} (${result.id}); roles granted: ${result.roles.join(", ")}`);
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
}

View File

@@ -67,6 +67,9 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
// Secret/cookie hardening: enforced in prod, off in dev so the throwaway + http cookies pass.
assert.match(webBlock, /REQUIRE_SECURE_SECRETS:\s*"true"/, "base enforces real secrets");
assert.match(override, /REQUIRE_SECURE_SECRETS:\s*"false"/, "dev allows the throwaway");
// §9 observability: prod emits structured JSON logs; dev flips it to human-readable text.
assert.match(webBlock, /LOG_FORMAT:\s*"json"/, "prod logs structured JSON");
assert.match(override, /LOG_FORMAT:\s*"text"/, "dev logs human-readable text");
// Postgres credentials are env-supplied (dev default), never a baked-in literal.
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
});

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", () => {

View File

@@ -8,6 +8,10 @@
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
export interface Config {
cacheTemplates: boolean;
csrfSecret: string;
@@ -20,7 +24,11 @@ export interface Config {
ketoWriteUrl: string;
kratosAdminUrl: string;
kratosPublicUrl: string;
logFormat: "json" | "text"; // §9: console/OTLP entry format (json for structured prod logs)
logLevel: LogLevel; // §9: minimum severity emitted
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
otlpEndpoint: string | undefined; // §9: OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // §9: OTLP wire format (protobuf for json-averse collectors)
port: number;
revocationDenylist: boolean; // §9: enable the optional instant role/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
@@ -54,6 +62,26 @@ function readOptional(env: Env, key: string): string | undefined {
return env[key] || undefined;
}
// One of a fixed set; a typo fails at boot rather than degrading silently at runtime.
function readEnum<T extends string>(env: Env, key: string, allowed: readonly T[], devDefault: T): T {
const value = env[key];
if (value === undefined) return devDefault;
if ((allowed as readonly string[]).includes(value)) return value as T;
throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`);
}
// An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot.
function readOptionalUrl(env: Env, key: string): string | undefined {
const value = env[key] || undefined;
if (value === undefined) return undefined;
try {
new URL(value);
} catch {
throw new Error(`config: ${key} is not a valid URL: ${value}`);
}
return value;
}
// An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
function readUrl(env: Env, key: string, devDefault: string): string {
const value = env[key] ?? devDefault;
@@ -112,7 +140,13 @@ export function loadConfig(env: Env = process.env): Config {
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
// §9 observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an
// OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo).
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env),
// Optional instant-revoke (§9), off by default. When on, an admin deactivate/delete or role
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m

94
src/logger.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createLogger, requestLogger, SERVICE_NAME } from "./logger.ts";
// A capture pair so a test reads exactly what hit stdout/stderr without touching the console.
function capture() {
const out: string[] = [];
const err: string[] = [];
return { err, out, stderr: (m: string) => err.push(m), stdout: (m: string) => out.push(m) };
}
test("createLogger: tags service.name, routes by severity, gates on level, honours format", () => {
const c = capture();
const log = createLogger({ format: "json", level: "info", stderr: c.stderr, stdout: c.stdout });
assert.equal(log.context["service.name"], SERVICE_NAME); // every line/record/span is attributed to the app
log.info("hello", { n: 1 });
log.warn("careful");
log.debug("below the level"); // info level → debug is dropped
assert.equal(c.out.length, 1); // info → stdout
assert.equal(c.err.length, 1); // warn → stderr; debug suppressed entirely
const rec = JSON.parse(c.out[0]!);
assert.equal(rec["service.name"], SERVICE_NAME);
assert.equal(rec.msg, "hello");
assert.equal(rec.n, 1); // metadata kept native in JSON
});
test("createLogger: level none silences every severity", () => {
const c = capture();
const log = createLogger({ level: "none", stderr: c.stderr, stdout: c.stdout });
log.error("nope");
log.info("nope");
assert.equal(c.out.length + c.err.length, 0);
});
test("createLogger: OTLP wired only when an endpoint is given", () => {
assert.equal(createLogger({}).conf.otlpHttpBaseURI, undefined); // console-only by default
const otlp = createLogger({ otlpEndpoint: "http://collector:4318", otlpProtocol: "http/protobuf" });
assert.equal(otlp.conf.otlpHttpBaseURI, "http://collector:4318");
assert.equal(otlp.conf.otlpProtocol, "http/protobuf");
});
test("createLogger: a set endpoint actually exports log records over OTLP/HTTP", async () => {
const orig = globalThis.fetch;
const urls: string[] = [];
globalThis.fetch = async (input) => {
urls.push(String(input));
return new Response("{}", { status: 200 });
};
try {
const log = createLogger({ otlpEndpoint: "http://collector:4318", stderr: () => {}, stdout: () => {} });
log.info("exported");
await new Promise((r) => setTimeout(r, 50)); // export is fire-and-forget in the background
} finally {
globalThis.fetch = orig;
}
assert.ok(urls.some((u) => u === "http://collector:4318/v1/logs"), "POSTs the log record to /v1/logs");
});
test("requestLogger: merges service.name + requestId, inherits the parent's streams + format", () => {
const c = capture();
const app = createLogger({ format: "json", level: "info", stderr: c.stderr, stdout: c.stdout });
const req = requestLogger(app, { requestId: "req-1" });
assert.equal(req.context["service.name"], SERVICE_NAME);
assert.equal(req.context["requestId"], "req-1");
req.info("request", { status: 200 });
const rec = JSON.parse(c.out[0]!); // inherited the parent's json stdout
assert.equal(rec.requestId, "req-1");
assert.equal(rec.status, 200);
});
test("requestLogger: each request is its own root trace; a valid upstream traceparent continues it", () => {
const app = createLogger({ stderr: () => {}, stdout: () => {} });
// No upstream header → two requests get two distinct fresh traces.
const a = requestLogger(app, { requestId: "a" }).traceparent();
const b = requestLogger(app, { requestId: "b" }).traceparent();
assert.match(a, /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/);
assert.notEqual(a.split("-")[1], b.split("-")[1]); // different trace ids
// A valid incoming traceparent is adopted: same trace id, fresh span id (distributed continuation).
const upstream = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
const cont = requestLogger(app, { requestId: "c", traceparent: upstream }).traceparent();
assert.equal(cont.split("-")[1], "0af7651916cd43dd8448eb211c80319c");
assert.notEqual(cont.split("-")[2], "b7ad6b7169203331");
});
test("requestLogger: a malformed traceparent is ignored, not thrown (starts a fresh trace)", () => {
const app = createLogger({ stderr: () => {}, stdout: () => {} });
const tp = requestLogger(app, { requestId: "x", traceparent: "garbage" }).traceparent();
assert.match(tp, /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/);
});

48
src/logger.ts Normal file
View File

@@ -0,0 +1,48 @@
// Structured logging + basic observability (todo §9), on @larvit/log (zero-dependency, OTLP-native).
// One app-level Log holds the config (level/format/OTLP) and tags every line with service.name;
// each request clones it into a short-lived trace span. Console always; OTLP only when configured.
import { Log, type LogLevel } from "@larvit/log";
export { Log };
export type { LogLevel };
export const SERVICE_NAME = "plainpages"; // OTLP resource attribute — what Loki/Tempo group logs+traces by
export interface LoggerOptions {
format?: "json" | "text";
level?: LogLevel | "none"; // @larvit/log's LogLevel omits "none"; LogConf accepts it to silence all
otlpEndpoint?: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only
otlpProtocol?: "http/json" | "http/protobuf";
stderr?: (msg: string) => void; // injectable so tests read output without the console
stdout?: (msg: string) => void;
}
// The app-level logger: a Log tagged service.name so every console line, OTLP log record and span is
// attributed to "plainpages". Level + format are explicit toggles (LOG_LEVEL/LOG_FORMAT —
// environment-agnostic, AGENTS.md §4). With otlpEndpoint set, logs + spans also export to that
// OTLP/HTTP collector (e.g. an OpenTelemetry Collector fronting Tempo/Loki); unset ⇒ console only,
// at zero export cost. Conditional spreads keep exactOptionalPropertyTypes happy (no `key: undefined`).
export function createLogger(opts: LoggerOptions = {}): Log {
return new Log({
context: { "service.name": SERVICE_NAME },
format: opts.format ?? "text",
logLevel: opts.level ?? "info",
...(opts.otlpEndpoint ? { otlpHttpBaseURI: opts.otlpEndpoint, otlpProtocol: opts.otlpProtocol ?? "http/json" } : {}),
...(opts.stderr ? { stderr: opts.stderr } : {}),
...(opts.stdout ? { stdout: opts.stdout } : {}),
});
}
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
// request its own root trace — so requests aren't all nested under one app-lifetime span — while
// inheriting the parent's level/format/streams/OTLP. A valid upstream W3C `traceparent` is adopted
// (the span continues that distributed trace across a reverse proxy/gateway; malformed ⇒ ignored, a
// fresh trace starts). `requestId` tags every line + the span for log↔trace correlation. Flush with
// `end()` on response finish to export the span — a no-op when OTLP is off.
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
return appLog.clone({
context: { ...appLog.context, requestId: opts.requestId },
spanName: "request",
...(opts.traceparent ? { traceparent: opts.traceparent } : {}),
});
}

View File

@@ -9,9 +9,13 @@ import { createJwksProvider } from "./jwks.ts";
import { createKetoClient } from "./keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts";
import { createKratosPublic } from "./kratos-public.ts";
import { createLogger } from "./logger.ts";
import { loadMenuConfig } from "./menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
// App-level logger (§9): structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
// per request for access logging + a trace span (src/app.ts); console-only otherwise.
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol });
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
// Every outbound Ory call is bounded so a hung/silent Ory can't park a request handler forever.
const oryFetch = withTimeout(fetch, config.oryTimeoutSec * 1000);
@@ -29,7 +33,7 @@ const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch });
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) => p.id).join(", ") });
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({
@@ -42,14 +46,23 @@ const server = createApp({
keto,
kratos,
kratosAdmin,
log,
menu,
plugins,
secureCookies: config.secureCookies,
}).listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`);
log.info("listening", { port: config.port, url: `http://localhost:${config.port}` });
});
// Drain in-flight requests on container stop instead of cutting them mid-response.
// Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
// pending OTLP export before exiting so the last logs/spans aren't lost. Guard re-entry so a second
// signal (or SIGTERM-then-SIGINT during a slow drain) doesn't double-close or end() an ended log.
let shuttingDown = false;
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => server.close(() => process.exit(0)));
process.on(signal, () => {
if (shuttingDown) return;
shuttingDown = true;
log.info("shutting down", { signal });
server.close(() => void log.end().finally(() => process.exit(0)));
});
}

View File

@@ -53,7 +53,9 @@ function plain(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
}
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false): Promise<void> {
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so
// static.ts stays standalone, while app.ts passes the request logger for structured output (§9).
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
let decoded: string;
try {
decoded = decodeURIComponent(requestedPath);
@@ -73,7 +75,7 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
// log and destroy the response to signal a truncated body, not a hung socket.
createReadStream(filePath)
.on("error", (err) => {
console.error(err);
onError(err);
res.destroy();
})
.pipe(res);