/** * Lightweight OTel-compatible tracing for Zeta crawler. * * Emits spans as structured JSON logs compatible with OpenTelemetry format. * Drop-in upgrade path: replace this module with @opentelemetry/sdk-node when needed. * * Usage: * const span = startSpan('crawl.url', { url, engine }); * // ... do work ... * endSpan(span, { screensFound: 3 }); */ import { randomBytes } from 'node:crypto'; export interface Span { traceId: string; spanId: string; parentSpanId?: string; name: string; startTimeMs: number; attributes: Record; } export interface FinishedSpan extends Span { endTimeMs: number; durationMs: number; status: 'ok' | 'error'; error?: string; } // Active trace context — per async execution context (simplified: per-process global) let currentTraceId: string | null = null; let currentSpanId: string | null = null; function genId(bytes: number): string { return randomBytes(bytes).toString('hex'); } /** Start a new span. If a trace is already active, the new span is a child. */ export function startSpan(name: string, attributes: Record = {}): Span { const traceId = currentTraceId ?? genId(16); const spanId = genId(8); const parentSpanId = currentSpanId ?? undefined; if (!currentTraceId) currentTraceId = traceId; currentSpanId = spanId; return { traceId, spanId, parentSpanId, name, startTimeMs: Date.now(), attributes }; } /** End a span and emit it as a structured log line. */ export function endSpan(span: Span, extraAttributes: Record = {}, error?: Error): FinishedSpan { const endTimeMs = Date.now(); const durationMs = endTimeMs - span.startTimeMs; const finished: FinishedSpan = { ...span, attributes: { ...span.attributes, ...extraAttributes }, endTimeMs, durationMs, status: error ? 'error' : 'ok', error: error?.message, }; // Reset context when root span ends if (!span.parentSpanId) { currentTraceId = null; currentSpanId = span.parentSpanId ?? null; } else { currentSpanId = span.parentSpanId ?? null; } // Emit as structured JSON — compatible with Datadog/Grafana log parsing console.log(JSON.stringify({ ts: new Date(endTimeMs).toISOString(), level: error ? 'error' : 'info', msg: `span:${finished.name}`, trace_id: finished.traceId, span_id: finished.spanId, parent_span_id: finished.parentSpanId, duration_ms: durationMs, status: finished.status, error: finished.error, ...finished.attributes, })); return finished; } /** Convenience: wrap an async function in a span. */ export async function withSpan( name: string, attributes: Record, fn: (span: Span) => Promise, ): Promise { const span = startSpan(name, attributes); try { const result = await fn(span); endSpan(span); return result; } catch (err: any) { endSpan(span, {}, err); throw err; } } /** Counter metrics — increment and log on flush. */ const counters: Map = new Map(); export function increment(metric: string, value = 1, tags: Record = {}): void { const key = metric + (Object.keys(tags).length ? ':' + Object.entries(tags).map(([k, v]) => `${k}=${v}`).join(',') : ''); counters.set(key, (counters.get(key) ?? 0) + value); } /** Flush counters as a structured log — call periodically (e.g. at job end). */ export function flushMetrics(): Record { const snapshot = Object.fromEntries(counters); if (Object.keys(snapshot).length > 0) { console.log(JSON.stringify({ ts: new Date().toISOString(), level: 'info', msg: 'metrics:flush', ...snapshot })); counters.clear(); } return snapshot; }