Skip to content

Commit 5dedc4e

Browse files
feat(handlers): add agent usage metrics and sub-agent tracking
- Tag token.usage, cost.usage, cache.count, message.count and model.usage counters with an 'agent' attribute sourced from session totals (populated by the chat.message hook); defaults to 'unknown' when absent - Add 'agent' to api_request and api_error log record attributes - Handle SubtaskPart in handleMessagePartUpdated: increment new opencode.subtask.count counter (attrs: agent, session.id, project.id) and emit subtask_invoked log event with agent, description, prompt_length - Tag opencode.session.count and session.created log records with is_subagent boolean derived from Session.parentID presence - Store agent in SessionTotals; seed to 'unknown' on session.created, updated by chat.message hook on each user prompt - Add subtaskCounter to Instruments type and register instrument in otel.ts - 151 tests passing, typecheck clean
1 parent ef785fa commit 5dedc4e

10 files changed

Lines changed: 297 additions & 24 deletions

File tree

src/handlers/message.ts

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,20 @@ import type { AssistantMessage, EventMessageUpdated, EventMessagePartUpdated, To
33
import { errorSummary, setBoundedMap, accumulateSessionTotals, isMetricEnabled } from "../util.ts"
44
import type { HandlerContext } from "../types.ts"
55

6+
type SubtaskPart = {
7+
type: "subtask"
8+
sessionID: string
9+
messageID: string
10+
prompt: string
11+
description: string
12+
agent: string
13+
}
14+
615
/**
716
* Handles a completed assistant message: increments token and cost counters and emits
817
* either an `api_request` or `api_error` log event depending on whether the message errored.
18+
* The `agent` attribute is sourced from the session totals, which are populated by the
19+
* `chat.message` hook when the user prompt is received.
920
*/
1021
export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext) {
1122
const msg = e.properties.info
@@ -15,45 +26,47 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
1526

1627
const { sessionID, modelID, providerID } = assistant
1728
const duration = assistant.time.completed - assistant.time.created
29+
const agent = ctx.sessionTotals.get(sessionID)?.agent ?? "unknown"
1830

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

2234
if (isMetricEnabled("token.usage", ctx)) {
2335
const { tokenCounter } = ctx.instruments
24-
tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "input" })
25-
tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "output" })
26-
tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "reasoning" })
27-
tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" })
28-
tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" })
36+
tokenCounter.add(assistant.tokens.input, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "input" })
37+
tokenCounter.add(assistant.tokens.output, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "output" })
38+
tokenCounter.add(assistant.tokens.reasoning, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "reasoning" })
39+
tokenCounter.add(assistant.tokens.cache.read, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" })
40+
tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" })
2941
}
3042

3143
if (isMetricEnabled("cost.usage", ctx)) {
32-
ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID })
44+
ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent })
3345
}
3446

3547
if (isMetricEnabled("cache.count", ctx)) {
3648
if (assistant.tokens.cache.read > 0) {
37-
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheRead" })
49+
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheRead" })
3850
}
3951
if (assistant.tokens.cache.write > 0) {
40-
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, type: "cacheCreation" })
52+
ctx.instruments.cacheCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" })
4153
}
4254
}
4355

4456
if (isMetricEnabled("message.count", ctx)) {
45-
ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID })
57+
ctx.instruments.messageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent })
4658
}
4759

4860
if (isMetricEnabled("model.usage", ctx)) {
49-
ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID })
61+
ctx.instruments.modelUsageCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, provider: providerID, agent })
5062
}
5163

5264
accumulateSessionTotals(sessionID, totalTokens, assistant.cost, ctx)
5365

5466
ctx.log("debug", "otel: token+cost counters incremented", {
5567
sessionID,
5668
model: modelID,
69+
agent,
5770
input: assistant.tokens.input,
5871
output: assistant.tokens.output,
5972
reasoning: assistant.tokens.reasoning,
@@ -74,6 +87,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
7487
"session.id": sessionID,
7588
model: modelID,
7689
provider: providerID,
90+
agent,
7791
error: errorSummary(assistant.error),
7892
duration_ms: duration,
7993
...ctx.commonAttrs,
@@ -82,6 +96,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
8296
return ctx.log("error", "otel: api_error", {
8397
sessionID,
8498
model: modelID,
99+
agent,
85100
error: errorSummary(assistant.error),
86101
duration_ms: duration,
87102
})
@@ -98,6 +113,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
98113
"session.id": sessionID,
99114
model: modelID,
100115
provider: providerID,
116+
agent,
101117
cost_usd: assistant.cost,
102118
duration_ms: duration,
103119
input_tokens: assistant.tokens.input,
@@ -111,6 +127,7 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
111127
return ctx.log("info", "otel: api_request", {
112128
sessionID,
113129
model: modelID,
130+
agent,
114131
cost_usd: assistant.cost,
115132
duration_ms: duration,
116133
input_tokens: assistant.tokens.input,
@@ -121,9 +138,43 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext
121138
/**
122139
* Tracks tool execution time between `running` and `completed`/`error` part updates,
123140
* records a `tool.duration` histogram measurement, and emits a `tool_result` log event.
141+
* Also handles `subtask` parts, incrementing the sub-agent invocation counter and emitting
142+
* a `subtask_invoked` log event.
124143
*/
125144
export function handleMessagePartUpdated(e: EventMessagePartUpdated, ctx: HandlerContext) {
126145
const part = e.properties.part
146+
147+
if (part.type === "subtask") {
148+
const subtask = part as unknown as SubtaskPart
149+
if (isMetricEnabled("subtask.count", ctx)) {
150+
ctx.instruments.subtaskCounter.add(1, {
151+
...ctx.commonAttrs,
152+
"session.id": subtask.sessionID,
153+
agent: subtask.agent,
154+
})
155+
}
156+
ctx.logger.emit({
157+
severityNumber: SeverityNumber.INFO,
158+
severityText: "INFO",
159+
timestamp: Date.now(),
160+
observedTimestamp: Date.now(),
161+
body: "subtask_invoked",
162+
attributes: {
163+
"event.name": "subtask_invoked",
164+
"session.id": subtask.sessionID,
165+
agent: subtask.agent,
166+
description: subtask.description,
167+
prompt_length: subtask.prompt.length,
168+
...ctx.commonAttrs,
169+
},
170+
})
171+
return ctx.log("info", "otel: subtask_invoked", {
172+
sessionID: subtask.sessionID,
173+
agent: subtask.agent,
174+
description: subtask.description,
175+
})
176+
}
177+
127178
if (part.type !== "tool") return
128179

129180
const toolPart = part as ToolPart

src/handlers/session.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,22 @@ import type { HandlerContext } from "../types.ts"
55

66
/** Increments the session counter, records start time, and emits a `session.created` log event. */
77
export function handleSessionCreated(e: EventSessionCreated, ctx: HandlerContext) {
8-
const sessionID = e.properties.info.id
9-
const createdAt = e.properties.info.time.created
8+
const { id: sessionID, time, parentID } = e.properties.info
9+
const createdAt = time.created
10+
const isSubagent = !!parentID
1011
if (isMetricEnabled("session.count", ctx)) {
11-
ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID })
12+
ctx.instruments.sessionCounter.add(1, { ...ctx.commonAttrs, "session.id": sessionID, is_subagent: isSubagent })
1213
}
13-
setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0 })
14+
setBoundedMap(ctx.sessionTotals, sessionID, { startMs: createdAt, tokens: 0, cost: 0, messages: 0, agent: "unknown" })
1415
ctx.logger.emit({
1516
severityNumber: SeverityNumber.INFO,
1617
severityText: "INFO",
1718
timestamp: createdAt,
1819
observedTimestamp: Date.now(),
1920
body: "session.created",
20-
attributes: { "event.name": "session.created", "session.id": sessionID, ...ctx.commonAttrs },
21+
attributes: { "event.name": "session.created", "session.id": sessionID, is_subagent: isSubagent, ...ctx.commonAttrs },
2122
})
22-
return ctx.log("info", "otel: session.created", { sessionID, createdAt })
23+
return ctx.log("info", "otel: session.created", { sessionID, createdAt, isSubagent })
2324
}
2425

2526
function sweepSession(sessionID: string, ctx: HandlerContext) {

src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ export const OtelPlugin: Plugin = async ({ project, client }) => {
135135
},
136136

137137
"chat.message": safe("chat.message", async (input, output) => {
138+
const agent = input.agent ?? "unknown"
139+
const totals = sessionTotals.get(input.sessionID)
140+
if (totals) totals.agent = agent
138141
const promptLength = output.parts.reduce(
139142
(acc, p) => (p.type === "text" ? acc + p.text.length : acc),
140143
0,
@@ -148,7 +151,7 @@ export const OtelPlugin: Plugin = async ({ project, client }) => {
148151
attributes: {
149152
"event.name": "user_prompt",
150153
"session.id": input.sessionID,
151-
agent: input.agent ?? "unknown",
154+
agent,
152155
prompt_length: promptLength,
153156
model: input.model
154157
? `${input.model.providerID}/${input.model.modelID}`

src/otel.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,9 @@ export function createInstruments(prefix: string): Instruments {
133133
unit: "{retry}",
134134
description: "Number of API retries observed via session.status events",
135135
}),
136+
subtaskCounter: meter.createCounter(`${prefix}subtask.count`, {
137+
unit: "{subtask}",
138+
description: "Number of sub-agent invocations observed via subtask message parts",
139+
}),
136140
}
137141
}

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type Instruments = {
4949
sessionCostGauge: Histogram
5050
modelUsageCounter: Counter
5151
retryCounter: Counter
52+
subtaskCounter: Counter
5253
}
5354

5455
/** Accumulated per-session totals used for gauge snapshots on session.idle. */
@@ -57,6 +58,7 @@ export type SessionTotals = {
5758
tokens: number
5859
cost: number
5960
messages: number
61+
agent: string
6062
}
6163

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

src/util.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,6 @@ export function accumulateSessionTotals(
4848
tokens: existing.tokens + tokens,
4949
cost: existing.cost + cost,
5050
messages: existing.messages + 1,
51+
agent: existing.agent,
5152
})
5253
}

tests/handlers/disabled-metrics.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,15 +200,48 @@ describe("OPENCODE_DISABLE_METRICS", () => {
200200
})
201201
})
202202

203+
describe("subtask.count disabled", () => {
204+
test("does not increment subtask counter", async () => {
205+
const { ctx, counters } = makeCtx("proj_test", ["subtask.count"])
206+
const e = {
207+
type: "message.part.updated",
208+
properties: {
209+
part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" },
210+
},
211+
} as unknown as EventMessagePartUpdated
212+
await handleMessagePartUpdated(e, ctx)
213+
expect(counters.subtask.calls).toHaveLength(0)
214+
})
215+
216+
test("still emits subtask_invoked log record", async () => {
217+
const { ctx, logger } = makeCtx("proj_test", ["subtask.count"])
218+
const e = {
219+
type: "message.part.updated",
220+
properties: {
221+
part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" },
222+
},
223+
} as unknown as EventMessagePartUpdated
224+
await handleMessagePartUpdated(e, ctx)
225+
expect(logger.records.at(0)!.body).toBe("subtask_invoked")
226+
})
227+
})
228+
203229
describe("multiple disabled at once", () => {
204230
test("disabling all metrics stops all counter/histogram calls", async () => {
205231
const all = [
206232
"session.count", "token.usage", "cost.usage", "lines_of_code.count",
207233
"commit.count", "tool.duration", "cache.count", "session.duration",
208234
"message.count", "session.token.total", "session.cost.total",
209-
"model.usage", "retry.count",
235+
"model.usage", "retry.count", "subtask.count",
210236
]
211237
const { ctx, counters, histograms, gauges } = makeCtx("proj_test", all)
238+
const subtaskEvent = {
239+
type: "message.part.updated",
240+
properties: {
241+
part: { type: "subtask", sessionID: "ses_1", messageID: "msg_1", agent: "build", description: "desc", prompt: "prompt" },
242+
},
243+
} as unknown as EventMessagePartUpdated
244+
212245
await handleSessionCreated(makeSessionCreated("ses_1"), ctx)
213246
await handleMessageUpdated(makeAssistantMessage(), ctx)
214247
handleSessionIdle(makeSessionIdle("ses_1"), ctx)
@@ -217,6 +250,7 @@ describe("OPENCODE_DISABLE_METRICS", () => {
217250
handleCommandExecuted(makeCommandExecuted("git commit -m 'test'"), ctx)
218251
await handleMessagePartUpdated(makeToolPart("running"), ctx)
219252
await handleMessagePartUpdated(makeToolPart("completed"), ctx)
253+
await handleMessagePartUpdated(subtaskEvent, ctx)
220254

221255
expect(counters.session.calls).toHaveLength(0)
222256
expect(counters.token.calls).toHaveLength(0)
@@ -227,6 +261,7 @@ describe("OPENCODE_DISABLE_METRICS", () => {
227261
expect(counters.retry.calls).toHaveLength(0)
228262
expect(counters.lines.calls).toHaveLength(0)
229263
expect(counters.commit.calls).toHaveLength(0)
264+
expect(counters.subtask.calls).toHaveLength(0)
230265
expect(histograms.tool.calls).toHaveLength(0)
231266
expect(histograms.sessionDuration.calls).toHaveLength(0)
232267
expect(gauges.sessionToken.calls).toHaveLength(0)

0 commit comments

Comments
 (0)