Skip to content

Commit bd56988

Browse files
suryaiyer95claude
andauthored
feat: track per-generation token usage in telemetry (#336)
- Emit `generation` telemetry event on every LLM step-finish with model_id, provider_id, agent, finish_reason, cost, duration_ms, and token breakdown - Token fields are flat to comply with Azure App Insights custom measurements schema: `tokens_input`, `tokens_output`, and optionally `tokens_reasoning`, `tokens_cache_read`, `tokens_cache_write` - Optional token fields are only included when the provider actually returns them — reasoning only for reasoning models, cache fields only when active - Remove unused `TokensPayload` type and special-case serializer handler - Step duration tracked from `start-step` to `finish-step` events - Update telemetry.md with accurate generation event field description - Update existing tests for flat token field shape Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 22651a6 commit bd56988

5 files changed

Lines changed: 51 additions & 35 deletions

File tree

docs/docs/reference/telemetry.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ We collect the following categories of events:
1111
| `session_start` | A new CLI session begins |
1212
| `session_end` | A CLI session ends (includes duration) |
1313
| `session_forked` | A session is forked from an existing one |
14-
| `generation` | An AI model generation completes (model ID, token counts, duration — no prompt content) |
14+
| `generation` | An AI model generation (step) completes model ID, provider ID, agent, finish reason, cost, duration, and token breakdown: input, output, and when available: reasoning tokens (reasoning models only), cache-read tokens (prompt cache hit), cache-write tokens (new cache entry). No prompt content. |
1515
| `tool_call` | A tool is invoked (tool name and category — no arguments or output) |
1616
| `native_call` | A native engine call completes (method name and duration — no arguments) |
1717
| `command` | A CLI command is executed (command name only) |

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,6 @@ export namespace Telemetry {
1414
const MAX_BUFFER_SIZE = 200
1515
const REQUEST_TIMEOUT_MS = 10_000
1616

17-
export type TokensPayload = {
18-
input: number
19-
output: number
20-
reasoning: number
21-
cache_read: number
22-
cache_write: number
23-
}
24-
2517
export type Event =
2618
| {
2719
type: "session_start"
@@ -50,9 +42,15 @@ export namespace Telemetry {
5042
provider_id: string
5143
agent: string
5244
finish_reason: string
53-
tokens: TokensPayload
5445
cost: number
5546
duration_ms: number
47+
// Flat token fields — only present when data is available from the provider.
48+
// No nested objects: Azure App Insights custom measures must be top-level numbers.
49+
tokens_input: number
50+
tokens_output: number
51+
tokens_reasoning?: number // only for reasoning models
52+
tokens_cache_read?: number // only when a cached prompt was reused
53+
tokens_cache_write?: number // only when a new cache entry was written
5654
}
5755
| {
5856
type: "tool_call"
@@ -571,14 +569,9 @@ export namespace Telemetry {
571569
}
572570
const measurements: Record<string, number> = {}
573571

574-
// Flatten all fields — nested `tokens` object gets prefixed keys
575572
for (const [k, v] of Object.entries(fields)) {
576573
if (k === "session_id" || k === "project_id" || k === "_retried") continue
577-
if (k === "tokens" && typeof v === "object" && v !== null) {
578-
for (const [tk, tv] of Object.entries(v as Record<string, unknown>)) {
579-
if (typeof tv === "number") measurements[`tokens_${tk}`] = tv
580-
}
581-
} else if (typeof v === "number") {
574+
if (typeof v === "number") {
582575
measurements[k] = v
583576
} else if (v !== undefined && v !== null) {
584577
properties[k] = typeof v === "object" ? JSON.stringify(v) : String(v)

packages/opencode/src/session/processor.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { PermissionNext } from "@/permission/next"
1616
import { Question } from "@/question"
1717
import { PartID } from "./schema"
1818
import type { SessionID, MessageID } from "./schema"
19+
// altimate_change start — import Telemetry for per-generation token tracking
20+
import { Telemetry } from "@/altimate/telemetry"
21+
// altimate_change end
1922

2023
export namespace SessionProcessor {
2124
const DOOM_LOOP_THRESHOLD = 3
@@ -35,6 +38,9 @@ export namespace SessionProcessor {
3538
let blocked = false
3639
let attempt = 0
3740
let needsCompaction = false
41+
// altimate_change start — per-step generation telemetry
42+
let stepStartTime = Date.now()
43+
// altimate_change end
3844

3945
const result = {
4046
get message() {
@@ -233,6 +239,9 @@ export namespace SessionProcessor {
233239

234240
case "start-step":
235241
snapshot = await Snapshot.track()
242+
// altimate_change start — record step start time for generation telemetry duration
243+
stepStartTime = Date.now()
244+
// altimate_change end
236245
await Session.updatePart({
237246
id: PartID.ascending(),
238247
messageID: input.assistantMessage.id,
@@ -251,6 +260,26 @@ export namespace SessionProcessor {
251260
input.assistantMessage.finish = value.finishReason
252261
input.assistantMessage.cost += usage.cost
253262
input.assistantMessage.tokens = usage.tokens
263+
// altimate_change start — emit per-generation telemetry with token breakdown
264+
// Optional fields are only included when the provider actually returns them.
265+
Telemetry.track({
266+
type: "generation",
267+
timestamp: Date.now(),
268+
session_id: input.sessionID,
269+
message_id: input.assistantMessage.id,
270+
model_id: input.model.id,
271+
provider_id: input.model.providerID,
272+
agent: input.assistantMessage.agent,
273+
finish_reason: value.finishReason ?? "unknown",
274+
cost: usage.cost,
275+
duration_ms: Date.now() - stepStartTime,
276+
tokens_input: usage.tokens.input,
277+
tokens_output: usage.tokens.output,
278+
...(value.usage.reasoningTokens !== undefined && { tokens_reasoning: usage.tokens.reasoning }),
279+
...(value.usage.cachedInputTokens !== undefined && { tokens_cache_read: usage.tokens.cache.read }),
280+
...(usage.tokens.cache.write > 0 && { tokens_cache_write: usage.tokens.cache.write }),
281+
})
282+
// altimate_change end
254283
await Session.updatePart({
255284
id: PartID.ascending(),
256285
reason: value.finishReason,

packages/opencode/test/session/processor.test.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -468,20 +468,18 @@ describe("generation telemetry", () => {
468468
provider_id: "anthropic",
469469
agent: "builder",
470470
finish_reason: "end_turn",
471-
tokens: {
472-
input: 1000,
473-
output: 500,
474-
reasoning: 200,
475-
cache_read: 800,
476-
cache_write: 100,
477-
},
471+
tokens_input: 1000,
472+
tokens_output: 500,
473+
tokens_reasoning: 200,
474+
tokens_cache_read: 800,
475+
tokens_cache_write: 100,
478476
cost: 0.05,
479477
duration_ms: 3000,
480478
}
481479

482480
expect(event.model_id).toBe("claude-opus-4-6")
483-
expect(event.tokens.input).toBe(1000)
484-
expect(event.tokens.cache_read).toBe(800)
481+
expect(event.tokens_input).toBe(1000)
482+
expect(event.tokens_cache_read).toBe(800)
485483
expect(event.cost).toBe(0.05)
486484
expect(event.finish_reason).toBe("end_turn")
487485
})

packages/opencode/test/telemetry/telemetry.test.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ describe("telemetry.toAppInsightsEnvelopes (indirect)", () => {
624624
}
625625
})
626626

627-
test("nested tokens object is flattened with tokens_ prefix", async () => {
627+
test("flat token fields appear in measurements", async () => {
628628
const { fetchCalls, cleanup } = await initWithMockedFetch()
629629
try {
630630
Telemetry.track({
@@ -636,13 +636,11 @@ describe("telemetry.toAppInsightsEnvelopes (indirect)", () => {
636636
provider_id: "anthropic",
637637
agent: "builder",
638638
finish_reason: "end_turn",
639-
tokens: {
640-
input: 100,
641-
output: 200,
642-
reasoning: 50,
643-
cache_read: 10,
644-
cache_write: 5,
645-
},
639+
tokens_input: 100,
640+
tokens_output: 200,
641+
tokens_reasoning: 50,
642+
tokens_cache_read: 10,
643+
tokens_cache_write: 5,
646644
cost: 0.01,
647645
duration_ms: 2000,
648646
})
@@ -656,8 +654,6 @@ describe("telemetry.toAppInsightsEnvelopes (indirect)", () => {
656654
expect(measurements.tokens_reasoning).toBe(50)
657655
expect(measurements.tokens_cache_read).toBe(10)
658656
expect(measurements.tokens_cache_write).toBe(5)
659-
// Raw "tokens" key should not appear in properties
660-
expect(envelopes[0].data.baseData.properties.tokens).toBeUndefined()
661657
} finally {
662658
cleanup()
663659
}

0 commit comments

Comments
 (0)