Files
oh-my-pi/packages/agent/test/otel.test.ts
T
can1357 e4e2389f83 refactor(agent): converted GenAI telemetry attributes to a const enum
- Replaced `GenAIAttr` with an `export const enum` in telemetry while preserving all GenAI attribute constants.
- Updated the OTEL stream test fixture to emit an `error` event with an `error` payload instead of a `done` event.
- Added runSubprocess telemetry propagation tests for inheriting parent telemetry and handling missing parent telemetry.
2026-05-15 14:46:54 +02:00

584 lines
22 KiB
TypeScript

/**
* Tests for OpenTelemetry instrumentation in the agent loop.
*
* Uses InMemorySpanExporter to capture spans synchronously and assert on
* span names, attributes, parent/child relationships, status codes, and
* lifecycle hook dispatch.
*/
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { agentLoop } from "@oh-my-pi/pi-agent-core/agent-loop";
import {
type AgentTelemetryConfig,
GenAIAttr,
GenAIOperation,
recordHandoff,
resolveTelemetry,
type TelemetryHookContext,
} from "@oh-my-pi/pi-agent-core/telemetry";
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core/types";
import type { Message, Model, UserMessage } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream, type EventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { context, SpanStatusCode, trace } from "@opentelemetry/api";
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
import {
BasicTracerProvider,
InMemorySpanExporter,
type ReadableSpan,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { Type } from "@sinclair/typebox";
import { createAssistantMessage } from "./helpers";
class MockAssistantStream extends AssistantMessageEventStream {}
const exporter = new InMemorySpanExporter();
let provider: BasicTracerProvider;
let contextManager: AsyncLocalStorageContextManager;
beforeAll(() => {
contextManager = new AsyncLocalStorageContextManager().enable();
context.setGlobalContextManager(contextManager);
provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
trace.setGlobalTracerProvider(provider);
});
afterEach(() => {
exporter.reset();
});
afterAll(async () => {
await provider.shutdown();
context.disable();
});
function createModel(): Model<"openai-responses"> {
return {
id: "mock-model",
name: "mock",
api: "openai-responses",
provider: "mock-provider",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
};
}
function createUserMessage(text: string): UserMessage {
return { role: "user", content: text, timestamp: Date.now() };
}
function identityConverter(messages: AgentMessage[]): Message[] {
return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
}
async function runAndDrain(stream: EventStream<AgentEvent, AgentMessage[]>): Promise<AgentEvent[]> {
const events: AgentEvent[] = [];
for await (const event of stream) events.push(event);
return events;
}
function findSpan(spans: ReadableSpan[], name: string): ReadableSpan | undefined {
return spans.find(s => s.name === name);
}
function spansByName(spans: ReadableSpan[], name: string): ReadableSpan[] {
return spans.filter(s => s.name === name);
}
describe("agent-loop OTEL instrumentation", () => {
it("emits no spans when telemetry is unset (zero-cost path)", async () => {
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => {
s.push({ type: "done", reason: "stop", message: createAssistantMessage([{ type: "text", text: "ok" }]) });
});
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
expect(exporter.getFinishedSpans()).toHaveLength(0);
});
it("emits invoke_agent → chat hierarchy with full gen_ai.* attribute envelope", async () => {
const finalMsg = createAssistantMessage([{ type: "text", text: "hello" }]);
finalMsg.usage = {
input: 12,
output: 34,
cacheRead: 5,
cacheWrite: 7,
totalTokens: 58,
reasoningTokens: 11,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
finalMsg.stopReason = "stop";
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
sessionId: "conv-42",
temperature: 0.7,
topP: 0.95,
maxTokens: 1024,
presencePenalty: 0.1,
telemetry: { agent: { id: "agent-1", name: "researcher", description: "test-agent" } },
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => s.push({ type: "done", reason: "stop", message: finalMsg }));
return s;
};
const ctx: AgentContext = { systemPrompt: ["you are helpful"], messages: [], tools: [] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const finished = exporter.getFinishedSpans();
const invoke = findSpan(finished, "invoke_agent researcher");
const chat = findSpan(finished, "chat mock-model");
expect(invoke).toBeDefined();
expect(chat).toBeDefined();
expect(chat?.parentSpanContext?.spanId).toBe(invoke?.spanContext().spanId);
// invoke_agent envelope
expect(invoke?.attributes[GenAIAttr.OperationName]).toBe(GenAIOperation.InvokeAgent);
expect(invoke?.attributes[GenAIAttr.AgentId]).toBe("agent-1");
expect(invoke?.attributes[GenAIAttr.AgentName]).toBe("researcher");
expect(invoke?.attributes[GenAIAttr.AgentDescription]).toBe("test-agent");
expect(invoke?.attributes[GenAIAttr.ConversationId]).toBe("conv-42");
expect(invoke?.attributes[GenAIAttr.AgentStepCount]).toBe(1);
// chat envelope
expect(chat?.attributes[GenAIAttr.OperationName]).toBe(GenAIOperation.Chat);
expect(chat?.attributes[GenAIAttr.System]).toBe("mock-provider");
expect(chat?.attributes[GenAIAttr.ProviderName]).toBe("mock-provider");
expect(chat?.attributes[GenAIAttr.RequestModel]).toBe("mock-model");
expect(chat?.attributes[GenAIAttr.RequestMaxTokens]).toBe(1024);
expect(chat?.attributes[GenAIAttr.RequestTemperature]).toBe(0.7);
expect(chat?.attributes[GenAIAttr.RequestTopP]).toBe(0.95);
expect(chat?.attributes[GenAIAttr.RequestPresencePenalty]).toBe(0.1);
expect(chat?.attributes[GenAIAttr.RequestChoiceCount]).toBe(1);
expect(chat?.attributes[GenAIAttr.AgentStepNumber]).toBe(0);
expect(chat?.attributes[GenAIAttr.OutputType]).toBe("text");
// chat response/usage
expect(chat?.attributes[GenAIAttr.ResponseModel]).toBe("mock");
expect(chat?.attributes[GenAIAttr.ResponseFinishReasons]).toEqual(["stop"]);
expect(chat?.attributes[GenAIAttr.UsageInputTokens]).toBe(12);
expect(chat?.attributes[GenAIAttr.UsageOutputTokens]).toBe(34);
expect(chat?.attributes[GenAIAttr.UsageTotalTokens]).toBe(58);
expect(chat?.attributes[GenAIAttr.UsageInputTokensCached]).toBe(5);
expect(chat?.attributes[GenAIAttr.UsageInputTokensCacheWrite]).toBe(7);
expect(chat?.attributes[GenAIAttr.UsageOutputTokensReasoning]).toBe(11);
});
it("emits execute_tool spans parented to invoke_agent (not chat) per semconv", async () => {
let callIndex = 0;
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: {},
};
const alphaSchema = Type.Object({ value: Type.String() });
const alphaTool: AgentTool<typeof alphaSchema> = {
name: "alpha",
label: "Alpha",
description: "echoes input",
parameters: alphaSchema,
execute: async () => ({ content: [{ type: "text", text: "alpha-result" }], details: {} }),
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const m = createAssistantMessage(
[{ type: "toolCall", id: "tc-1", name: "alpha", arguments: { value: "x" } }],
"toolUse",
);
s.push({ type: "done", reason: "toolUse", message: m });
} else {
s.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "done" }]),
});
}
callIndex++;
});
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [alphaTool] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const finished = exporter.getFinishedSpans();
const invoke = findSpan(finished, "invoke_agent");
const tool = findSpan(finished, "execute_tool alpha");
const chatSpans = spansByName(finished, "chat mock-model");
expect(invoke).toBeDefined();
expect(tool).toBeDefined();
expect(chatSpans).toHaveLength(2); // tool turn + follow-up
expect(tool?.parentSpanContext?.spanId).toBe(invoke?.spanContext().spanId);
expect(tool?.attributes[GenAIAttr.OperationName]).toBe(GenAIOperation.ExecuteTool);
expect(tool?.attributes[GenAIAttr.ToolName]).toBe("alpha");
expect(tool?.attributes[GenAIAttr.ToolCallId]).toBe("tc-1");
expect(tool?.attributes[GenAIAttr.ToolType]).toBe("function");
expect(tool?.attributes[GenAIAttr.ToolDescription]).toBe("echoes input");
expect(tool?.status.code).toBe(SpanStatusCode.UNSET);
// invoke_agent.step_count counts chat completions
expect(invoke?.attributes[GenAIAttr.AgentStepCount]).toBe(2);
});
it("parents downstream spans created during tool execution (active-context propagation)", async () => {
let callIndex = 0;
const userTracer = trace.getTracer("user-tool");
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: {},
};
const probeSchema = Type.Object({ value: Type.String() });
const probeTool: AgentTool<typeof probeSchema> = {
name: "probe",
label: "Probe",
description: "creates a child span during execute",
parameters: probeSchema,
execute: async () => {
const inner = userTracer.startSpan("user-work-inside-tool");
inner.end();
return { content: [{ type: "text", text: "ok" }], details: {} };
},
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const m = createAssistantMessage(
[{ type: "toolCall", id: "tc-1", name: "probe", arguments: { value: "x" } }],
"toolUse",
);
s.push({ type: "done", reason: "toolUse", message: m });
} else {
s.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "done" }]),
});
}
callIndex++;
});
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [probeTool] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const finished = exporter.getFinishedSpans();
const tool = findSpan(finished, "execute_tool probe");
const userInner = findSpan(finished, "user-work-inside-tool");
expect(tool).toBeDefined();
expect(userInner).toBeDefined();
expect(userInner?.parentSpanContext?.spanId).toBe(tool?.spanContext().spanId);
});
it("records ERROR status + exception when a tool throws", async () => {
let callIndex = 0;
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: {},
};
const failSchema = Type.Object({ value: Type.String() });
const failTool: AgentTool<typeof failSchema> = {
name: "fail",
label: "Fail",
description: "throws",
parameters: failSchema,
execute: async () => {
throw new Error("boom");
},
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const m = createAssistantMessage(
[{ type: "toolCall", id: "tc-1", name: "fail", arguments: { value: "x" } }],
"toolUse",
);
s.push({ type: "done", reason: "toolUse", message: m });
} else {
s.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "done" }]),
});
}
callIndex++;
});
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [failTool] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const tool = findSpan(exporter.getFinishedSpans(), "execute_tool fail");
expect(tool).toBeDefined();
expect(tool?.status.code).toBe(SpanStatusCode.ERROR);
expect(tool?.attributes[GenAIAttr.ErrorType]).toBe("Error");
expect(tool?.events.some(e => e.name === "exception")).toBe(true);
});
it("emits ERROR status on chat spans when stopReason is error", async () => {
const errMsg = createAssistantMessage([{ type: "text", text: "" }], "error");
errMsg.errorMessage = "provider returned 500";
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: {},
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => s.push({ type: "error", reason: "error", error: errMsg }));
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const chat = findSpan(exporter.getFinishedSpans(), "chat mock-model");
expect(chat).toBeDefined();
expect(chat?.status.code).toBe(SpanStatusCode.ERROR);
expect(chat?.attributes[GenAIAttr.ErrorType]).toBe("error");
expect(chat?.attributes[GenAIAttr.ResponseFinishReasons]).toEqual(["error"]);
});
it("captures request/response content when captureMessageContent is true", async () => {
const finalMsg = createAssistantMessage([{ type: "text", text: "hi back" }]);
finalMsg.stopReason = "stop";
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: { captureMessageContent: true },
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => s.push({ type: "done", reason: "stop", message: finalMsg }));
return s;
};
const ctx: AgentContext = { systemPrompt: ["sys-instruction"], messages: [], tools: [] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const chat = findSpan(exporter.getFinishedSpans(), "chat mock-model");
const inputs = chat?.attributes[GenAIAttr.InputMessages] as string | undefined;
const outputs = chat?.attributes[GenAIAttr.OutputMessages] as string | undefined;
const systemAttr = chat?.attributes[GenAIAttr.SystemInstructions] as string | undefined;
expect(typeof inputs).toBe("string");
expect(JSON.parse(inputs!)).toHaveLength(1);
expect(typeof outputs).toBe("string");
expect(JSON.parse(outputs!)[0].content[0].text).toBe("hi back");
expect(JSON.parse(systemAttr!)).toEqual(["sys-instruction"]);
});
it("invokes costEstimator and stamps gen_ai.cost.estimated_usd", async () => {
const finalMsg = createAssistantMessage([{ type: "text", text: "ok" }]);
finalMsg.usage = {
input: 1000,
output: 500,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 1500,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
finalMsg.stopReason = "stop";
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: {
costEstimator: input => ({
usd: (input.usage.inputTokens / 1_000_000) * 3 + (input.usage.outputTokens / 1_000_000) * 15,
inputUsd: (input.usage.inputTokens / 1_000_000) * 3,
outputUsd: (input.usage.outputTokens / 1_000_000) * 15,
}),
},
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => s.push({ type: "done", reason: "stop", message: finalMsg }));
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const chat = findSpan(exporter.getFinishedSpans(), "chat mock-model");
expect(chat?.attributes[GenAIAttr.CostEstimatedUsd]).toBeCloseTo(0.0105, 6);
expect(chat?.attributes[GenAIAttr.CostInputUsd]).toBeCloseTo(0.003, 6);
expect(chat?.attributes[GenAIAttr.CostOutputUsd]).toBeCloseTo(0.0075, 6);
});
it("emits gen_ai.cost.unavailable_reason when the estimator declines", async () => {
const finalMsg = createAssistantMessage([{ type: "text", text: "ok" }]);
finalMsg.stopReason = "stop";
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: { costEstimator: () => ({ unavailable: "unsupported_tier" }) },
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => s.push({ type: "done", reason: "stop", message: finalMsg }));
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const chat = findSpan(exporter.getFinishedSpans(), "chat mock-model");
expect(chat?.attributes[GenAIAttr.CostUnavailableReason]).toBe("unsupported_tier");
expect(chat?.attributes[GenAIAttr.CostEstimatedUsd]).toBeUndefined();
});
it("fires onSpanStart and onSpanEnd for every kind", async () => {
let callIndex = 0;
const starts: TelemetryHookContext[] = [];
const ends: TelemetryHookContext[] = [];
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: {
agent: { id: "a", name: "main" },
onSpanStart: ctx => starts.push(ctx),
onSpanEnd: ctx => ends.push(ctx),
},
};
const echoSchema = Type.Object({ value: Type.String() });
const echoTool: AgentTool<typeof echoSchema> = {
name: "echo",
label: "Echo",
description: "echo",
parameters: echoSchema,
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
const m = createAssistantMessage(
[{ type: "toolCall", id: "tc-1", name: "echo", arguments: { value: "x" } }],
"toolUse",
);
s.push({ type: "done", reason: "toolUse", message: m });
} else {
s.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "done" }]),
});
}
callIndex++;
});
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [echoTool] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
const startKinds = starts.map(s => s.kind);
const endKinds = ends.map(s => s.kind);
expect(startKinds).toEqual(["invoke_agent", "chat", "execute_tool", "chat"]);
expect(endKinds).toEqual(["chat", "execute_tool", "chat", "invoke_agent"]);
expect(starts.find(s => s.kind === "execute_tool")?.toolName).toBe("echo");
expect(starts.find(s => s.kind === "execute_tool")?.toolCallId).toBe("tc-1");
});
it("recordHandoff emits a one-shot handoff span with from/to agent identity", async () => {
const telemetry = resolveTelemetry({}, "conv-1");
expect(telemetry).toBeDefined();
recordHandoff(telemetry, {
fromAgent: { id: "a", name: "main" },
toAgent: { id: "b", name: "specialist" },
});
const span = findSpan(exporter.getFinishedSpans(), "handoff main → specialist");
expect(span).toBeDefined();
expect(span?.attributes[GenAIAttr.OperationName]).toBe(GenAIOperation.Handoff);
expect(span?.attributes["gen_ai.handoff.from_agent.name"]).toBe("main");
expect(span?.attributes["gen_ai.handoff.to_agent.name"]).toBe("specialist");
expect(span?.attributes[GenAIAttr.ConversationId]).toBe("conv-1");
});
it("reads OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT once at first resolveTelemetry call", () => {
// The env var is parsed once and cached for the lifetime of the process so
// every span pays the same lookup cost. Once an earlier test has hit
// `resolveTelemetry`, the cache is already primed; covering this contract
// requires only that the explicit `captureMessageContent` field on the
// config still wins over the cached env value.
const before = process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT;
try {
process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "true";
const overridden = resolveTelemetry({ captureMessageContent: false }, undefined);
expect(overridden?.captureMessageContent).toBe(false);
const enabled = resolveTelemetry({ captureMessageContent: true }, undefined);
expect(enabled?.captureMessageContent).toBe(true);
} finally {
if (before === undefined) delete process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT;
else process.env.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = before;
}
});
it("attaches user-supplied attributes to every span", async () => {
let callIndex = 0;
const cfg: AgentTelemetryConfig = {
attributes: { "deployment.environment": "prod", "service.name": "test-svc" },
};
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
telemetry: cfg,
};
const echoSchema = Type.Object({ value: Type.String() });
const tool: AgentTool<typeof echoSchema> = {
name: "echo",
label: "Echo",
description: "",
parameters: echoSchema,
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => {
if (callIndex === 0) {
s.push({
type: "done",
reason: "toolUse",
message: createAssistantMessage(
[{ type: "toolCall", id: "tc-1", name: "echo", arguments: { value: "x" } }],
"toolUse",
),
});
} else {
s.push({
type: "done",
reason: "stop",
message: createAssistantMessage([{ type: "text", text: "done" }]),
});
}
callIndex++;
});
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [tool] };
await runAndDrain(agentLoop([createUserMessage("hi")], ctx, config, undefined, streamFn));
for (const span of exporter.getFinishedSpans()) {
expect(span.attributes["deployment.environment"]).toBe("prod");
expect(span.attributes["service.name"]).toBe("test-svc");
}
});
});