/** * Optional OTLP/HTTP (JSON) export of API server spans and worker activity spans. Off unless * SPACE_OTEL_EXPORTER_OTLP_ENDPOINT names a collector base URL (spans go to * `/v1/traces`). No dependency, no collector required: Space logs stay * joinable by W3C trace id without it. * * Spans carry only the method, route template, status code, service name and * Space request id; never URLs with parameters, headers, bodies or user data. * Export is best effort: a bounded queue drops (and counts) spans instead of * growing, each POST has a timeout, and a failing collector never affects a * request. */ export interface ServerSpan { traceId: string; spanId: string; parentSpanId: string | null; name: string; startTimeUnixNano: bigint; endTimeUnixNano: bigint; attributes: Readonly>; error: boolean; /** OTLP span kind; SERVER (2) unless given. Worker activities use INTERNAL (1). */ kind?: number; } export interface SpanExporterStats { exported: number; dropped: number; failed: number; } export interface SpanExporter { record(span: ServerSpan): void; flush(): Promise; close(): Promise; stats(): SpanExporterStats; } type FetchLike = (url: string, init: { method: string; headers: Record; body: string; signal: AbortSignal }) => Promise<{ ok: boolean }>; export interface OtlpSpanExporterOptions { endpoint: string; serviceName: string; fetch?: FetchLike; batchSize?: number; maxQueue?: number; flushIntervalMs?: number; timeoutMs?: number; } const hexId = (length: number) => new RegExp(`^[0-9a-f]{${length}}$`); const traceIdPattern = hexId(32); const spanIdPattern = hexId(16); /** The collector base URL, or null when export is off or the value is unsafe. */ export function otlpTracesUrl(value: string | undefined): string | null { if (!value?.trim()) return null; let url: URL; try { url = new URL(value.trim()); } catch { return null; } if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || url.search || url.hash) return null; return `${url.origin}${url.pathname.replace(/\/+$/, "")}/v1/traces`; } function attributeValue(value: string | number) { return typeof value === "number" && Number.isInteger(value) ? { intValue: String(value) } : { stringValue: String(value) }; } export function otlpTraceRequest(spans: readonly ServerSpan[], serviceName: string) { return { resourceSpans: [{ resource: { attributes: [{ key: "service.name", value: { stringValue: serviceName } }] }, scopeSpans: [{ scope: { name: serviceName }, spans: spans.map(span => ({ traceId: span.traceId, spanId: span.spanId, ...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}), name: span.name, kind: span.kind ?? 2, startTimeUnixNano: span.startTimeUnixNano.toString(), endTimeUnixNano: span.endTimeUnixNano.toString(), attributes: Object.entries(span.attributes).map(([key, value]) => ({ key, value: attributeValue(value) })), // STATUS_CODE_ERROR or STATUS_CODE_UNSET status: { code: span.error ? 2 : 0 } })) }] }] }; } export function createOtlpSpanExporter(options: OtlpSpanExporterOptions): SpanExporter { const url = otlpTracesUrl(options.endpoint); if (!url) throw new Error("Invalid OTLP endpoint."); const send: FetchLike = options.fetch ?? ((target, init) => fetch(target, init)); const batchSize = options.batchSize ?? 256; const maxQueue = options.maxQueue ?? 4096; const timeoutMs = options.timeoutMs ?? 3000; const queue: ServerSpan[] = []; const stats: SpanExporterStats = { exported: 0, dropped: 0, failed: 0 }; let inFlight: Promise | null = null; const flushOnce = async () => { const batch = queue.splice(0, batchSize); if (!batch.length) return; try { const response = await send(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(otlpTraceRequest(batch, options.serviceName)), signal: AbortSignal.timeout(timeoutMs) }); if (response.ok) stats.exported += batch.length; else stats.failed += batch.length; } catch { stats.failed += batch.length; } }; const flush = (): Promise => { if (!inFlight) { inFlight = (async () => { try { while (queue.length) await flushOnce(); } finally { inFlight = null; } })(); } return inFlight; }; const timer = setInterval(() => void flush(), options.flushIntervalMs ?? 5000); timer.unref?.(); return { record(span) { if (!traceIdPattern.test(span.traceId) || !spanIdPattern.test(span.spanId)) return; if (queue.length >= maxQueue) { stats.dropped += 1; return; } queue.push(span); if (queue.length >= batchSize) void flush(); }, flush, async close() { clearInterval(timer); await flush(); }, stats: () => ({ ...stats }) }; } /** The exporter configured by SPACE_OTEL_EXPORTER_OTLP_ENDPOINT, or null when export is off. */ export function otlpSpanExporterFromEnv(env: NodeJS.ProcessEnv, serviceName: string): SpanExporter | null { const endpoint = env.SPACE_OTEL_EXPORTER_OTLP_ENDPOINT; return otlpTracesUrl(endpoint) ? createOtlpSpanExporter({ endpoint: endpoint!, serviceName }) : null; }