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.
This commit is contained in:
can1357
2026-05-15 11:45:34 +02:00
parent b642607ea9
commit e4e2389f83
3 changed files with 139 additions and 52 deletions
+51 -51
View File
@@ -47,66 +47,66 @@ const CONTENT_CAPTURE_ENV = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
* GenAI semantic-convention attribute keys grouped by operation. Hoisted so
* call sites stay typo-proof and easy to grep.
*/
export const GenAIAttr = {
export const enum GenAIAttr {
// Common identifiers
System: "gen_ai.system",
ProviderName: "gen_ai.provider.name",
OperationName: "gen_ai.operation.name",
ConversationId: "gen_ai.conversation.id",
OutputType: "gen_ai.output.type",
System = "gen_ai.system",
ProviderName = "gen_ai.provider.name",
OperationName = "gen_ai.operation.name",
ConversationId = "gen_ai.conversation.id",
OutputType = "gen_ai.output.type",
// Agent identity
AgentId: "gen_ai.agent.id",
AgentName: "gen_ai.agent.name",
AgentDescription: "gen_ai.agent.description",
AgentStepNumber: "gen_ai.agent.step.number",
AgentStepCount: "gen_ai.agent.step.count",
AgentId = "gen_ai.agent.id",
AgentName = "gen_ai.agent.name",
AgentDescription = "gen_ai.agent.description",
AgentStepNumber = "gen_ai.agent.step.number",
AgentStepCount = "gen_ai.agent.step.count",
// Request shape
RequestModel: "gen_ai.request.model",
RequestMaxTokens: "gen_ai.request.max_tokens",
RequestTemperature: "gen_ai.request.temperature",
RequestTopP: "gen_ai.request.top_p",
RequestTopK: "gen_ai.request.top_k",
RequestFrequencyPenalty: "gen_ai.request.frequency_penalty",
RequestPresencePenalty: "gen_ai.request.presence_penalty",
RequestStopSequences: "gen_ai.request.stop_sequences",
RequestSeed: "gen_ai.request.seed",
RequestChoiceCount: "gen_ai.request.choice.count",
RequestServiceTier: "gen_ai.request.service_tier",
RequestReasoningEffort: "gen_ai.request.reasoning.effort",
RequestToolChoice: "gen_ai.request.tool.choice",
RequestAvailableTools: "gen_ai.request.available_tools",
RequestModel = "gen_ai.request.model",
RequestMaxTokens = "gen_ai.request.max_tokens",
RequestTemperature = "gen_ai.request.temperature",
RequestTopP = "gen_ai.request.top_p",
RequestTopK = "gen_ai.request.top_k",
RequestFrequencyPenalty = "gen_ai.request.frequency_penalty",
RequestPresencePenalty = "gen_ai.request.presence_penalty",
RequestStopSequences = "gen_ai.request.stop_sequences",
RequestSeed = "gen_ai.request.seed",
RequestChoiceCount = "gen_ai.request.choice.count",
RequestServiceTier = "gen_ai.request.service_tier",
RequestReasoningEffort = "gen_ai.request.reasoning.effort",
RequestToolChoice = "gen_ai.request.tool.choice",
RequestAvailableTools = "gen_ai.request.available_tools",
// Response shape
ResponseModel: "gen_ai.response.model",
ResponseId: "gen_ai.response.id",
ResponseFinishReasons: "gen_ai.response.finish_reasons",
ResponseServiceTier: "gen_ai.response.service_tier",
ResponseModel = "gen_ai.response.model",
ResponseId = "gen_ai.response.id",
ResponseFinishReasons = "gen_ai.response.finish_reasons",
ResponseServiceTier = "gen_ai.response.service_tier",
// Usage
UsageInputTokens: "gen_ai.usage.input_tokens",
UsageOutputTokens: "gen_ai.usage.output_tokens",
UsageInputTokensCached: "gen_ai.usage.input_tokens.cached",
UsageInputTokensCacheWrite: "gen_ai.usage.input_tokens.cache_write",
UsageOutputTokensReasoning: "gen_ai.usage.output_tokens.reasoning",
UsageTotalTokens: "gen_ai.usage.total_tokens",
UsageServerSideTools: "gen_ai.usage.server_tool_requests",
UsageInputTokens = "gen_ai.usage.input_tokens",
UsageOutputTokens = "gen_ai.usage.output_tokens",
UsageInputTokensCached = "gen_ai.usage.input_tokens.cached",
UsageInputTokensCacheWrite = "gen_ai.usage.input_tokens.cache_write",
UsageOutputTokensReasoning = "gen_ai.usage.output_tokens.reasoning",
UsageTotalTokens = "gen_ai.usage.total_tokens",
UsageServerSideTools = "gen_ai.usage.server_tool_requests",
// Tools
ToolCallId: "gen_ai.tool.call.id",
ToolName: "gen_ai.tool.name",
ToolDescription: "gen_ai.tool.description",
ToolType: "gen_ai.tool.type",
ToolCallArguments: "gen_ai.tool.call.arguments",
ToolCallResult: "gen_ai.tool.call.result",
ToolCallId = "gen_ai.tool.call.id",
ToolName = "gen_ai.tool.name",
ToolDescription = "gen_ai.tool.description",
ToolType = "gen_ai.tool.type",
ToolCallArguments = "gen_ai.tool.call.arguments",
ToolCallResult = "gen_ai.tool.call.result",
// Content capture (opt-in)
InputMessages: "gen_ai.input.messages",
OutputMessages: "gen_ai.output.messages",
SystemInstructions: "gen_ai.system_instructions",
InputMessages = "gen_ai.input.messages",
OutputMessages = "gen_ai.output.messages",
SystemInstructions = "gen_ai.system_instructions",
// Cost (vendor extension; matches Sentry-AI + Langfuse conventions)
CostEstimatedUsd: "gen_ai.cost.estimated_usd",
CostInputUsd: "gen_ai.cost.input_usd",
CostOutputUsd: "gen_ai.cost.output_usd",
CostUnavailableReason: "gen_ai.cost.unavailable_reason",
CostEstimatedUsd = "gen_ai.cost.estimated_usd",
CostInputUsd = "gen_ai.cost.input_usd",
CostOutputUsd = "gen_ai.cost.output_usd",
CostUnavailableReason = "gen_ai.cost.unavailable_reason",
// Errors
ErrorType: "error.type",
} as const;
ErrorType = "error.type",
}
/** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
export const GenAIOperation = {
+1 -1
View File
@@ -344,7 +344,7 @@ describe("agent-loop OTEL instrumentation", () => {
};
const streamFn = () => {
const s = new MockAssistantStream();
queueMicrotask(() => s.push({ type: "done", reason: "error", message: errMsg }));
queueMicrotask(() => s.push({ type: "error", reason: "error", error: errMsg }));
return s;
};
const ctx: AgentContext = { systemPrompt: [], messages: [], tools: [] };
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import type { AgentTelemetryConfig, Tracer } from "@oh-my-pi/pi-agent-core";
import { type AssistantMessage, Effort } from "@oh-my-pi/pi-ai";
import { Settings } from "../../src/config/settings";
import type { LoadExtensionsResult } from "../../src/extensibility/extensions/types";
@@ -466,3 +467,89 @@ describe("runSubprocess yield reminders", () => {
expect(createAgentSessionSpy).not.toHaveBeenCalled();
});
});
describe("runSubprocess telemetry propagation", () => {
afterEach(() => {
vi.restoreAllMocks();
});
const baseAgent: AgentDefinition = {
name: "reviewer",
description: "code review specialist",
systemPrompt: "you are a reviewer",
source: "bundled",
};
const baseOptions = {
cwd: "/tmp",
agent: baseAgent,
task: "do work",
index: 0,
id: "subagent-telemetry",
settings: Settings.isolated(),
modelRegistry: { refresh: async () => {} } as unknown as import("../../src/config/model-registry").ModelRegistry,
enableLsp: false,
};
function buildSession() {
return createMockSession(({ emit }) => {
emit({
type: "tool_execution_end",
toolCallId: "tool-telemetry",
toolName: "yield",
result: {
content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } },
},
isError: false,
});
});
}
it("derives subagent telemetry from parent: keeps tracer/hooks, swaps agent identity, clears conversationId", async () => {
const createAgentSessionSpy = mockCreateAgentSession(buildSession());
const onSpanStart = () => {};
const onSpanEnd = () => {};
const costEstimator = () => undefined;
const tracer = { startSpan: () => undefined } as unknown as Tracer;
const parentTelemetry: AgentTelemetryConfig = {
tracer,
captureMessageContent: true,
attributes: { "deployment.id": "prod" },
agent: { id: "0-Main", name: "main", description: "primary agent" },
conversationId: "parent-conversation",
onSpanStart,
onSpanEnd,
costEstimator,
};
await runSubprocess({ ...baseOptions, id: "subagent-telemetry-derive", parentTelemetry });
expect(createAgentSessionSpy).toHaveBeenCalledTimes(1);
const forwarded = createAgentSessionSpy.mock.calls[0]?.[0]?.telemetry;
expect(forwarded).toBeDefined();
if (!forwarded) throw new Error("expected telemetry on createAgentSession call");
expect(forwarded.tracer).toBe(tracer);
expect(forwarded.captureMessageContent).toBe(true);
expect(forwarded.attributes).toEqual({ "deployment.id": "prod" });
expect(forwarded.onSpanStart).toBe(onSpanStart);
expect(forwarded.onSpanEnd).toBe(onSpanEnd);
expect(forwarded.costEstimator).toBe(costEstimator);
expect(forwarded.agent).toEqual({
id: "subagent-telemetry-derive",
name: baseAgent.name,
description: baseAgent.description,
});
// Child loop falls back to its own session id for gen_ai.conversation.id.
expect(forwarded.conversationId).toBeUndefined();
});
it("forwards no telemetry when the parent has none", async () => {
const createAgentSessionSpy = mockCreateAgentSession(buildSession());
await runSubprocess({ ...baseOptions, id: "subagent-telemetry-none" });
expect(createAgentSessionSpy).toHaveBeenCalledTimes(1);
expect(createAgentSessionSpy.mock.calls[0]?.[0]?.telemetry).toBeUndefined();
});
});