§9 trace all fetch + ENV service name + leveled logging (todo §9 follow-up); route every outbound fetch through the request logger, make the OTLP service name implementer-configurable, and add proper leveled logging throughout. An AsyncLocalStorage<Log> makes the per-request logger ambient (runWithLog/currentLog), so all outbound fetch traces with no signature churn: tracedFetch (a typeof fetch) routes through the active request log (client span + propagated W3C traceparent) for string/URL inputs, else plain fetch; server.ts wires it under the Ory timeout into every Kratos/Keto/Hydra + JWKS call (timeout still honoured — log.fetch spreads {...init,headers}). RequestContext gained ctx.log (request logger; additive/contract-stable, silent default) so a handler/plugin logs in-trace and ctx.log.fetch(url) traces upstream calls; the reference plugin's createUpstream defaults to tracedFetch and its handlers log via ctx.log; plugin-api.ts exports tracedFetch + the Log class. SERVICE_NAME (config + createLogger({serviceName})) brands the OTLP service.name. Leveled logging: who-did-what audit info lines on every admin write (user/group/role/client create·delete·assign — actor/target, no secrets), info on login (session mint) + logout, warn on missing-role 403 + CSRF rejections + Ory-unreachable, debug on a JWKS kid-miss reload. app.ts's handler body was extracted to handleRequest run inside runWithLog; end() now fires exactly once after BOTH the handler unwinds AND the response closes, so a client abort mid-handler can't end the log out from under a still-running ctx.log/tracedFetch (regression-tested) and the happy-path access line is never dropped. bootstrap.ts wraps main in runWithLog + traces the seed calls. Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log). Stability-reviewer: APPROVE, no Critical/High (fixed the abort-race end(); green nits addressed). docs/plugin-contract.md (ctx.log/ctx.log.fetch/tracedFetch) + README (config, Observability tracing/serviceName, plugin note, Layout) updated. typecheck + 333 units + the full scripts/ci.sh E2E gate green (326 → 333).
This commit is contained in:
+36
-6
@@ -1,30 +1,34 @@
|
||||
// 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.
|
||||
// An AsyncLocalStorage makes that per-request Log ambiently available, so every outbound `fetch`
|
||||
// (`tracedFetch`) and any deep module (`currentLog()`) joins the request's trace with no threading.
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
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 const SERVICE_NAME = "plainpages"; // default 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";
|
||||
serviceName?: string; // OTLP service.name (SERVICE_NAME env); an implementer brands their own logs/traces
|
||||
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`).
|
||||
// attributed to the service. Level + format + name are explicit toggles (LOG_LEVEL/LOG_FORMAT/
|
||||
// SERVICE_NAME — 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 },
|
||||
context: { "service.name": opts.serviceName || SERVICE_NAME },
|
||||
format: opts.format ?? "text",
|
||||
logLevel: opts.level ?? "info",
|
||||
...(opts.otlpEndpoint ? { otlpHttpBaseURI: opts.otlpEndpoint, otlpProtocol: opts.otlpProtocol ?? "http/json" } : {}),
|
||||
@@ -33,6 +37,32 @@ export function createLogger(opts: LoggerOptions = {}): Log {
|
||||
});
|
||||
}
|
||||
|
||||
// The current request's Log, made ambient so deep modules (the Ory clients via tracedFetch, login,
|
||||
// jwks) join its trace + correlation without threading a logger through every signature.
|
||||
const requestStore = new AsyncLocalStorage<Log>();
|
||||
|
||||
// Run `fn` with `log` as the ambient request logger (app.ts wraps each request). currentLog() reads
|
||||
// it back; returns undefined outside any request (boot, tests) so callers use `currentLog()?.info(…)`.
|
||||
export function runWithLog<T>(log: Log, fn: () => T): T {
|
||||
return requestStore.run(log, fn);
|
||||
}
|
||||
export function currentLog(): Log | undefined {
|
||||
return requestStore.getStore();
|
||||
}
|
||||
|
||||
// A drop-in `fetch` that traces through the active request log — a client span nested under the
|
||||
// request span, with a W3C `traceparent` injected so the downstream service continues the same
|
||||
// trace. Outside a request (no ambient log) or for a non-string/URL input it's a plain `fetch`.
|
||||
// server.ts wires this (under the Ory timeout) into every Kratos/Keto/Hydra/JWKS call; a plugin
|
||||
// uses it for its upstream calls (exported via plugin-api.ts). The trace-setup adds no throw of its
|
||||
// own, but log.fetch throws synchronously if the request log has already ended (app.ts ends it only
|
||||
// after the handler unwinds, so a live handler never hits that).
|
||||
export const tracedFetch: typeof fetch = (input, init) => {
|
||||
const log = currentLog();
|
||||
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user