From d465670ded1a8a3803f80e28625bf82fc9a078e2 Mon Sep 17 00:00:00 2001 From: syrac88 Date: Tue, 14 Jul 2026 21:28:20 +0200 Subject: [PATCH] feat: emit gen_ai.client.token.usage as an OTel Histogram per GenAI semconv Adds a Histogram instrument alongside the existing opencode.token.usage Counter so GenAI-semconv-compliant backends (e.g. Splunk Observability Cloud AI Agent Monitoring) can populate their token tiles, which require histogram-typed metrics and silently ignore counters regardless of name. The metric name is intentionally unprefixed (fixed by the spec), uses the semconv-recommended bucket boundaries, and skips zero-valued token types so the distribution is not distorted. Fully additive: the existing opencode.token.usage Counter is unchanged. Co-Authored-By: Claude Fable 5 --- README.md | 1 + src/handlers/message.ts | 25 ++++++++++++++++++++++- src/otel.ts | 10 +++++++++ src/types.ts | 1 + tests/handlers/message.test.ts | 37 ++++++++++++++++++++++++++++++++++ tests/helpers.ts | 5 ++++- 6 files changed, 77 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1e75f8f..90e1602 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ An [opencode](https://opencode.ai) plugin that exports telemetry via OpenTelemet |--------|------|-------------| | `opencode.session.count` | Counter | Incremented on each `session.created` event | | `opencode.token.usage` | Counter | Per token type: `input`, `output`, `reasoning`, `cacheRead`, `cacheCreation` | +| `gen_ai.client.token.usage` | Histogram | Same token data shaped per the [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage), dimensioned by `gen_ai.token.type`, `gen_ai.request.model`, `gen_ai.provider.name`, `gen_ai.operation.name`. **Not prefixed** — the name is fixed by the spec so semconv-aware backends (e.g. Splunk Observability Cloud AI Agent Monitoring) can populate their token tiles. Zero-valued token types are not recorded. Disable via `OPENCODE_DISABLE_METRICS=gen_ai.client.token.usage`. | | `opencode.cost.usage` | Counter | USD cost per completed assistant message | | `opencode.lines_of_code.count` | Counter | **Gross positive churn, not a net total.** Emits the positive delta of `additions`/`deletions` since the previous `session.diff` for the same session; negative deltas (when opencode's cumulative `additions` or `deletions` shrinks vs. the last event) are dropped. Summing the counter therefore reports gross lines added/removed across forward transitions — it does *not* reconcile back to the session's current state after any revert (full or partial). Intra-message rewrites that opencode collapses in its per-message cumulative are not visible here at all. Use `opencode.lines_of_code.total` for the authoritative live cumulative. | | `opencode.lines_of_code.total` | Gauge | **Authoritative live cumulative lines added/removed for the session.** Refreshed on every `session.diff` with opencode's current cumulative value. Drops back to `0` if opencode reports a revert to baseline, and tracks partial reverts faithfully. Query this (not the counter) to answer "what does this session currently amount to". | diff --git a/src/handlers/message.ts b/src/handlers/message.ts index 568536e..8d381f0 100644 --- a/src/handlers/message.ts +++ b/src/handlers/message.ts @@ -52,7 +52,8 @@ type SubtaskPart = { } /** - * Handles a completed assistant message: increments token and cost counters, emits + * Handles a completed assistant message: increments token and cost counters, records + * the `gen_ai.client.token.usage` histogram (OTel GenAI semantic conventions), emits * either an `api_request` or `api_error` log event, and ends the LLM span for this message. * The `agent` attribute is sourced from the session totals, which are populated by the * `chat.message` hook when the user prompt is received. @@ -81,6 +82,28 @@ export function handleMessageUpdated(e: EventMessageUpdated, ctx: HandlerContext tokenCounter.add(assistant.tokens.cache.write, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent, type: "cacheCreation" }) } + if (isMetricEnabled("gen_ai.client.token.usage", ctx)) { + const { genaiTokenHistogram } = ctx.instruments + const genaiTokens: Array<[string, number]> = [ + ["input", assistant.tokens.input], + ["output", assistant.tokens.output], + ["reasoning", assistant.tokens.reasoning], + ["cacheRead", assistant.tokens.cache.read], + ["cacheCreation", assistant.tokens.cache.write], + ] + for (const [type, value] of genaiTokens) { + // Zero measurements would distort the distribution (histograms count every record). + if (value <= 0) continue + genaiTokenHistogram.record(value, { + ...ctx.commonAttrs, + "gen_ai.operation.name": "chat", + "gen_ai.provider.name": providerID, + "gen_ai.request.model": modelID, + "gen_ai.token.type": type, + }) + } + } + if (isMetricEnabled("cost.usage", ctx)) { ctx.instruments.costCounter.add(assistant.cost, { ...ctx.commonAttrs, "session.id": sessionID, model: modelID, agent }) } diff --git a/src/otel.ts b/src/otel.ts index 27a2bcf..e5abe73 100644 --- a/src/otel.ts +++ b/src/otel.ts @@ -156,6 +156,16 @@ export function createInstruments(prefix: string): Instruments { unit: "tokens", description: "Number of tokens used", }), + // Deliberately not prefixed: the metric name is fixed by the OTel GenAI semantic + // conventions, and semconv-strict backends (e.g. Splunk AI Agent Monitoring) only + // recognise it under this exact name and as a Histogram. + genaiTokenHistogram: meter.createHistogram("gen_ai.client.token.usage", { + unit: "{token}", + description: "Number of input and output tokens used, per OTel GenAI semantic conventions", + advice: { + explicitBucketBoundaries: [1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864], + }, + }), costCounter: meter.createCounter(`${prefix}cost.usage`, { unit: "USD", description: "Cost of the opencode session in USD", diff --git a/src/types.ts b/src/types.ts index 2aa33cb..9055188 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,7 @@ export type PendingPermission = { export type Instruments = { sessionCounter: Counter tokenCounter: Counter + genaiTokenHistogram: Histogram costCounter: Counter linesCounter: Counter linesTotalGauge: Gauge diff --git a/tests/handlers/message.test.ts b/tests/handlers/message.test.ts index f5afb39..8b5fef8 100644 --- a/tests/handlers/message.test.ts +++ b/tests/handlers/message.test.ts @@ -143,6 +143,43 @@ describe("handleMessageUpdated", () => { expect(inputCall.attrs["team"]).toBe("platform") }) + test("records gen_ai.client.token.usage histogram per token type with GenAI attributes", async () => { + const { ctx, histograms } = makeCtx("proj_test", [], [], true, { team: "platform" }) + await handleMessageUpdated( + makeAssistantMessageUpdated({ + tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 20, write: 5 } }, + }), + ctx, + ) + const types = histograms.genaiToken.calls.map((c) => c.attrs["gen_ai.token.type"]) + expect(types).toEqual(["input", "output", "reasoning", "cacheRead", "cacheCreation"]) + const inputCall = histograms.genaiToken.calls.find((c) => c.attrs["gen_ai.token.type"] === "input")! + expect(inputCall.value).toBe(100) + expect(inputCall.attrs["gen_ai.operation.name"]).toBe("chat") + expect(inputCall.attrs["gen_ai.provider.name"]).toBe("anthropic") + expect(inputCall.attrs["gen_ai.request.model"]).toBe("claude-3-5-sonnet") + expect(inputCall.attrs["team"]).toBe("platform") + }) + + test("skips zero-valued token types in gen_ai.client.token.usage histogram", async () => { + const { ctx, histograms } = makeCtx() + await handleMessageUpdated( + makeAssistantMessageUpdated({ + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + }), + ctx, + ) + const types = histograms.genaiToken.calls.map((c) => c.attrs["gen_ai.token.type"]) + expect(types).toEqual(["input", "output"]) + }) + + test("does not record gen_ai.client.token.usage when disabled", async () => { + const { ctx, histograms, counters } = makeCtx("proj_test", ["gen_ai.client.token.usage"]) + await handleMessageUpdated(makeAssistantMessageUpdated({}), ctx) + expect(histograms.genaiToken.calls).toHaveLength(0) + expect(counters.token.calls).not.toHaveLength(0) + }) + test("increments cost counter", async () => { const { ctx, counters } = makeCtx() await handleMessageUpdated(makeAssistantMessageUpdated({ cost: 0.05 }), ctx) diff --git a/tests/helpers.ts b/tests/helpers.ts index 14d2bff..b4f6474 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -160,6 +160,7 @@ export type MockContext = { histograms: { tool: SpyHistogram sessionDuration: SpyHistogram + genaiToken: SpyHistogram } gauges: { sessionToken: SpyHistogram @@ -190,6 +191,7 @@ export function makeCtx( const subtask = makeCounter() const toolHistogram = makeHistogram() const sessionDurationHistogram = makeHistogram() + const genaiTokenHistogram = makeHistogram() const sessionTokenGauge = makeHistogram() const sessionCostGauge = makeHistogram() const linesTotalGauge = makeGauge() @@ -200,6 +202,7 @@ export function makeCtx( const instruments: Instruments = { sessionCounter: session as unknown as Counter, tokenCounter: token as unknown as Counter, + genaiTokenHistogram: genaiTokenHistogram as unknown as Histogram, costCounter: cost as unknown as Counter, linesCounter: lines as unknown as Counter, linesTotalGauge: linesTotalGauge as unknown as Gauge, @@ -247,7 +250,7 @@ export function makeCtx( return { ctx, counters: { session, token, cost, lines, commit, cache, message, modelUsage, retry, subtask }, - histograms: { tool: toolHistogram, sessionDuration: sessionDurationHistogram }, + histograms: { tool: toolHistogram, sessionDuration: sessionDurationHistogram, genaiToken: genaiTokenHistogram }, gauges: { sessionToken: sessionTokenGauge, sessionCost: sessionCostGauge, linesTotal: linesTotalGauge }, logger, pluginLog,