§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:
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user