diff --git a/src/handlers/activity.ts b/src/handlers/activity.ts index e9266d4..5ad9498 100644 --- a/src/handlers/activity.ts +++ b/src/handlers/activity.ts @@ -1,6 +1,6 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import type { EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk" -import { isMetricEnabled, setBoundedMap } from "../util.ts" +import { agentAttrs, getSessionAgentMeta, isMetricEnabled, setBoundedMap } from "../util.ts" import type { HandlerContext } from "../types.ts" /** @@ -69,6 +69,7 @@ export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerConte if (e.properties.name !== "bash") return ctx.log("debug", "otel: command.executed (bash)", { sessionID: e.properties.sessionID, argumentsLength: e.properties.arguments.length }) if (!GIT_COMMIT_RE.test(e.properties.arguments)) return + const { agentName, agentType } = getSessionAgentMeta(e.properties.sessionID, ctx) if (isMetricEnabled("commit.count", ctx)) { ctx.instruments.commitCounter.add(1, { @@ -86,6 +87,7 @@ export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerConte attributes: { "event.name": "commit", "session.id": e.properties.sessionID, + ...agentAttrs(agentName, agentType), ...ctx.commonAttrs, }, }) diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 2241ee1..f6dce25 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -27,7 +27,7 @@ import { TOOL_NAME, TOOL_PARAMETERS, } from "@arizeai/openinference-semantic-conventions" -import { errorSummary, setBoundedMap, accumulateSessionTotals, isMetricEnabled, isTraceEnabled } from "../util.ts" +import { agentAttrs, errorSummary, setBoundedMap, accumulateSessionTotals, getSessionAgentMeta, isMetricEnabled, isTraceEnabled } from "../util.ts" import type { HandlerContext } from "../types.ts" const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND @@ -56,7 +56,8 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext const { sessionID, modelID, providerID } = assistant const duration = assistant.time.completed - assistant.time.created - const agent = ctx.sessionTotals.get(sessionID)?.agent ?? "unknown" + const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) + const agent = agentName const totalTokens = assistant.tokens.input + assistant.tokens.output + assistant.tokens.reasoning + assistant.tokens.cache.read + assistant.tokens.cache.write @@ -110,6 +111,8 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext if (msgSpan) { const outputText = ctx.messageOutputs.get(msgKey) msgSpan.setAttributes({ + [AGENT_NAME]: agentName, + "agent.type": agentType, [LLM_TOKEN_COUNT_PROMPT]: assistant.tokens.input, [LLM_TOKEN_COUNT_COMPLETION]: assistant.tokens.output, [LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]: assistant.tokens.reasoning, @@ -150,7 +153,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext "session.id": sessionID, model: modelID, provider: providerID, - agent, + ...agentAttrs(agentName, agentType), error: errorSummary(assistant.error), duration_ms: duration, ...ctx.commonAttrs, @@ -173,12 +176,12 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext body: "api_request", attributes: { "event.name": "api_request", - "session.id": sessionID, - model: modelID, - provider: providerID, - agent, - cost_usd: assistant.cost, - duration_ms: duration, + "session.id": sessionID, + model: modelID, + provider: providerID, + ...agentAttrs(agentName, agentType), + cost_usd: assistant.cost, + duration_ms: duration, input_tokens: assistant.tokens.input, output_tokens: assistant.tokens.output, reasoning_tokens: assistant.tokens.reasoning, @@ -224,6 +227,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle ...ctx.commonAttrs, "session.id": subtask.sessionID, agent: subtask.agent, + "agent.type": "subagent", }) } ctx.emitLog({ @@ -235,7 +239,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle attributes: { "event.name": "subtask_invoked", "session.id": subtask.sessionID, - agent: subtask.agent, + ...agentAttrs(subtask.agent, "subagent"), description: subtask.description, prompt_length: subtask.prompt.length, ...ctx.commonAttrs, @@ -253,6 +257,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle const key = `${toolPart.sessionID}:${toolPart.callID}` if (toolPart.state.status === "running") { + const { agentName, agentType } = getSessionAgentMeta(toolPart.sessionID, ctx) const toolSpan = isTraceEnabled("tool", ctx) ? (() => { const sessionSpan = ctx.sessionSpans.get(toolPart.sessionID) @@ -273,6 +278,8 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle [TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input), [INPUT_VALUE]: JSON.stringify(toolPart.state.input), [INPUT_MIME_TYPE]: MimeType.JSON, + [AGENT_NAME]: agentName, + "agent.type": agentType, ...ctx.commonAttrs, }, }, @@ -299,6 +306,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle if (end === undefined) return const duration_ms = end - start const success = toolPart.state.status === "completed" + const { agentName, agentType } = getSessionAgentMeta(toolPart.sessionID, ctx) if (isMetricEnabled("tool.duration", ctx)) { ctx.instruments.toolDurationHistogram.record(duration_ms, { @@ -335,6 +343,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle parentCtx, ) })() + toolSpan.setAttributes({ [AGENT_NAME]: agentName, "agent.type": agentType }) toolSpan.setAttribute("tool.success", success) if (success) { const output = (toolPart.state as { output: string }).output @@ -370,6 +379,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle "event.name": "tool_result", "session.id": toolPart.sessionID, tool_name: toolPart.tool, + ...agentAttrs(agentName, agentType), success, duration_ms, ...sizeAttr, @@ -409,6 +419,7 @@ export function startMessageSpan( if (!isTraceEnabled("llm", ctx)) return const msgKey = `${sessionID}:${messageID}` if (ctx.messageSpans.has(msgKey)) return + const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) const sessionSpan = ctx.sessionSpans.get(sessionID) const baseCtx = ctx.rootContext() const parentCtx = sessionSpan @@ -423,7 +434,8 @@ export function startMessageSpan( attributes: { [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM, [SESSION_ID]: sessionID, - [AGENT_NAME]: ctx.sessionTotals.get(sessionID)?.agent ?? "unknown", + [AGENT_NAME]: agentName, + "agent.type": agentType, [LLM_SYSTEM]: providerID, [LLM_PROVIDER]: providerID, [LLM_MODEL_NAME]: modelID, diff --git a/src/handlers/permission.ts b/src/handlers/permission.ts index bf95476..74d9834 100644 --- a/src/handlers/permission.ts +++ b/src/handlers/permission.ts @@ -1,6 +1,6 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import type { EventPermissionUpdated, EventPermissionReplied } from "@opencode-ai/sdk" -import { setBoundedMap } from "../util.ts" +import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "../util.ts" import type { HandlerContext } from "../types.ts" /** Stores a pending permission prompt in the context map for later correlation with its reply. */ @@ -20,6 +20,7 @@ export function handlePermissionReplied(e: EventPermissionReplied, ctx: HandlerC const pending = ctx.pendingPermissions.get(permissionID) ctx.pendingPermissions.delete(permissionID) const decision = response === "allow" || response === "allowAlways" ? "accept" : "reject" + const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) ctx.log("debug", "otel: tool_decision emitted", { permissionID, sessionID, decision, source: response, tool_name: pending?.title ?? "unknown" }) ctx.emitLog({ severityNumber: SeverityNumber.INFO, @@ -34,6 +35,7 @@ export function handlePermissionReplied(e: EventPermissionReplied, ctx: HandlerC tool_type: pending?.type ?? "unknown", decision, source: response, + ...agentAttrs(agentName, agentType), ...ctx.commonAttrs, }, }) diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 849097e..f6bdb9b 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -2,8 +2,8 @@ import { SeverityNumber } from "@opentelemetry/api-logs" import { SpanStatusCode, trace } from "@opentelemetry/api" import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk" import { AGENT_NAME, OpenInferenceSpanKind, SemanticConventions, SESSION_ID } from "@arizeai/openinference-semantic-conventions" -import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../util.ts" -import type { HandlerContext } from "../types.ts" +import { agentAttrs, errorSummary, getSessionAgentMeta, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../util.ts" +import type { HandlerContext, SessionAgentType } from "../types.ts" const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND @@ -12,10 +12,11 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext const { id: sessionID, time, parentID } = e.properties.info const createdAt = time.created const isSubagent = !!parentID + const agentType: SessionAgentType = isSubagent ? "subagent" : "primary" if (isMetricEnabled("session.count", ctx)) { ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, is_subagent: isSubagent }) } - setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown" }) + setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown", agentType }) // WARNING: disabling "session" traces while "llm" or "tool" traces remain enabled // leaves those child spans without a local session parent. If OPENCODE_TRACEPARENT @@ -35,6 +36,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT, [SESSION_ID]: sessionID, [AGENT_NAME]: "unknown", + "agent.type": agentType, "session.is_subagent": isSubagent, ...ctx.commonAttrs, }, @@ -50,7 +52,13 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext timestamp: createdAt, observedTimestamp: Date.now(), body: "session.created", - attributes: { "event.name": "session.created", "session.id": sessionID, is_subagent: isSubagent, ...ctx.commonAttrs }, + attributes: { + "event.name": "session.created", + "session.id": sessionID, + is_subagent: isSubagent, + ...agentAttrs("unknown", agentType), + ...ctx.commonAttrs, + }, }) return ctx.log("info", "otel: session.created", { sessionID, createdAt, isSubagent }) } @@ -84,6 +92,7 @@ function sweepSession(sessionID: string, ctx: HandlerContext) { export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { const sessionID = e.properties.sessionID const totals = ctx.sessionTotals.get(sessionID) + const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx) ctx.sessionTotals.delete(sessionID) ctx.sessionDiffTotals.delete(sessionID) sweepSession(sessionID, ctx) @@ -109,6 +118,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { if (totals) { sessionSpan.setAttributes({ [AGENT_NAME]: totals.agent, + "agent.type": totals.agentType, "session.total_tokens": totals.tokens, "session.total_cost_usd": totals.cost, "session.total_messages": totals.messages, @@ -131,6 +141,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) { total_tokens: totals?.tokens ?? 0, total_cost_usd: totals?.cost ?? 0, total_messages: totals?.messages ?? 0, + ...agentAttrs(agentName, agentType), ...ctx.commonAttrs, }, }) @@ -145,6 +156,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) { const rawID = e.properties.sessionID const sessionID = rawID ?? "unknown" const error = errorSummary(e.properties.error) + const { agentName, agentType } = rawID ? getSessionAgentMeta(rawID, ctx) : { agentName: "unknown", agentType: "unknown" as const } const totals = rawID ? ctx.sessionTotals.get(rawID) : undefined if (rawID) { ctx.sessionTotals.delete(rawID) @@ -155,7 +167,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) { if (rawID) { const sessionSpan = ctx.sessionSpans.get(rawID) if (sessionSpan) { - if (totals) sessionSpan.setAttribute(AGENT_NAME, totals.agent) + if (totals) sessionSpan.setAttributes({ [AGENT_NAME]: totals.agent, "agent.type": totals.agentType }) sessionSpan.setStatus({ code: SpanStatusCode.ERROR, message: error }) sessionSpan.setAttribute("error", error) sessionSpan.end() @@ -173,6 +185,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) { "event.name": "session.error", "session.id": sessionID, error, + ...agentAttrs(agentName, agentType), ...ctx.commonAttrs, }, }) diff --git a/src/index.ts b/src/index.ts index 1116872..87fafd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSess import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts" import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts" import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts" +import { agentAttrs, getSessionAgentMeta } from "./util.ts" const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown" @@ -182,10 +183,11 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree "chat.message": safe("chat.message", async (input, output) => { const agent = input.agent ?? "unknown" + const { agentType } = getSessionAgentMeta(input.sessionID, ctx) const totals = sessionTotals.get(input.sessionID) if (totals) totals.agent = agent const sessionSpan = sessionSpans.get(input.sessionID) - if (sessionSpan) sessionSpan.setAttribute(AGENT_NAME, agent) + if (sessionSpan) sessionSpan.setAttributes({ [AGENT_NAME]: agent, "agent.type": agentType }) const promptText = output.parts.map((part) => { switch (part.type) { case "text": @@ -211,7 +213,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree attributes: { "event.name": "user_prompt", "session.id": input.sessionID, - agent, + ...agentAttrs(agent, agentType), prompt_length: promptLength, model: input.model ? `${input.model.providerID}/${input.model.modelID}` diff --git a/src/types.ts b/src/types.ts index 586ae1f..df7d66c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,6 +54,9 @@ export type Instruments = { subtaskCounter: Counter } +/** Session role emitted by opencode: either the primary/root agent or a spawned subagent. */ +export type SessionAgentType = "primary" | "subagent" + /** Accumulated per-session totals used for gauge snapshots on session.idle. */ export type SessionTotals = { startMs: number @@ -61,6 +64,7 @@ export type SessionTotals = { cost: number messages: number agent: string + agentType: SessionAgentType } /** Shared context threaded through every event handler. */ diff --git a/src/util.ts b/src/util.ts index 32548bc..f79fe97 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,5 @@ import { MAX_PENDING } from "./types.ts" -import type { HandlerContext } from "./types.ts" +import type { HandlerContext, SessionAgentType } from "./types.ts" /** Returns a human-readable summary string from an opencode error object. */ export function errorSummary(err: { name: string; data?: unknown } | undefined): string { @@ -57,5 +57,27 @@ export function accumulateSessionTotals( cost: existing.cost + cost, messages: existing.messages + 1, agent: existing.agent, + agentType: existing.agentType, }) } + +/** Returns the current session-scoped agent name/type, defaulting to `unknown` when unavailable. */ +export function getSessionAgentMeta( + sessionID: string, + ctx: Pick, +): { agentName: string; agentType: SessionAgentType | "unknown" } { + const totals = ctx.sessionTotals.get(sessionID) + return { + agentName: totals?.agent ?? "unknown", + agentType: totals?.agentType ?? "unknown", + } +} + +/** Builds a consistent agent attribute set for OTLP logs, metrics, and spans. */ +export function agentAttrs(agentName: string, agentType: SessionAgentType | "unknown") { + return { + agent: agentName, + "agent.name": agentName, + "agent.type": agentType, + } as const +} diff --git a/tests/handlers/activity.test.ts b/tests/handlers/activity.test.ts index b53dc4f..077fce3 100644 --- a/tests/handlers/activity.test.ts +++ b/tests/handlers/activity.test.ts @@ -143,10 +143,13 @@ describe("handleCommandExecuted", () => { test("emits commit log record", () => { const { ctx, logger } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) handleCommandExecuted(makeCommandExecuted("bash", "git commit -m 'fix: bug'"), ctx) expect(logger.records).toHaveLength(1) expect(logger.records.at(0)!.body).toBe("commit") expect(logger.records.at(0)!.attributes?.["session.id"]).toBe("ses_1") + expect(logger.records.at(0)!.attributes?.["agent.name"]).toBe("build") + expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") }) test("ignores non-bash commands", () => { diff --git a/tests/handlers/message.test.ts b/tests/handlers/message.test.ts index b09396e..f5afb39 100644 --- a/tests/handlers/message.test.ts +++ b/tests/handlers/message.test.ts @@ -195,7 +195,7 @@ describe("handleMessageUpdated", () => { test("accumulates session totals including cache tokens", async () => { const { ctx } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) await handleMessageUpdated( makeAssistantMessageUpdated({ sessionID: "ses_1", @@ -276,12 +276,15 @@ describe("handleMessagePartUpdated", () => { test("emits tool_result log on success with exact byte length", async () => { const { ctx, logger } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) await handleMessagePartUpdated(makeToolPartUpdated("running"), ctx) await handleMessagePartUpdated(makeToolPartUpdated("completed"), ctx) const record = logger.records.at(0)! expect(record.body).toBe("tool_result") expect(record.attributes?.["success"]).toBe(true) expect(record.attributes?.["tool_result_size_bytes"]).toBe(Buffer.byteLength("result output", "utf8")) + expect(record.attributes?.["agent.name"]).toBe("build") + expect(record.attributes?.["agent.type"]).toBe("primary") }) test("emits error-severity log on tool error", async () => { @@ -325,7 +328,7 @@ describe("handleMessagePartUpdated", () => { describe("handleMessageUpdated — agent attribute", () => { test("includes agent attr on token counters from session totals", async () => { const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan", agentType: "primary" }) await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) const inputCall = counters.token.calls.find((c) => c.attrs["type"] === "input")! expect(inputCall.attrs["agent"]).toBe("plan") @@ -333,28 +336,28 @@ describe("handleMessageUpdated — agent attribute", () => { test("includes agent attr on cost counter", async () => { const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) expect(counters.cost.calls.at(0)!.attrs["agent"]).toBe("build") }) test("includes agent attr on message counter", async () => { const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "general" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "general", agentType: "primary" }) await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) expect(counters.message.calls.at(0)!.attrs["agent"]).toBe("general") }) test("includes agent attr on model usage counter", async () => { const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review", agentType: "primary" }) await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) expect(counters.modelUsage.calls.at(0)!.attrs["agent"]).toBe("review") }) test("includes agent attr on cache counters", async () => { const { ctx, counters } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "tdd" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "tdd", agentType: "primary" }) await handleMessageUpdated( makeAssistantMessageUpdated({ sessionID: "ses_1", tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 10, write: 5 } } }), ctx, @@ -371,19 +374,22 @@ describe("handleMessageUpdated — agent attribute", () => { test("includes agent on api_request log record", async () => { const { ctx, logger } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "plan", agentType: "primary" }) await handleMessageUpdated(makeAssistantMessageUpdated({ sessionID: "ses_1" }), ctx) expect(logger.records.at(0)!.attributes?.["agent"]).toBe("plan") + expect(logger.records.at(0)!.attributes?.["agent.name"]).toBe("plan") + expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") }) test("includes agent on api_error log record", async () => { const { ctx, logger } = makeCtx() - ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build" }) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) await handleMessageUpdated( makeAssistantMessageUpdated({ sessionID: "ses_1", error: { name: "APIError" } }), ctx, ) expect(logger.records.at(0)!.attributes?.["agent"]).toBe("build") + expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") }) }) @@ -408,6 +414,8 @@ describe("handleMessagePartUpdated — subtask parts", () => { const record = logger.records.at(0)! expect(record.body).toBe("subtask_invoked") expect(record.attributes?.["agent"]).toBe("plan") + expect(record.attributes?.["agent.name"]).toBe("plan") + expect(record.attributes?.["agent.type"]).toBe("subagent") expect(record.attributes?.["description"]).toBe("Plan the feature") expect(record.attributes?.["prompt_length"]).toBe("Create a plan".length) }) diff --git a/tests/handlers/permission.test.ts b/tests/handlers/permission.test.ts index 1a4d97b..89b7885 100644 --- a/tests/handlers/permission.test.ts +++ b/tests/handlers/permission.test.ts @@ -42,6 +42,7 @@ describe("handlePermissionUpdated", () => { describe("handlePermissionReplied", () => { test("emits tool_decision log on allow", () => { const { ctx, logger } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review", agentType: "subagent" }) handlePermissionUpdated(makePermissionUpdated("perm_1"), ctx) handlePermissionReplied(makePermissionReplied("perm_1", "allow"), ctx) const record = logger.records.at(0)! @@ -50,6 +51,8 @@ describe("handlePermissionReplied", () => { expect(record.attributes?.["source"]).toBe("allow") expect(record.attributes?.["tool_name"]).toBe("Read file") expect(record.attributes?.["tool_type"]).toBe("tool") + expect(record.attributes?.["agent.name"]).toBe("review") + expect(record.attributes?.["agent.type"]).toBe("subagent") }) test("emits tool_decision log on allowAlways", () => { diff --git a/tests/handlers/session.test.ts b/tests/handlers/session.test.ts index 737a082..bbada8a 100644 --- a/tests/handlers/session.test.ts +++ b/tests/handlers/session.test.ts @@ -124,7 +124,7 @@ describe("handleSessionIdle", () => { test("records session token and cost histograms when totals exist", async () => { const { ctx, gauges } = makeCtx() await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 500, tokens: 150, cost: 0.03, messages: 2, agent: "build" }) + ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 500, tokens: 150, cost: 0.03, messages: 2, agent: "build", agentType: "primary" }) handleSessionIdle(makeSessionIdle("ses_1"), ctx) expect(gauges.sessionToken.calls).toHaveLength(1) expect(gauges.sessionToken.calls.at(0)!.value).toBe(150) @@ -135,12 +135,14 @@ describe("handleSessionIdle", () => { test("emits total_tokens and total_messages in log record attributes", async () => { const { ctx, logger } = makeCtx() await handleSessionCreated(makeSessionCreated("ses_1"), ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 200, cost: 0.05, messages: 3, agent: "general" }) + ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 200, cost: 0.05, messages: 3, agent: "general", agentType: "primary" }) handleSessionIdle(makeSessionIdle("ses_1"), ctx) const record = logger.records.find(r => r.body === "session.idle")! expect(record.attributes?.["total_tokens"]).toBe(200) expect(record.attributes?.["total_cost_usd"]).toBe(0.05) expect(record.attributes?.["total_messages"]).toBe(3) + expect(record.attributes?.["agent.name"]).toBe("general") + expect(record.attributes?.["agent.type"]).toBe("primary") }) test("does not record histograms when no prior session.created", () => { @@ -220,18 +222,21 @@ describe("handleSessionCreated — is_subagent", () => { const { ctx, logger } = makeCtx() await handleSessionCreated(makeSessionCreated("ses_1"), ctx) expect(logger.records.at(0)!.attributes?.["is_subagent"]).toBe(false) + expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary") }) test("includes is_subagent=true on session.created log record for child session", async () => { const { ctx, logger } = makeCtx() await handleSessionCreated(makeSessionCreated("ses_child", 1000, "ses_parent"), ctx) expect(logger.records.at(0)!.attributes?.["is_subagent"]).toBe(true) + expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("subagent") }) - test("seeds sessionTotals agent as 'unknown' on creation", async () => { + test("seeds sessionTotals agent metadata on creation", async () => { const { ctx } = makeCtx() await handleSessionCreated(makeSessionCreated("ses_1"), ctx) expect(ctx.sessionTotals.get("ses_1")!.agent).toBe("unknown") + expect(ctx.sessionTotals.get("ses_1")!.agentType).toBe("primary") }) }) diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index fa62e07..c980bcb 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -170,12 +170,14 @@ describe("session spans", () => { test("sets session total attributes before ending on idle", () => { const { ctx, tracer } = makeCtx() handleSessionCreated(makeSessionCreated("ses_1"), ctx) - ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 250, cost: 0.05, messages: 3, agent: "build" }) + ctx.sessionTotals.set("ses_1", { startMs: Date.now() - 100, tokens: 250, cost: 0.05, messages: 3, agent: "build", agentType: "primary" }) handleSessionIdle(makeSessionIdle("ses_1"), ctx) const span = tracer.spans[0]! expect(span.attributes["session.total_tokens"]).toBe(250) expect(span.attributes["session.total_cost_usd"]).toBe(0.05) expect(span.attributes["session.total_messages"]).toBe(3) + expect(span.attributes[AGENT_NAME]).toBe("build") + expect(span.attributes["agent.type"]).toBe("primary") }) test("ends session span with ERROR status on session.error", () => { @@ -237,10 +239,13 @@ describe("tool spans", () => { test("tool span carries tool.name attribute", () => { const { ctx, tracer } = makeCtx() + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" }) handleMessagePartUpdated(makeToolPartUpdated("running", { tool: "read_file" }), ctx) expect(tracer.spans[0]!.attributes["tool.name"]).toBe("read_file") expect(tracer.spans[0]!.attributes[TOOL_NAME]).toBe("read_file") expect(tracer.spans[0]!.attributes[OPENINFERENCE_SPAN_KIND]).toBe(OpenInferenceSpanKind.TOOL) + expect(tracer.spans[0]!.attributes[AGENT_NAME]).toBe("build") + expect(tracer.spans[0]!.attributes["agent.type"]).toBe("primary") }) test("ends tool span with OK status on completion", () => { @@ -365,6 +370,7 @@ describe("message (LLM) spans", () => { test("handleMessageUpdated sets OpenInference token attributes on span", () => { const { ctx, tracer } = makeCtx() startMessageSpan("ses_1", "msg_1", "claude-3-5-sonnet", "anthropic", 1000, ctx) + ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "review", agentType: "subagent" }) handleMessageUpdated( makeAssistantMessageUpdated({ id: "msg_1", @@ -378,6 +384,8 @@ describe("message (LLM) spans", () => { expect(span.attributes[LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]).toBe(10) expect(span.attributes[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_READ]).toBe(30) expect(span.attributes[LLM_TOKEN_COUNT_PROMPT_DETAILS_CACHE_WRITE]).toBe(5) + expect(span.attributes[AGENT_NAME]).toBe("review") + expect(span.attributes["agent.type"]).toBe("subagent") }) test("handleMessageUpdated no-ops span handling when no span exists for messageID", () => {