Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/handlers/activity.ts
Original file line number Diff line number Diff line change
@@ -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"

/**
Expand Down Expand Up @@ -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, {
Expand All @@ -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,
},
})
Expand Down
34 changes: 23 additions & 11 deletions src/handlers/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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,
},
},
Expand All @@ -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, {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/handlers/permission.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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,
Expand All @@ -34,6 +35,7 @@ export function handlePermissionReplied(e: EventPermissionReplied, ctx: HandlerC
tool_type: pending?.type ?? "unknown",
decision,
source: response,
...agentAttrs(agentName, agentType),
...ctx.commonAttrs,
},
})
Expand Down
23 changes: 18 additions & 5 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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,
},
Expand All @@ -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 })
}
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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,
},
})
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -173,6 +185,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
"event.name": "session.error",
"session.id": sessionID,
error,
...agentAttrs(agentName, agentType),
...ctx.commonAttrs,
},
})
Expand Down
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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":
Expand All @@ -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}`
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ 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
tokens: number
cost: number
messages: number
agent: string
agentType: SessionAgentType
}

/** Shared context threaded through every event handler. */
Expand Down
24 changes: 23 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<HandlerContext, "sessionTotals">,
): { 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
}
3 changes: 3 additions & 0 deletions tests/handlers/activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading