autotel
Version:
Write Once, Observe Anywhere
151 lines (135 loc) • 4.66 kB
text/typescript
/**
* 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,
};
}