// Structured logging + basic observability, 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"; // 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, tagged service.name. With otlpEndpoint set, logs + spans also export to that // OTLP/HTTP collector; unset ⇒ console only, at zero export cost. The conditional spreads keep // exactOptionalPropertyTypes happy (no `key: undefined`). export function createLogger(opts: LoggerOptions = {}): Log { return new Log({ 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" } : {}), ...(opts.stderr ? { stderr: opts.stderr } : {}), ...(opts.stdout ? { stdout: opts.stdout } : {}), }); } // The ambient per-request Log (see the header); set by runWithLog, read by currentLog/tracedFetch. const requestStore = new AsyncLocalStorage(); // 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(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 under the request // span, with a W3C `traceparent` injected so the downstream service continues the same trace. // Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws // synchronously once the request log has ended; app.ts ends it only after the handler unwinds. 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 `traceparent` is adopted; // malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span. 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 } : {}), }); }