import { ROOT_CONTEXT, context, trace, type Context, type ContextManager, } from "@opentelemetry/api"; import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { assistantCloudTraceExportOptions, assistantCloudTraceMetadata, createAssistantCloudSpanProcessor, isAssistantCloudSpan, withAssistantCloudTraceMetadata, } from "./index"; const TRACE_ID = "0123456789abcdef0123456789abcdef"; const SPAN_ID = "0123456789abcdef"; class TestContextManager implements ContextManager { private activeContext: Context = ROOT_CONTEXT; active(): Context { return this.activeContext; } with ReturnType>( nextContext: Context, fn: F, thisArg?: ThisParameterType, ...args: A ): ReturnType { const previousContext = this.activeContext; this.activeContext = nextContext; try { return fn.call(thisArg, ...args); } finally { this.activeContext = previousContext; } } bind(_context: Context, target: T): T { return target; } enable(): this { return this; } disable(): this { this.activeContext = ROOT_CONTEXT; return this; } } function span( name: string, attributes: Record = {}, ): ReadableSpan { return { name, attributes, spanContext: () => ({ traceId: TRACE_ID, spanId: SPAN_ID, traceFlags: 1, }), resource: { asyncAttributesPending: false }, } as ReadableSpan; } function recordingExporter() { const spans: ReadableSpan[] = []; const exporter = { export: vi.fn( ( nextSpans: ReadableSpan[], resultCallback: Parameters[1], ) => { spans.push(...nextSpans); resultCallback({ code: 0 }); }, ), shutdown: vi.fn().mockResolvedValue(undefined), } satisfies SpanExporter; return { exporter, spans }; } beforeAll(() => { context.disable(); context.setGlobalContextManager(new TestContextManager()); }); afterAll(() => { context.disable(); }); describe("assistantCloudTraceExportOptions", () => { it("uses the default receiver and authorization header", () => { expect(assistantCloudTraceExportOptions({ apiKey: "key" })).toEqual({ url: "https://backend.assistant-api.com/v1/traces", headers: { Authorization: "Bearer key" }, }); }); it("uses a custom receiver without a trailing slash", () => { expect( assistantCloudTraceExportOptions({ apiKey: "key", baseUrl: "https://cloud.example.com", }), ).toEqual({ url: "https://cloud.example.com/v1/traces", headers: { Authorization: "Bearer key" }, }); }); it("removes trailing slashes and preserves extra headers", () => { expect( assistantCloudTraceExportOptions({ apiKey: "key", baseUrl: "https://cloud.example.com/", headers: { "X-Workspace": "workspace_1" }, }), ).toEqual({ url: "https://cloud.example.com/v1/traces", headers: { Authorization: "Bearer key", "X-Workspace": "workspace_1", }, }); }); it("rejects an empty API key", () => { expect(() => assistantCloudTraceExportOptions({ apiKey: "" })).toThrow( "An Assistant Cloud API key is required", ); }); }); describe("isAssistantCloudSpan", () => { it("accepts AI span name prefixes", () => { expect(isAssistantCloudSpan(span("gen_ai.client"))).toBe(true); expect(isAssistantCloudSpan(span("ai.streamText"))).toBe(true); expect(isAssistantCloudSpan(span("llm.generate"))).toBe(true); }); it("accepts the GenAI operation attribute", () => { expect( isAssistantCloudSpan( span("generate", { "gen_ai.operation.name": "ai.generateText" }), ), ).toBe(true); }); it("rejects ordinary spans", () => { expect(isAssistantCloudSpan(span("GET /api/chat"))).toBe(false); }); }); describe("createAssistantCloudSpanProcessor", () => { it("exports matching spans and drops other spans", async () => { const { exporter, spans } = recordingExporter(); const processor = createAssistantCloudSpanProcessor(exporter); processor.onEnd(span("gen_ai.client")); processor.onEnd(span("GET /api/chat")); await processor.forceFlush(); expect(spans.map((candidate) => candidate.name)).toEqual(["gen_ai.client"]); }); it("uses a custom filter when one is provided", async () => { const { exporter, spans } = recordingExporter(); const processor = createAssistantCloudSpanProcessor(exporter, { filter: (candidate) => candidate.name === "custom", }); processor.onEnd(span("gen_ai.client")); processor.onEnd(span("custom")); await processor.forceFlush(); expect(spans.map((candidate) => candidate.name)).toEqual(["custom"]); }); it("flushes and shuts down the wrapped processor", async () => { const { exporter } = recordingExporter(); const processor = createAssistantCloudSpanProcessor(exporter); await processor.forceFlush(); await processor.shutdown(); expect(exporter.shutdown).toHaveBeenCalledTimes(1); }); }); describe("assistantCloudTraceMetadata", () => { it("returns the active trace ID", () => { const result = context.with( trace.setSpanContext(context.active(), { traceId: TRACE_ID, spanId: SPAN_ID, traceFlags: 1, }), assistantCloudTraceMetadata, ); expect(result).toEqual({ traceId: TRACE_ID }); }); it("returns an empty object without an active span", () => { expect(assistantCloudTraceMetadata()).toEqual({}); }); }); describe("withAssistantCloudTraceMetadata", () => { it("merges the active trace ID into start metadata", () => { const messageMetadata = withAssistantCloudTraceMetadata(({ part }) => ({ type: part.type, })); const result = context.with( trace.setSpanContext(context.active(), { traceId: TRACE_ID, spanId: SPAN_ID, traceFlags: 1, }), () => messageMetadata({ part: { type: "start" } }), ); expect(result).toEqual({ type: "start", traceId: TRACE_ID }); }); it("passes through start metadata when no span is active", () => { const value = { started: true }; const messageMetadata = withAssistantCloudTraceMetadata(() => value); expect(messageMetadata({ part: { type: "start" } })).toBe(value); expect(withAssistantCloudTraceMetadata()({ part: { type: "start" } })).toBe( undefined, ); }); it("passes through finish metadata with an active span", () => { const value = { usage: { totalTokens: 42 } }; const messageMetadata = withAssistantCloudTraceMetadata(() => value); const result = context.with( trace.setSpanContext(context.active(), { traceId: TRACE_ID, spanId: SPAN_ID, traceFlags: 1, }), () => messageMetadata({ part: { type: "finish" } }), ); expect(result).toBe(value); }); });