UNPKG

autotel

Version:
285 lines (251 loc) 8.98 kB
import { AsyncLocalStorage } from 'node:async_hooks'; import { trace as otelTrace } from '@opentelemetry/api'; import type { TraceContext } from './trace-context'; import { createTraceContext } from './trace-context'; import { recordStructuredError } from './structured-error'; import { flattenToAttributes } from './flatten-attributes'; import { emitCorrelatedEvent } from './correlated-events'; const POST_EMIT_FORK_HINT = "For intentional background work tied to this request, use log.fork('label', fn) when available."; function warnPostEmit(method: string, detail: string): void { console.warn( `[autotel] ${method} called after the wide event was emitted — ${detail} This data will not appear in observability. ${POST_EMIT_FORK_HINT}`, ); } function mergeInto( target: Record<string, unknown>, source: Record<string, unknown>, ): void { for (const key in source) { const sourceVal = source[key]; if (sourceVal === undefined) continue; const targetVal = target[key]; if ( sourceVal !== null && typeof sourceVal === 'object' && !Array.isArray(sourceVal) && targetVal !== null && typeof targetVal === 'object' && !Array.isArray(targetVal) ) { mergeInto( targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>, ); } else if (Array.isArray(targetVal) && Array.isArray(sourceVal)) { target[key] = [...targetVal, ...sourceVal]; } else { target[key] = sourceVal; } } } const requestContextStore = new AsyncLocalStorage<TraceContext>(); export function runWithRequestContext<T>(ctx: TraceContext, fn: () => T): T { return requestContextStore.run(ctx, fn); } export interface RequestLogger { set(fields: Record<string, unknown>): void; info(message: string, fields?: Record<string, unknown>): void; warn(message: string, fields?: Record<string, unknown>): void; error(error: Error | string, fields?: Record<string, unknown>): void; getContext(): Record<string, unknown>; emitNow(overrides?: Record<string, unknown>): RequestLogSnapshot; fork( label: string, fn: () => void | Promise<void>, options?: ForkOptions, ): void; } export interface RequestLogSnapshot { timestamp: string; traceId: string; spanId: string; correlationId: string; context: Record<string, unknown>; } export interface RequestLoggerOptions { /** Callback invoked by emitNow() for manual fan-out. */ onEmit?: (snapshot: RequestLogSnapshot) => void | Promise<void>; } /** * Optional lifecycle hooks for adapters that need to track child loggers * spawned by `log.fork()` (e.g. active logger maps in framework integrations). */ export interface ForkLifecycle { /** Called after the child logger is created, before `fn` runs. */ onChildEnter?: (child: RequestLogger) => void; /** Called after the child has finished (emit + drain), success or failure. */ onChildExit?: (child: RequestLogger) => void; } export interface ForkOptions { lifecycle?: ForkLifecycle; } function resolveContext(ctx?: TraceContext): TraceContext { if (ctx) return ctx; const stored = requestContextStore.getStore(); if (stored) return stored; const span = otelTrace.getActiveSpan(); if (!span) { throw new Error( '[autotel] getRequestLogger() requires an active span or runWithRequestContext(). Wrap your handler with trace() or use runWithRequestContext().', ); } return createTraceContext(span); } export function getRequestLogger( ctx?: TraceContext, options?: RequestLoggerOptions, ): RequestLogger { const activeContext = resolveContext(ctx); let contextState: Record<string, unknown> = {}; let emitted = false; let lastSnapshot: RequestLogSnapshot | null = null; const addLogEvent = ( level: 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>, ) => { const attrs = fields ? flattenToAttributes(fields) : undefined; emitCorrelatedEvent(activeContext, `log.${level}`, { message, ...(attrs ?? {}), }); }; const sealCheck = (method: string, keys: string[]): void => { if (emitted) { warnPostEmit( method, `Keys dropped: ${keys.length ? keys.join(', ') : '(empty)'}.`, ); } }; return { set(fields: Record<string, unknown>) { sealCheck('log.set()', Object.keys(fields)); if (emitted) return; mergeInto(contextState, fields); activeContext.setAttributes(flattenToAttributes(fields)); }, info(message: string, fields?: Record<string, unknown>) { const keys = fields ? ['message', ...Object.keys(fields).filter((k) => k !== 'requestLogs')] : ['message']; sealCheck('log.info()', keys); if (emitted) return; addLogEvent('info', message, fields); if (fields) { mergeInto(contextState, fields); activeContext.setAttributes(flattenToAttributes(fields)); } }, warn(message: string, fields?: Record<string, unknown>) { const keys = fields ? ['message', ...Object.keys(fields).filter((k) => k !== 'requestLogs')] : ['message']; sealCheck('log.warn()', keys); if (emitted) return; addLogEvent('warn', message, fields); activeContext.setAttribute('autotel.log.level', 'warn'); if (fields) { mergeInto(contextState, fields); activeContext.setAttributes(flattenToAttributes(fields)); } }, error(error: Error | string, fields?: Record<string, unknown>) { const keys = fields ? [...Object.keys(fields), 'error'] : ['error']; sealCheck('log.error()', keys); if (emitted) return; const err = typeof error === 'string' ? new Error(error) : error; recordStructuredError(activeContext, err); addLogEvent('error', err.message, fields); if (fields) { mergeInto(contextState, fields); activeContext.setAttributes(flattenToAttributes(fields)); } activeContext.setAttribute('autotel.log.level', 'error'); }, getContext() { return { ...contextState }; }, emitNow(overrides?: Record<string, unknown>): RequestLogSnapshot { if (emitted) { warnPostEmit('log.emitNow()', 'Ignoring duplicate emit.'); return lastSnapshot as RequestLogSnapshot; } const mergedContext = { ...contextState, ...(overrides ?? {}), }; const flattened = flattenToAttributes(mergedContext); activeContext.setAttributes(flattened); const snapshot: RequestLogSnapshot = { timestamp: new Date().toISOString(), traceId: activeContext.traceId, spanId: activeContext.spanId, correlationId: activeContext.correlationId, context: mergedContext, }; emitCorrelatedEvent(activeContext, 'log.emit.manual', { ...flattened, }); if (options?.onEmit) { Promise.resolve(options.onEmit(snapshot)).catch((error) => { console.warn('[autotel] request logger onEmit failed:', error); }); } emitted = true; lastSnapshot = snapshot; return snapshot; }, fork( label: string, fn: () => void | Promise<void>, forkOptions?: ForkOptions, ): void { const parentRequestId = activeContext.correlationId; if (typeof parentRequestId !== 'string' || parentRequestId.length === 0) { throw new Error( '[autotel] log.fork() requires the parent logger to have a correlationId. ' + 'Ensure the request was created by autotel middleware.', ); } const tracer = otelTrace.getTracer('autotel.request-logger'); const lifecycle = forkOptions?.lifecycle; void tracer.startActiveSpan(`request.fork:${label}`, (childSpan) => { const childContext: TraceContext = { ...createTraceContext(childSpan), correlationId: crypto.randomUUID(), }; requestContextStore.run(childContext, () => { const childLog = getRequestLogger(childContext); childLog.set({ operation: label, _parentCorrelationId: parentRequestId, }); lifecycle?.onChildEnter?.(childLog); void Promise.resolve() .then(() => fn()) .then(() => { childLog.emitNow(); }) .catch((err: unknown) => { const error = err instanceof Error ? err : new Error(String(err)); childLog.error(error); childLog.emitNow(); }) .finally(() => { try { lifecycle?.onChildExit?.(childLog); } catch (hookError) { console.warn( '[autotel] fork onChildExit hook threw:', hookError, ); } childSpan.end(); }); }); }); }, }; }