From 68971b44a5f714a4bf21017c9c18ddcb1459277c Mon Sep 17 00:00:00 2001 From: John D'Emic Date: Sat, 19 Sep 2026 21:01:10 -0400 Subject: [PATCH] feat: mirror the agent name onto gen_ai.agent.name on LLM spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM spans carry the agent under the OpenInference key only, so a consumer reading the OpenTelemetry GenAI conventions sees the provider, model and token usage but no agent attribution — it cannot tell a build request from a plan request, or from a named subagent. Mirror the value onto gen_ai.agent.name, taken from the semantic-conventions package already in the dependency tree. Nothing is renamed or removed, so OpenInference consumers are unaffected. agent.type has no GenAI equivalent and is left as-is. Scoped to LLM spans, matching the issue. Tool, subtask and session spans also set the OpenInference key and could follow if that is wanted. The tests assert the literal attribute string rather than the constant the source imports: that string is the wire format consumers match on, so a constant renamed upstream should fail the suite rather than silently change what goes over the wire. Closes #126 Co-Authored-By: Claude Opus 5 (1M context) --- src/handlers/message.ts | 3 +++ tests/handlers/spans.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 248228a..3a20db4 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -27,6 +27,7 @@ import { TOOL_NAME, TOOL_PARAMETERS, } from "@arizeai/openinference-semantic-conventions" +import { ATTR_GEN_AI_AGENT_NAME } from "@opentelemetry/semantic-conventions/incubating" import { agentAttrs, errorSummary, @@ -123,6 +124,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext const outputText = ctx.messageOutputs.get(msgKey) msgSpan.setAttributes({ [AGENT_NAME]: agentName, + [ATTR_GEN_AI_AGENT_NAME]: agentName, "agent.type": agentType, [LLM_TOKEN_COUNT_PROMPT]: assistant.tokens.input, [LLM_TOKEN_COUNT_COMPLETION]: assistant.tokens.output, @@ -449,6 +451,7 @@ export function startMessageSpan( [OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM, [SESSION_ID]: sessionID, [AGENT_NAME]: agentName, + [ATTR_GEN_AI_AGENT_NAME]: agentName, "agent.type": agentType, [LLM_SYSTEM]: providerID, [LLM_PROVIDER]: providerID, diff --git a/tests/handlers/spans.test.ts b/tests/handlers/spans.test.ts index 18851d0..d3d0106 100644 --- a/tests/handlers/spans.test.ts +++ b/tests/handlers/spans.test.ts @@ -370,6 +370,15 @@ describe("message (LLM) spans", () => { expect(tracer.spans[0]!.attributes[LLM_MODEL_NAME]).toBe("claude-sonnet-4") }) + // Asserted as a literal key, not via the semconv constant the source imports: + // this string is the wire format Gen AI consumers match on, so the test has to + // fail if the constant is renamed upstream. + test("startMessageSpan mirrors the agent name onto gen_ai.agent.name", () => { + const { ctx, tracer } = makeCtx() + startMessageSpan("ses_1", "msg_1", "user_1", "claude-sonnet-4", "anthropic", 1000, ctx, "plan") + expect(tracer.spans[0]!.attributes["gen_ai.agent.name"]).toBe(tracer.spans[0]!.attributes[AGENT_NAME]) + }) + test("startMessageSpan is a no-op when span already exists for sessionID:messageID", () => { const { ctx, tracer } = makeCtx() startMessageSpan("ses_1", "msg_1", "user_1", "claude", "anthropic", 1000, ctx) @@ -434,6 +443,17 @@ describe("message (LLM) spans", () => { expect(span.attributes["agent.type"]).toBe("subagent") }) + test("handleMessageUpdated mirrors the agent name onto gen_ai.agent.name", () => { + const { ctx, tracer } = makeCtx() + startMessageSpan("ses_1", "msg_1", "user_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" }), ctx) + const span = tracer.spans[0]! + expect(span.attributes["gen_ai.agent.name"]).toBe("review") + // the OpenInference key is mirrored, never replaced + expect(span.attributes[AGENT_NAME]).toBe("review") + }) + test("handleMessageUpdated no-ops span handling when no span exists for messageID", () => { const { ctx, tracer } = makeCtx() const spansBefore = tracer.spans.length