diff --git a/packages/agent/src/telemetry.ts b/packages/agent/src/telemetry.ts index dcbe30db8..04d3bd363 100644 --- a/packages/agent/src/telemetry.ts +++ b/packages/agent/src/telemetry.ts @@ -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 = { diff --git a/packages/agent/test/otel.test.ts b/packages/agent/test/otel.test.ts index 41619ea36..4971ed963 100644 --- a/packages/agent/test/otel.test.ts +++ b/packages/agent/test/otel.test.ts @@ -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: [] }; diff --git a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts index b1db72a5a..5647cdf87 100644 --- a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts +++ b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts @@ -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(); + }); +});