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:
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user