Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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". |
Expand Down
25 changes: 24 additions & 1 deletion src/handlers/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 })
}
Expand Down
10 changes: 10 additions & 0 deletions src/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type PendingPermission = {
export type Instruments = {
sessionCounter: Counter
tokenCounter: Counter
genaiTokenHistogram: Histogram
costCounter: Counter
linesCounter: Counter
linesTotalGauge: Gauge
Expand Down
37 changes: 37 additions & 0 deletions tests/handlers/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export type MockContext = {
histograms: {
tool: SpyHistogram
sessionDuration: SpyHistogram
genaiToken: SpyHistogram
}
gauges: {
sessionToken: SpyHistogram
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading