/** * Logger implementation — pure record-builder over an injected LogSink. * * All I/O goes through the sink. `{ now, newId }` are injected for * deterministic tests (P2). No ambient state (P3). */ import type { Attributes, ErrorAttributes, Level, LogContext, LogDeps, Logger, LogLineRecord, LogSink, Span, SpanCloseRecord, SpanLink, SpanOpenRecord, SpanStatus, } from "../contracts/logging.js"; interface LoggerState { readonly ctx: LogContext; readonly attrs: Attributes | undefined; readonly deps: LogDeps; readonly sink: LogSink; } function mergeAttributes( base: Attributes | undefined, extra: Attributes | undefined, ): Attributes | undefined { if (base === undefined && extra === undefined) return undefined; if (base === undefined) return extra; if (extra === undefined) return base; return { ...base, ...extra }; } function isScalarAttr(value: unknown): value is string | number | boolean | null { const t = typeof value; return t === "string" || t === "number" || t === "boolean" || value === null; } function emitLog(state: LoggerState, level: Level, msg: string, attrs?: Attributes): void { const merged = mergeAttributes(state.attrs, attrs); const base = { kind: "log" as const, level, msg, timestamp: state.deps.now(), extensionId: state.ctx.extensionId, }; const record: LogLineRecord = state.ctx.conversationId !== undefined || state.ctx.turnId !== undefined || state.ctx.spanId !== undefined || state.ctx.parentSpanId !== undefined || merged !== undefined ? { ...base, ...(state.ctx.conversationId !== undefined ? { conversationId: state.ctx.conversationId } : {}), ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}), ...(state.ctx.spanId !== undefined ? { spanId: state.ctx.spanId } : {}), ...(state.ctx.parentSpanId !== undefined ? { parentSpanId: state.ctx.parentSpanId } : {}), ...(merged !== undefined ? { attributes: merged } : {}), } : base; try { state.sink.emit(record); } catch { // Swallow — D7: the turn is sovereign (never break the caller). } } function buildSpanOpen( state: LoggerState, name: string, spanId: string, attrs?: Attributes, body?: string, parentSpanId?: string, ): SpanOpenRecord { const base = { kind: "span-open" as const, spanId, name, timestamp: state.deps.now(), extensionId: state.ctx.extensionId, }; const merged = mergeAttributes(state.attrs, attrs); const effectiveParent = parentSpanId ?? state.ctx.parentSpanId; return { ...base, ...(state.ctx.conversationId !== undefined ? { conversationId: state.ctx.conversationId } : {}), ...(state.ctx.turnId !== undefined ? { turnId: state.ctx.turnId } : {}), ...(effectiveParent !== undefined ? { parentSpanId: effectiveParent } : {}), ...(merged !== undefined ? { attributes: merged } : {}), ...(body !== undefined ? { body } : {}), }; } function buildSpanLink( target: { readonly spanId: string; readonly turnId?: string }, reason?: string, ): SpanLink { return { spanId: target.spanId, ...(target.turnId !== undefined ? { turnId: target.turnId } : {}), ...(reason !== undefined ? { reason } : {}), }; } /** * Create a structured Logger. Pure factory — all I/O goes through the * injected sink. `{ now, newId }` are injected for deterministic tests. * * @param ctx Initial correlation context (extensionId + optional ids). * @param sink Fire-and-forget record sink. * @param deps Clock + id generator. * @param attrs Optional default attributes (from child()). */ export function createLogger( ctx: LogContext, sink: LogSink, deps: LogDeps, attrs?: Attributes, ): Logger { const state: LoggerState = { ctx, attrs, deps, sink }; function makeSpan( name: string, spanAttrs?: Attributes, parentSpanId?: string, body?: string, ): Span { const spanId = deps.newId(); const mergedParent = parentSpanId ?? state.ctx.spanId; const spanCtx: LogContext = { extensionId: ctx.extensionId, ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}), ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}), spanId, ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}), }; const openRecord = buildSpanOpen(state, name, spanId, spanAttrs, body, mergedParent); const spanAttrsMutable: Record = spanAttrs !== undefined ? { ...spanAttrs } : {}; const links: SpanLink[] = []; const openedAt = deps.now(); try { sink.emit(openRecord); } catch { // Swallow — D7. } const spanLogger = createLogger(spanCtx, sink, deps, state.attrs); const span: Span = { id: spanId, log: spanLogger, setAttributes(newAttrs: Attributes): void { for (const [key, value] of Object.entries(newAttrs)) { spanAttrsMutable[key] = value; } }, addLink(target, reason): void { links.push(buildSpanLink(target, reason)); }, child(childName: string, childAttrs?: Attributes, childBody?: string): Span { return makeSpan(childName, childAttrs, spanId, childBody); }, end(outcome?): void { const closedAt = deps.now(); const err = outcome?.err; let status: SpanStatus = "ok"; if (err !== undefined && err !== null) { status = "error"; const errMsg = err instanceof Error ? err.message : String(err); spanAttrsMutable["error.message"] = errMsg; if (err instanceof Error && err.stack !== undefined) { spanAttrsMutable["error.stack"] = err.stack; } } if (outcome?.attrs !== undefined) { for (const [key, value] of Object.entries(outcome.attrs)) { spanAttrsMutable[key] = value; } } const hasAttrs = Object.keys(spanAttrsMutable).length > 0; // Merge child-bound default attrs (state.attrs) the SAME way span-open // does (buildSpanOpen). Without this, an attribute bound via // `logger.child({ attrs })` appears on the span-open record but NOT the // span-close record — so a query like `warm = true` can't find the // closed span (with its usage/status). Open and close must agree. const mergedCloseAttrs = mergeAttributes( state.attrs, hasAttrs ? spanAttrsMutable : undefined, ); const hasLinks = links.length > 0; const base = { kind: "span-close" as const, spanId, name, timestamp: closedAt, durationMs: closedAt - openedAt, status, extensionId: ctx.extensionId, }; const closeRecord: SpanCloseRecord = { ...base, ...(ctx.conversationId !== undefined ? { conversationId: ctx.conversationId } : {}), ...(ctx.turnId !== undefined ? { turnId: ctx.turnId } : {}), ...(mergedParent !== undefined ? { parentSpanId: mergedParent } : {}), ...(mergedCloseAttrs !== undefined ? { attributes: mergedCloseAttrs } : {}), ...(hasLinks ? { links: [...links] } : {}), ...(outcome?.body !== undefined ? { body: outcome.body } : {}), }; try { sink.emit(closeRecord); } catch { // Swallow — D7. } }, }; return span; } const logger: Logger = { debug(msg: string, attrs?: Attributes): void { emitLog(state, "debug", msg, attrs); }, info(msg: string, attrs?: Attributes): void { emitLog(state, "info", msg, attrs); }, warn(msg: string, attrs?: Attributes): void { emitLog(state, "warn", msg, attrs); }, error(msg: string, attrs?: ErrorAttributes): void { const err = attrs?.err; if (err !== undefined && err !== null) { // Extract scalar attributes (everything except err). const scalarAttrs: Record = {}; if (attrs !== undefined) { for (const [key, value] of Object.entries(attrs)) { if (key !== "err" && isScalarAttr(value)) { scalarAttrs[key] = value; } } } const merged = mergeAttributes( state.attrs, Object.keys(scalarAttrs).length > 0 ? scalarAttrs : undefined, ); const errMsg = err instanceof Error ? err.message : String(err); const errorAttrs: Record = { ...(merged ?? {}), "error.message": errMsg, }; if (err instanceof Error && err.stack !== undefined) { errorAttrs["error.stack"] = err.stack; } emitLog(state, "error", msg, errorAttrs as Attributes); } else { // No err field — filter to scalar attributes only. const scalarAttrs: Record = {}; if (attrs !== undefined) { for (const [key, value] of Object.entries(attrs)) { if (isScalarAttr(value)) { scalarAttrs[key] = value; } } } emitLog( state, "error", msg, Object.keys(scalarAttrs).length > 0 ? (scalarAttrs as Attributes) : undefined, ); } }, child(childCtx: Partial & { readonly attrs?: Attributes }): Logger { const convId = childCtx.conversationId ?? ctx.conversationId; const tId = childCtx.turnId ?? ctx.turnId; const sId = childCtx.spanId ?? ctx.spanId; const pId = childCtx.parentSpanId ?? ctx.parentSpanId; const newCtx: LogContext = { extensionId: ctx.extensionId, ...(convId !== undefined ? { conversationId: convId } : {}), ...(tId !== undefined ? { turnId: tId } : {}), ...(sId !== undefined ? { spanId: sId } : {}), ...(pId !== undefined ? { parentSpanId: pId } : {}), }; const newAttrs = mergeAttributes(state.attrs, childCtx.attrs); return createLogger(newCtx, sink, deps, newAttrs); }, span(name: string, attrs?: Attributes, body?: string): Span { return makeSpan(name, attrs, undefined, body); }, }; return logger; }