import { appendFile, mkdir } from 'node:fs/promises'; import { dirname, join } from 'node:path'; export type IngestTraceLevel = 'info' | 'debug' | 'trace' | 'error'; const TRACE_LEVEL_RANK: Record = { error: 0, info: 1, debug: 2, trace: 3, }; export interface IngestTraceContext { tracePath: string; jobId: string; connectionId: string; sourceKey: string; runId?: string; syncId?: string; level?: IngestTraceLevel; } export interface IngestTraceEvent { schemaVersion: 1; at: string; level: IngestTraceLevel; jobId: string; connectionId: string; sourceKey: string; runId?: string; syncId?: string; phase: string; event: string; durationMs?: number; data?: Record; error?: { name: string; message: string; stack?: string; }; } export interface IngestTraceWriter { readonly tracePath: string; readonly context: IngestTraceContext; withContext(context: Partial>): IngestTraceWriter; event( level: IngestTraceLevel, phase: string, event: string, data?: Record, error?: unknown, durationMs?: number, ): Promise; } export function ingestTracePathForJob(homeDir: string, jobId: string): string { return join(homeDir, 'ingest-traces', jobId, 'trace.jsonl'); } function serializeError(error: unknown): IngestTraceEvent['error'] | undefined { if (error === undefined || error === null) { return undefined; } if (error instanceof Error) { return { name: error.name, message: error.message, ...(error.stack ? { stack: error.stack } : {}), }; } return { name: 'Error', message: String(error) }; } function shouldWrite(configured: IngestTraceLevel, incoming: IngestTraceLevel): boolean { return TRACE_LEVEL_RANK[incoming] <= TRACE_LEVEL_RANK[configured]; } export class FileIngestTraceWriter implements IngestTraceWriter { readonly tracePath: string; readonly context: IngestTraceContext; constructor(context: IngestTraceContext) { this.context = { ...context, level: context.level ?? 'debug' }; this.tracePath = context.tracePath; } withContext(context: Partial>): IngestTraceWriter { return new FileIngestTraceWriter({ ...this.context, ...context, tracePath: this.tracePath }); } async event( level: IngestTraceLevel, phase: string, event: string, data?: Record, error?: unknown, durationMs?: number, ): Promise { if (!shouldWrite(this.context.level ?? 'debug', level)) { return; } const serializedError = serializeError(error); const payload: IngestTraceEvent = { schemaVersion: 1, at: new Date().toISOString(), level, jobId: this.context.jobId, connectionId: this.context.connectionId, sourceKey: this.context.sourceKey, ...(this.context.runId ? { runId: this.context.runId } : {}), ...(this.context.syncId ? { syncId: this.context.syncId } : {}), phase, event, ...(durationMs !== undefined ? { durationMs } : {}), ...(data ? { data } : {}), ...(serializedError ? { error: serializedError } : {}), }; await mkdir(dirname(this.tracePath), { recursive: true }); await appendFile(this.tracePath, `${JSON.stringify(payload)}\n`, 'utf-8'); } } export class NoopIngestTraceWriter implements IngestTraceWriter { readonly tracePath = ''; readonly context: IngestTraceContext = { tracePath: '', jobId: '', connectionId: '', sourceKey: '', level: 'error', }; withContext(): IngestTraceWriter { return this; } async event(): Promise {} } export async function traceTimed( trace: IngestTraceWriter, phase: string, event: string, data: Record, fn: () => Promise, ): Promise { await trace.event('debug', phase, `${event}_started`, data); const started = Date.now(); try { const result = await fn(); await trace.event('debug', phase, `${event}_finished`, data, undefined, Date.now() - started); return result; } catch (error) { await trace.event('error', phase, `${event}_failed`, data, error, Date.now() - started); throw error; } }