Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet
| `opencode.model.usage` | Counter | Messages per model and provider |
| `opencode.retry.count` | Counter | API retries observed via `session.status` events |

All session-scoped metrics include `session.id`. Sessions created by subagents also include `root.session.id`, which identifies the top-level session, plus `is_subagent=true`. Costs and tokens remain attributed to their emitting `session.id`; aggregate by `root.session.id` in your metrics backend to include subagent usage in a root-session total.

### Log events

| Event | Description |
Expand Down
49 changes: 29 additions & 20 deletions src/handlers/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,19 @@ import {
setBoundedMap,
accumulateSessionTotals,
getSessionAgentMeta,
metricAttrs,
isMetricEnabled,
isTraceEnabled,
resolveSessionTraceContext,
sessionAttrs,
} from "../util.ts"
import type { HandlerContext } from "../types.ts"

const OPENINFERENCE_SPAN_KIND = SemanticConventions.OPENINFERENCE_SPAN_KIND
const LLM_FINISH_REASON = "llm.finish_reason"

type SubtaskPart = {
id?: string
type: "subtask"
sessionID: string
messageID: string
Expand All @@ -69,38 +72,39 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
const duration = assistant.time.completed - assistant.time.created
const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx)
const agent = agentName
const metricAgent = metricAttrs(sessionID, agentName, agentType, ctx)

const totalTokens = assistant.tokens.input + assistant.tokens.output + assistant.tokens.reasoning
+ assistant.tokens.cache.read + assistant.tokens.cache.write

if (isMetricEnabled("token.usage", ctx)) {
const { tokenCounter } = ctx.instruments
tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "input" })
tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "output" })
tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "reasoning" })
tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" })
tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" })
tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent, type: "input" })
tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent, type: "output" })
tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent, type: "reasoning" })
tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent, type: "cacheRead" })
tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent, type: "cacheCreation" })
}

if (isMetricEnabled("cost.usage", ctx)) {
ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent })
ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent })
}

if (isMetricEnabled("cache.count", ctx)) {
if (assistant.tokens.cache.read > 0) {
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" })
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent, type: "cacheRead" })
}
if (assistant.tokens.cache.write > 0) {
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" })
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent, type: "cacheCreation" })
}
}

if (isMetricEnabled("message.count", ctx)) {
ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent })
ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent })
}

if (isMetricEnabled("model.usage", ctx)) {
ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID, agent })
ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, model: modelID, provider: providerID, ...metricAgent })
}

accumulateSessionTotals(sessionID, totalTokens, assistant.cost, ctx)
Expand Down Expand Up @@ -168,7 +172,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
body: "api_error",
attributes: {
"event.name": "api_error",
"session.id": sessionID,
...sessionAttrs(sessionID, ctx),
model: modelID,
provider: providerID,
"gen_ai.provider.name": genAiProviderName(providerID),
Expand All @@ -195,7 +199,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
body: "api_request",
attributes: {
"event.name": "api_request",
"session.id": sessionID,
...sessionAttrs(sessionID, ctx),
model: modelID,
provider: providerID,
"gen_ai.provider.name": genAiProviderName(providerID),
Expand Down Expand Up @@ -242,12 +246,17 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle

if (part.type === "subtask") {
const subtask = part as unknown as SubtaskPart
const key = subtask.id
? `${subtask.sessionID}:${subtask.id}`
: `${subtask.sessionID}:${subtask.messageID}:${subtask.agent}:${subtask.description}:${subtask.prompt}`
if (ctx.seenSubtasks.has(key)) return
Comment on lines +249 to +254
setBoundedMap(ctx.seenSubtasks, key, Date.now())
const { agentName, agentType } = getSessionAgentMeta(subtask.sessionID, ctx)
if (isMetricEnabled("subtask.count", ctx)) {
ctx.instruments.subtaskCounter.add(1, {
...ctx.commonAttrs,
"session.id": subtask.sessionID,
agent: subtask.agent,
"agent.type": "subagent",
...metricAttrs(subtask.sessionID, agentName, agentType, ctx),
"subtask.agent": subtask.agent,
})
}
ctx.emitLog({
Expand All @@ -258,8 +267,9 @@ export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: Handle
body: "subtask_invoked",
attributes: {
"event.name": "subtask_invoked",
"session.id": subtask.sessionID,
...agentAttrs(subtask.agent, "subagent"),
...sessionAttrs(subtask.sessionID, ctx),
"parent.message.id": subtask.messageID,
...agentAttrs(agentName, agentType),
description: subtask.description,
prompt_length: subtask.prompt.length,
...ctx.commonAttrs,
Expand Down Expand Up @@ -447,9 +457,8 @@ export function startMessageSpan(
kind: SpanKind.CLIENT,
attributes: {
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.LLM,
[SESSION_ID]: sessionID,
[AGENT_NAME]: agentName,
"agent.type": agentType,
...sessionAttrs(sessionID, ctx),
...agentAttrs(agentName, agentType),
[LLM_SYSTEM]: providerID,
[LLM_PROVIDER]: providerID,
"gen_ai.provider.name": genAiProviderName(providerID),
Expand Down
43 changes: 30 additions & 13 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,18 @@ import {
MimeType,
OpenInferenceSpanKind,
SemanticConventions,
SESSION_ID,
} from "@arizeai/openinference-semantic-conventions"
import {
agentAttrs,
errorSummary,
getSessionAgentMeta,
metricAttrs,
setBoundedMap,
isMetricEnabled,
isTraceEnabled,
resolveSessionTraceContext,
sessionAttrs,
setSessionMetadata,
} from "../util.ts"
import type { HandlerContext, SessionAgentType } from "../types.ts"

Expand Down Expand Up @@ -60,10 +62,9 @@ export function handleRunStarted(
startTime,
attributes: {
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT,
[SESSION_ID]: sessionID,
[AGENT_NAME]: agent,
"agent.type": "primary",
"session.is_subagent": false,
...sessionAttrs(sessionID, ctx),
...agentAttrs(agent, ctx.sessionMetadata.get(sessionID)?.agentType ?? "primary"),
"session.is_subagent": ctx.sessionMetadata.get(sessionID)?.agentType === "subagent",
...(promptText
? {
[INPUT_VALUE]: promptText,
Expand All @@ -87,8 +88,22 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
const createdAt = time.created
const isSubagent = !!parentID
const agentType: SessionAgentType = isSubagent ? "subagent" : "primary"
const prior = ctx.sessionMetadata.get(sessionID)
setSessionMetadata(sessionID, {
sessionID,
parentSessionID: parentID,
parentMessageID: prior?.parentMessageID,
rootSessionID: parentID ? (ctx.sessionMetadata.get(parentID)?.rootSessionID ?? parentID) : sessionID,
agentType,
}, ctx)
if (isMetricEnabled("session.count", ctx)) {
ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, is_subagent: isSubagent })
const rootSessionID = ctx.sessionMetadata.get(sessionID)?.rootSessionID
ctx.instruments.sessionCounter.add(1, {
...ctx.commonAttrs,
"session.id": sessionID,
...(rootSessionID ? { "root.session.id": rootSessionID } : {}),
is_subagent: isSubagent,
})
}
setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown", agentType })

Expand All @@ -99,10 +114,9 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
startTime: createdAt,
attributes: {
[OPENINFERENCE_SPAN_KIND]: OpenInferenceSpanKind.AGENT,
[SESSION_ID]: sessionID,
[AGENT_NAME]: "unknown",
"agent.type": agentType,
...agentAttrs("unknown", agentType),
"session.is_subagent": isSubagent,
...sessionAttrs(sessionID, ctx),
...ctx.commonAttrs,
},
},
Expand All @@ -120,7 +134,7 @@ export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext
body: "session.created",
attributes: {
"event.name": "session.created",
"session.id": sessionID,
...sessionAttrs(sessionID, ctx),
is_subagent: isSubagent,
...agentAttrs("unknown", agentType),
...ctx.commonAttrs,
Expand Down Expand Up @@ -162,11 +176,13 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
const sessionID = e.properties.sessionID
const totals = ctx.sessionTotals.get(sessionID)
const { agentName, agentType } = getSessionAgentMeta(sessionID, ctx)
const hierarchyAttrs = sessionAttrs(sessionID, ctx)
const metricAgent = metricAttrs(sessionID, agentName, agentType, ctx)
ctx.sessionTotals.delete(sessionID)
ctx.sessionDiffTotals.delete(sessionID)
sweepSession(sessionID, ctx)

const attrs = { ...ctx.commonAttrs, "session.id": sessionID }
const attrs = { ...ctx.commonAttrs, ...metricAgent }
let duration_ms: number | undefined

if (totals) {
Expand Down Expand Up @@ -223,7 +239,7 @@ export function handleSessionIdle(e: EventSessionIdle, ctx: HandlerContext) {
body: "session.idle",
attributes: {
"event.name": "session.idle",
"session.id": sessionID,
...hierarchyAttrs,
total_tokens: totals?.tokens ?? 0,
total_cost_usd: totals?.cost ?? 0,
total_messages: totals?.messages ?? 0,
Expand All @@ -244,6 +260,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
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
const hierarchyAttrs = sessionAttrs(sessionID, ctx)
if (rawID) {
ctx.sessionTotals.delete(rawID)
ctx.sessionDiffTotals.delete(rawID)
Expand Down Expand Up @@ -279,7 +296,7 @@ export function handleSessionError(e: EventSessionError, ctx: HandlerContext) {
body: "session.error",
attributes: {
"event.name": "session.error",
"session.id": sessionID,
...hierarchyAttrs,
error,
...agentAttrs(agentName, agentType),
...ctx.commonAttrs,
Expand Down
39 changes: 27 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ import { handleMessageUpdated, handleMessagePartUpdated, startMessageSpan } from
import { handlePermissionUpdated, handlePermissionReplied } from "./handlers/permission.ts"
import { handleSessionDiff, handleCommandExecuted } from "./handlers/activity.ts"
import { handleChatHeaders } from "./handlers/chat-headers.ts"
import { agentAttrs, getSessionAgentMeta, setBoundedMap } from "./util.ts"
import type { SessionTotals } from "./types.ts"
import { agentAttrs, getSessionAgentMeta, setBoundedMap, setSessionMetadata } from "./util.ts"

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

Expand Down Expand Up @@ -105,6 +104,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
const pendingToolSpans = new Map()
const pendingPermissions = new Map()
const sessionTotals = new Map()
const sessionMetadata = new Map()
const seenSubtasks = new Map()
const sessionDiffTotals = new Map()
const runSpans = new Map()
const runSpanContexts = new Map()
Expand Down Expand Up @@ -150,6 +151,8 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
pendingToolSpans,
pendingPermissions,
sessionTotals,
sessionMetadata,
seenSubtasks,
sessionDiffTotals,
disabledMetrics,
disabledTraces,
Expand Down Expand Up @@ -224,16 +227,7 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
"chat.message": safe("chat.message", async (input, output) => {
const agent = input.agent ?? "unknown"
const startTime = Date.now()
const existingTotals = sessionTotals.get(input.sessionID)
const nextTotals: SessionTotals = {
startMs: existingTotals?.startMs ?? startTime,
tokens: existingTotals?.tokens ?? 0,
cost: existingTotals?.cost ?? 0,
messages: existingTotals?.messages ?? 0,
agent,
agentType: existingTotals?.agentType ?? "primary",
}
setBoundedMap(sessionTotals, input.sessionID, nextTotals)
prepareSessionForMessage(input.sessionID, agent, startTime, ctx)
const { agentType } = getSessionAgentMeta(input.sessionID, ctx)
const sessionSpan = sessionSpans.get(input.sessionID)
if (sessionSpan) sessionSpan.setAttributes({ [AGENT_NAME]: agent, "agent.type": agentType })
Expand Down Expand Up @@ -364,3 +358,24 @@ export const OtelPlugin: Plugin = async ({ project, client, directory, worktree
}),
}
}

export function prepareSessionForMessage(sessionID: string, agent: string, startTime: number, ctx: HandlerContext) {
const existingTotals = ctx.sessionTotals.get(sessionID)
const priorMetadata = ctx.sessionMetadata.get(sessionID)
const agentType = existingTotals?.agentType ?? priorMetadata?.agentType ?? "primary"
setBoundedMap(ctx.sessionTotals, sessionID, {
startMs: existingTotals?.startMs ?? startTime,
tokens: existingTotals?.tokens ?? 0,
cost: existingTotals?.cost ?? 0,
messages: existingTotals?.messages ?? 0,
agent,
agentType,
})
setSessionMetadata(sessionID, {
sessionID,
parentSessionID: priorMetadata?.parentSessionID,
parentMessageID: priorMetadata?.parentMessageID,
rootSessionID: priorMetadata?.rootSessionID ?? sessionID,
agentType,
}, ctx)
}
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ export type SessionTotals = {
agentType: SessionAgentType
}

/** Bounded hierarchy identity for one session. */
export type SessionMetadata = {
sessionID: string
parentSessionID?: string
parentMessageID?: string
rootSessionID?: string
agentType: SessionAgentType | "unknown"
}

/** Pending root-run metadata captured from `chat.message` until the user message ID is known. */
export type PendingRun = {
agent: string
Expand All @@ -93,6 +102,8 @@ export type HandlerContext = {
pendingToolSpans: Map<string, PendingToolSpan>
pendingPermissions: Map<string, PendingPermission>
sessionTotals: Map<string, SessionTotals>
sessionMetadata: Map<string, SessionMetadata>
seenSubtasks: Map<string, number>
sessionDiffTotals: Map<string, { additions: number; deletions: number }>
disabledMetrics: Set<string>
disabledTraces: Set<string>
Expand Down
Loading
Loading