UNPKG

autotel

Version:
151 lines (135 loc) 4.66 kB
/** * TestSpanCollector — SpanExporter that groups finished spans by traceId * and drains per-trace for embedding in test metadata. * * @example * ```typescript * import { TestSpanCollector } from 'autotel/test-span-collector'; * import { SimpleSpanProcessor } from 'autotel/processors'; * import { getAutotelTracerProvider } from 'autotel'; * * const collector = new TestSpanCollector(); * const provider = getAutotelTracerProvider(); * provider.addSpanProcessor(new SimpleSpanProcessor(collector)); * * // After a test span ends: * const spans = collector.drainTrace(traceId, rootSpanId); * // spans contains only descendants of rootSpanId * ``` */ import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { SpanStatusCode } from '@opentelemetry/api'; /** @see ExportResultCode from @opentelemetry/core */ const ExportResultCode = { SUCCESS: 0, FAILED: 1 } as const; /** Attribute value types that survive serialization */ type SerializableValue = | string | number | boolean | string[] | number[] | boolean[]; /** * Portable serialized span for embedding in test metadata. * `startTimeMs` is derived from OTel HrTime — epoch-based wall-clock ms in the current SDK. * * Defined as a `type` (not `interface`) so it is assignable to * `Record<string, unknown>` in TypeScript 6+ strict mode. */ export type SerializedSpan = { spanId: string; parentSpanId?: string; name: string; startTimeMs: number; durationMs: number; status: 'ok' | 'error' | 'unset'; statusMessage?: string; attributes?: Record<string, SerializableValue>; }; export class TestSpanCollector implements SpanExporter { private traces = new Map<string, ReadableSpan[]>(); export( spans: ReadableSpan[], callback: (result: { code: number }) => void, ): void { for (const span of spans) { const traceId = span.spanContext().traceId; let list = this.traces.get(traceId); if (!list) { list = []; this.traces.set(traceId, list); } list.push(span); } callback({ code: ExportResultCode.SUCCESS }); } /** * Drain and serialize spans that are descendants of `rootSpanId` within `traceId`. * Filters to the subtree rooted at the test span to prevent cross-test mixing. * Removes the entire traceId entry from the collector. */ drainTrace(traceId: string, rootSpanId: string): SerializedSpan[] { const allSpans = this.traces.get(traceId); this.traces.delete(traceId); if (!allSpans?.length) return []; // Build spanId → span index for efficient parent-chain walking const byId = new Map<string, ReadableSpan>(); for (const s of allSpans) byId.set(s.spanContext().spanId, s); // Filter to spans that are the root or descendants of rootSpanId const included = allSpans.filter((s) => { let id: string | undefined = s.spanContext().spanId; while (id) { if (id === rootSpanId) return true; const parent = byId.get(id); const parentId = parent?.parentSpanContext?.spanId || undefined; if (parentId === id) break; // cycle guard id = parentId; } return false; }); return included.map((s) => serializeSpan(s)); } shutdown(): Promise<void> { this.traces.clear(); return Promise.resolve(); } forceFlush(): Promise<void> { return Promise.resolve(); } } function hrTimeToMs(hr: [number, number]): number { return hr[0] * 1000 + hr[1] / 1_000_000; } function isSerializable(v: unknown): v is SerializableValue { if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return true; if (Array.isArray(v) && v.length > 0) { const t = typeof v[0]; return ( (t === 'string' || t === 'number' || t === 'boolean') && v.every((e) => typeof e === t) ); } return false; } export function serializeSpan(span: ReadableSpan): SerializedSpan { const attrs: Record<string, SerializableValue> = {}; for (const [k, v] of Object.entries(span.attributes)) { if (isSerializable(v)) attrs[k] = v; } return { spanId: span.spanContext().spanId, parentSpanId: span.parentSpanContext?.spanId || undefined, name: span.name, startTimeMs: hrTimeToMs(span.startTime as [number, number]), durationMs: hrTimeToMs(span.duration as [number, number]), status: span.status.code === SpanStatusCode.ERROR ? 'error' : span.status.code === SpanStatusCode.OK ? 'ok' : 'unset', statusMessage: span.status.message || undefined, attributes: Object.keys(attrs).length > 0 ? attrs : undefined, }; }