Skip to content

Commit 307fabc

Browse files
Merge pull request #69 from DEVtheOPS/feat/agent-metadata
2 parents 257984c + c2759e9 commit 307fabc

12 files changed

Lines changed: 117 additions & 33 deletions

File tree

src/handlers/activity.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
22
import type { EventSessionDiff, EventCommandExecuted } from "@opencode-ai/sdk"
3-
import { isMetricEnabled, setBoundedMap } from "../util.ts"
3+
import { agentAttrs, getSessionAgentMeta, isMetricEnabled, setBoundedMap } from "../util.ts"
44
import type { HandlerContext } from "../types.ts"
55

66
/**
@@ -69,6 +69,7 @@ export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerConte
6969
if (e.properties.name !== "bash") return
7070
ctx.log("debug", "otel: command.executed (bash)", { sessionID: e.properties.sessionID, argumentsLength: e.properties.arguments.length })
7171
if (!GIT_COMMIT_RE.test(e.properties.arguments)) return
72+
const { agentName, agentType } = getSessionAgentMeta(e.properties.sessionID, ctx)
7273

7374
if (isMetricEnabled("commit.count", ctx)) {
7475
ctx.instruments.commitCounter.add(1, {
@@ -86,6 +87,7 @@ export function handleCommandExecuted(e: EventCommandExecuted, ctx: HandlerConte
8687
attributes: {
8788
"event.name": "commit",
8889
"session.id": e.properties.sessionID,
90+
...agentAttrs(agentName, agentType),
8991
...ctx.commonAttrs,
9092
},
9193
})

src/handlers/message.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
TOOL_NAME,
2828
TOOL_PARAMETERS,
2929
} from "@arizeai/openinference-semantic-conventions"
30-
import { errorSummary, setBoundedMap, accumulateSessionTotals, isMetricEnabled, isTraceEnabled } from "../util.ts"
30+
import { agentAttrs, errorSummary, setBoundedMap, accumulateSessionTotals, getSessionAgentMeta, isMetricEnabled, isTraceEnabled } from "../util.ts"
3131
import type { HandlerContext } from "../types.ts"
3232

3333
const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND
@@ -56,7 +56,8 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
5656

5757
const { sessionID, modelID, providerID } = assistant
5858
const duration = assistant.time.completed - assistant.time.created
59-
const agent = ctx.sessionTotals.get(sessionID)?.agent ?? "unknown"
59+
const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx)
60+
const agent = agentName
6061

6162
const totalTokens = assistant.tokens.input + assistant.tokens.output + assistant.tokens.reasoning
6263
+ assistant.tokens.cache.read + assistant.tokens.cache.write
@@ -110,6 +111,8 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
110111
if (msgSpan) {
111112
const outputText = ctx.messageOutputs.get(msgKey)
112113
msgSpan.setAttributes({
114+
[AGENT_NAME]: agentName,
115+
"agent.type": agentType,
113116
[LLM_TOKEN_COUNT_PROMPT]: assistant.tokens.input,
114117
[LLM_TOKEN_COUNT_COMPLETION]: assistant.tokens.output,
115118
[LLM_TOKEN_COUNT_COMPLETION_DETAILS_REASONING]: assistant.tokens.reasoning,
@@ -150,7 +153,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
150153
"session.id": sessionID,
151154
model: modelID,
152155
provider: providerID,
153-
agent,
156+
...agentAttrs(agentName, agentType),
154157
error: errorSummary(assistant.error),
155158
duration_ms: duration,
156159
...ctx.commonAttrs,
@@ -173,12 +176,12 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
173176
body: "api_request",
174177
attributes: {
175178
"event.name": "api_request",
176-
"session.id": sessionID,
177-
model: modelID,
178-
provider: providerID,
179-
agent,
180-
cost_usd: assistant.cost,
181-
duration_ms: duration,
179+
"session.id": sessionID,
180+
model: modelID,
181+
provider: providerID,
182+
...agentAttrs(agentName, agentType),
183+
cost_usd: assistant.cost,
184+
duration_ms: duration,
182185
input_tokens: assistant.tokens.input,
183186
output_tokens: assistant.tokens.output,
184187
reasoning_tokens: assistant.tokens.reasoning,
@@ -224,6 +227,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
224227
...ctx.commonAttrs,
225228
"session.id": subtask.sessionID,
226229
agent: subtask.agent,
230+
"agent.type": "subagent",
227231
})
228232
}
229233
ctx.emitLog({
@@ -235,7 +239,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
235239
attributes: {
236240
"event.name": "subtask_invoked",
237241
"session.id": subtask.sessionID,
238-
agent: subtask.agent,
242+
...agentAttrs(subtask.agent, "subagent"),
239243
description: subtask.description,
240244
prompt_length: subtask.prompt.length,
241245
...ctx.commonAttrs,
@@ -253,6 +257,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
253257
const key = `${toolPart.sessionID}:${toolPart.callID}`
254258

255259
if (toolPart.state.status === "running") {
260+
const { agentName, agentType } = getSessionAgentMeta(toolPart.sessionID, ctx)
256261
const toolSpan = isTraceEnabled("tool", ctx)
257262
? (() => {
258263
const sessionSpan = ctx.sessionSpans.get(toolPart.sessionID)
@@ -273,6 +278,8 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
273278
[TOOL_PARAMETERS]: JSON.stringify(toolPart.state.input),
274279
[INPUT_VALUE]: JSON.stringify(toolPart.state.input),
275280
[INPUT_MIME_TYPE]: MimeType.JSON,
281+
[AGENT_NAME]: agentName,
282+
"agent.type": agentType,
276283
...ctx.commonAttrs,
277284
},
278285
},
@@ -299,6 +306,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
299306
if (end === undefined) return
300307
const duration_ms = end - start
301308
const success = toolPart.state.status === "completed"
309+
const { agentName, agentType } = getSessionAgentMeta(toolPart.sessionID, ctx)
302310

303311
if (isMetricEnabled("tool.duration", ctx)) {
304312
ctx.instruments.toolDurationHistogram.record(duration_ms, {
@@ -335,6 +343,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
335343
parentCtx,
336344
)
337345
})()
346+
toolSpan.setAttributes({ [AGENT_NAME]: agentName, "agent.type": agentType })
338347
toolSpan.setAttribute("tool.success", success)
339348
if (success) {
340349
const output = (toolPart.state as { output: string }).output
@@ -370,6 +379,7 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
370379
"event.name": "tool_result",
371380
"session.id": toolPart.sessionID,
372381
tool_name: toolPart.tool,
382+
...agentAttrs(agentName, agentType),
373383
success,
374384
duration_ms,
375385
...sizeAttr,
@@ -409,6 +419,7 @@ export function startMessageSpan(
409419
if (!isTraceEnabled("llm", ctx)) return
410420
const msgKey = `${sessionID}:${messageID}`
411421
if (ctx.messageSpans.has(msgKey)) return
422+
const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx)
412423
const sessionSpan = ctx.sessionSpans.get(sessionID)
413424
const baseCtx = ctx.rootContext()
414425
const parentCtx = sessionSpan
@@ -423,7 +434,8 @@ export function startMessageSpan(
423434
attributes: {
424435
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM,
425436
[SESSION_ID]: sessionID,
426-
[AGENT_NAME]: ctx.sessionTotals.get(sessionID)?.agent ?? "unknown",
437+
[AGENT_NAME]: agentName,
438+
"agent.type": agentType,
427439
[LLM_SYSTEM]: providerID,
428440
[LLM_PROVIDER]: providerID,
429441
[LLM_MODEL_NAME]: modelID,

src/handlers/permission.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { SeverityNumber } from "@opentelemetry/api-logs"
22
import type { EventPermissionUpdated, EventPermissionReplied } from "@opencode-ai/sdk"
3-
import { setBoundedMap } from "../util.ts"
3+
import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "../util.ts"
44
import type { HandlerContext } from "../types.ts"
55

66
/** 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
2020
const pending = ctx.pendingPermissions.get(permissionID)
2121
ctx.pendingPermissions.delete(permissionID)
2222
const decision = response === "allow" || response === "allowAlways" ? "accept" : "reject"
23+
const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx)
2324
ctx.log("debug", "otel: tool_decision emitted", { permissionID, sessionID, decision, source: response, tool_name: pending?.title ?? "unknown" })
2425
ctx.emitLog({
2526
severityNumber: SeverityNumber.INFO,
@@ -34,6 +35,7 @@ export function handlePermissionReplied(e: EventPermissionReplied, ctx: HandlerC
3435
tool_type: pending?.type ?? "unknown",
3536
decision,
3637
source: response,
38+
...agentAttrs(agentName, agentType),
3739
...ctx.commonAttrs,
3840
},
3941
})

src/handlers/session.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { SeverityNumber } from "@opentelemetry/api-logs"
22
import { SpanStatusCode, trace } from "@opentelemetry/api"
33
import type { EventSessionCreated, EventSessionIdle, EventSessionError, EventSessionStatus } from "@opencode-ai/sdk"
44
import { AGENT_NAME, OpenInferenceSpanKind, SemanticConventions, SESSION_ID } from "@arizeai/openinference-semantic-conventions"
5-
import { errorSummary, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../util.ts"
6-
import type { HandlerContext } from "../types.ts"
5+
import { agentAttrs, errorSummary, getSessionAgentMeta, setBoundedMap, isMetricEnabled, isTraceEnabled } from "../util.ts"
6+
import type { HandlerContext, SessionAgentType } from "../types.ts"
77

88
const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND
99

@@ -12,10 +12,11 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
1212
const { id: sessionID, time, parentID } = e.properties.info
1313
const createdAt = time.created
1414
const isSubagent = !!parentID
15+
const agentType: SessionAgentType = isSubagent ? "subagent" : "primary"
1516
if (isMetricEnabled("session.count", ctx)) {
1617
ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, is_subagent: isSubagent })
1718
}
18-
setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown" })
19+
setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown", agentType })
1920

2021
// WARNING: disabling "session" traces while "llm" or "tool" traces remain enabled
2122
// leaves those child spans without a local session parent. If OPENCODE_TRACEPARENT
@@ -35,6 +36,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
3536
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT,
3637
[SESSION_ID]: sessionID,
3738
[AGENT_NAME]: "unknown",
39+
"agent.type": agentType,
3840
"session.is_subagent": isSubagent,
3941
...ctx.commonAttrs,
4042
},
@@ -50,7 +52,13 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
5052
timestamp: createdAt,
5153
observedTimestamp: Date.now(),
5254
body: "session.created",
53-
attributes: { "event.name": "session.created", "session.id": sessionID, is_subagent: isSubagent, ...ctx.commonAttrs },
55+
attributes: {
56+
"event.name": "session.created",
57+
"session.id": sessionID,
58+
is_subagent: isSubagent,
59+
...agentAttrs("unknown", agentType),
60+
...ctx.commonAttrs,
61+
},
5462
})
5563
return ctx.log("info", "otel: session.created", { sessionID, createdAt, isSubagent })
5664
}
@@ -84,6 +92,7 @@ function sweepSession(sessionID: string, ctx: HandlerContext) {
8492
export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
8593
const sessionID = e.properties.sessionID
8694
const totals = ctx.sessionTotals.get(sessionID)
95+
const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx)
8796
ctx.sessionTotals.delete(sessionID)
8897
ctx.sessionDiffTotals.delete(sessionID)
8998
sweepSession(sessionID, ctx)
@@ -109,6 +118,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
109118
if (totals) {
110119
sessionSpan.setAttributes({
111120
[AGENT_NAME]: totals.agent,
121+
"agent.type": totals.agentType,
112122
"session.total_tokens": totals.tokens,
113123
"session.total_cost_usd": totals.cost,
114124
"session.total_messages": totals.messages,
@@ -131,6 +141,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
131141
total_tokens: totals?.tokens ?? 0,
132142
total_cost_usd: totals?.cost ?? 0,
133143
total_messages: totals?.messages ?? 0,
144+
...agentAttrs(agentName, agentType),
134145
...ctx.commonAttrs,
135146
},
136147
})
@@ -145,6 +156,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
145156
const rawID = e.properties.sessionID
146157
const sessionID = rawID ?? "unknown"
147158
const error = errorSummary(e.properties.error)
159+
const { agentName, agentType } = rawID ? getSessionAgentMeta(rawID, ctx) : { agentName: "unknown", agentType: "unknown" as const }
148160
const totals = rawID ? ctx.sessionTotals.get(rawID) : undefined
149161
if (rawID) {
150162
ctx.sessionTotals.delete(rawID)
@@ -155,7 +167,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
155167
if (rawID) {
156168
const sessionSpan = ctx.sessionSpans.get(rawID)
157169
if (sessionSpan) {
158-
if (totals) sessionSpan.setAttribute(AGENT_NAME, totals.agent)
170+
if (totals) sessionSpan.setAttributes({ [AGENT_NAME]: totals.agent, "agent.type": totals.agentType })
159171
sessionSpan.setStatus({ code: SpanStatusCode.ERROR, message: error })
160172
sessionSpan.setAttribute("error", error)
161173
sessionSpan.end()
@@ -173,6 +185,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
173185
"event.name": "session.error",
174186
"session.id": sessionID,
175187
error,
188+
...agentAttrs(agentName, agentType),
176189
...ctx.commonAttrs,
177190
},
178191
})

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { handleSessionCreated, handleSessionIdle, handleSessionError, handleSess
2525
import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from "./handlers/message.ts"
2626
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
2727
import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts"
28+
import { agentAttrs, getSessionAgentMeta } from "./util.ts"
2829

2930
const PLUGIN_VERSION: string = (pkg as { version?: string }).version ?? "unknown"
3031

@@ -182,10 +183,11 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
182183

183184
"chat.message": safe("chat.message", async (input, output) => {
184185
const agent = input.agent ?? "unknown"
186+
const { agentType } = getSessionAgentMeta(input.sessionID, ctx)
185187
const totals = sessionTotals.get(input.sessionID)
186188
if (totals) totals.agent = agent
187189
const sessionSpan = sessionSpans.get(input.sessionID)
188-
if (sessionSpan) sessionSpan.setAttribute(AGENT_NAME, agent)
190+
if (sessionSpan) sessionSpan.setAttributes({ [AGENT_NAME]: agent, "agent.type": agentType })
189191
const promptText = output.parts.map((part) => {
190192
switch (part.type) {
191193
case "text":
@@ -211,7 +213,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
211213
attributes: {
212214
"event.name": "user_prompt",
213215
"session.id": input.sessionID,
214-
agent,
216+
...agentAttrs(agent, agentType),
215217
prompt_length: promptLength,
216218
model: input.model
217219
? `${input.model.providerID}/${input.model.modelID}`

src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,17 @@ export type Instruments = {
5454
subtaskCounter: Counter
5555
}
5656

57+
/** Session role emitted by opencode: either the primary/root agent or a spawned subagent. */
58+
export type SessionAgentType = "primary" | "subagent"
59+
5760
/** Accumulated per-session totals used for gauge snapshots on session.idle. */
5861
export type SessionTotals = {
5962
startMs: number
6063
tokens: number
6164
cost: number
6265
messages: number
6366
agent: string
67+
agentType: SessionAgentType
6468
}
6569

6670
/** Shared context threaded through every event handler. */

src/util.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { MAX_PENDING } from "./types.ts"
2-
import type { HandlerContext } from "./types.ts"
2+
import type { HandlerContext, SessionAgentType } from "./types.ts"
33

44
/** Returns a human-readable summary string from an opencode error object. */
55
export function errorSummary(err: { name: string; data?: unknown } | undefined): string {
@@ -57,5 +57,27 @@ export function accumulateSessionTotals(
5757
cost: existing.cost + cost,
5858
messages: existing.messages + 1,
5959
agent: existing.agent,
60+
agentType: existing.agentType,
6061
})
6162
}
63+
64+
/** Returns the current session-scoped agent name/type, defaulting to `unknown` when unavailable. */
65+
export function getSessionAgentMeta(
66+
sessionID: string,
67+
ctx: Pick<HandlerContext, "sessionTotals">,
68+
): { agentName: string; agentType: SessionAgentType | "unknown" } {
69+
const totals = ctx.sessionTotals.get(sessionID)
70+
return {
71+
agentName: totals?.agent ?? "unknown",
72+
agentType: totals?.agentType ?? "unknown",
73+
}
74+
}
75+
76+
/** Builds a consistent agent attribute set for OTLP logs, metrics, and spans. */
77+
export function agentAttrs(agentName: string, agentType: SessionAgentType | "unknown") {
78+
return {
79+
agent: agentName,
80+
"agent.name": agentName,
81+
"agent.type": agentType,
82+
} as const
83+
}

tests/handlers/activity.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,13 @@ describe("handleCommandExecuted", () => {
143143

144144
test("emits commit log record", () => {
145145
const { ctx, logger } = makeCtx()
146+
ctx.sessionTotals.set("ses_1", { startMs: 0, tokens: 0, cost: 0, messages: 0, agent: "build", agentType: "primary" })
146147
handleCommandExecuted(makeCommandExecuted("bash", "git commit -m 'fix: bug'"), ctx)
147148
expect(logger.records).toHaveLength(1)
148149
expect(logger.records.at(0)!.body).toBe("commit")
149150
expect(logger.records.at(0)!.attributes?.["session.id"]).toBe("ses_1")
151+
expect(logger.records.at(0)!.attributes?.["agent.name"]).toBe("build")
152+
expect(logger.records.at(0)!.attributes?.["agent.type"]).toBe("primary")
150153
})
151154

152155
test("ignores non-bash commands", () => {

0 commit comments

Comments
 (0)