assistant-cloud
Version:
Cloud integration for assistant-ui
262 lines (222 loc) • 7.11 kB
text/typescript
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<A extends unknown[], F extends (...args: A) => ReturnType<F>>(
nextContext: Context,
fn: F,
thisArg?: ThisParameterType<F>,
...args: A
): ReturnType<F> {
const previousContext = this.activeContext;
this.activeContext = nextContext;
try {
return fn.call(thisArg, ...args);
} finally {
this.activeContext = previousContext;
}
}
bind<T>(_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<string, unknown> = {},
): 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<SpanExporter["export"]>[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);
});
});