Skip to content

fix(tracing): stamp prompt-cache token attributes on the LLM span (#441) - #443

Merged
initializ-mk merged 1 commit into
mainfrom
fix/span-cache-token-attrs
Sep 5, 2026
Merged

fix(tracing): stamp prompt-cache token attributes on the LLM span (#441)#443
initializ-mk merged 1 commit into
mainfrom
fix/span-cache-token-attrs

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Fixes #441.

Problem

The llm.completion span stamped only gen_ai.usage.input_tokens / output_tokens (loop.go:531-533). The prompt-cache counts forge already parses (resp.Usage.CacheReadInputTokens / CacheCreationInputTokens, live since #431/#432) were dropped from the span — so cache stats were invisible in traces, and the span diverged from the llm_call audit event, which does carry them. That broke the documented trace↔audit invariant ("span_id resolves to the span carrying matching gen_ai.usage.* tokens").

Fix

  • New attribute constants (observability/attrs.go): gen_ai.usage.cache_read_input_tokens, gen_ai.usage.cache_creation_input_tokens, gen_ai.usage.total_input_tokens. OTel GenAI semconv has no standard cache-token attributes yet, so they follow forge's existing gen_ai.usage.* prefix.
  • Stamp them on the LLM span (loop.go), only when non-zero so non-caching / non-Anthropic (e.g. OpenAI) calls keep their exact span shape. total_input_tokens (= input + cache_read + cache_creation) is emitted whenever caching contributed, matching the audit event's always-present bill-from field.

Tests

TestExecuteStampsCacheTokensOnLLMSpan: a cache-heavy turn stamps cache_read=4000, cache_creation=200, total_input=4212; a non-caching turn omits all three cache attributes (present-vs-zero distinction asserted). Existing span-tree tests unchanged.

gofmt + golangci-lint clean; runtime + observability suites pass.

Scope

Flat cache counts only. The nested cache_creation.{ephemeral_5m,ephemeral_1h}_input_tokens TTL split (from the user's report) is tracked separately in #442 and builds on this plumbing.

Docs

observability-tracing.md span-attribute table + audit-logging.md trace-link note updated.

https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ

The llm.completion span carried only gen_ai.usage.input_tokens /
output_tokens, so the prompt-cache counts forge already parses
(cache_read/creation, since #431/#432) were invisible in traces — and
the span diverged from the llm_call audit event, which does carry them.

- Add gen_ai.usage.cache_read_input_tokens / cache_creation_input_tokens
  / total_input_tokens attribute constants (observability/attrs.go),
  following forge's existing gen_ai.usage.* prefix (OTel GenAI semconv
  has no standard cache-token attributes yet).
- Stamp them on the LLM span (loop.go), only when non-zero so
  non-caching / non-Anthropic calls keep their span shape; total is
  emitted whenever caching contributed, matching the audit event's
  always-present bill-from field.

This restores trace<->audit consistency: an llm_call row's span_id now
resolves to a span carrying matching cache tokens.

Tests: cache-heavy call stamps read/creation/total on the span; a
non-caching call omits the cache attributes entirely. Docs:
observability-tracing.md span table + audit-logging.md trace-link note.

Scope: flat cache counts only. The nested cache_creation.{5m,1h} TTL
split is tracked separately in #442.

Claude-Session: https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve-grade — a faithful, minimal extension of the #431/#432 cache-token plumbing that restores the trace↔audit invariant. Traced against the branch source; all 10 CI checks green.

Verified

  • Correct math — appends cache_read / cache_creation each when > 0, and total_input_tokens via resp.Usage.TotalInputTokens() (= input + cache_read + cache_creation, the helper added in #432). Test asserts 12 / 4000 / 200 → total 4212.
  • Non-zero gating mirrors the audit event's omitempty — non-caching / non-Anthropic (OpenAI) spans keep their exact prior shape. The test asserts the three cache attrs are absent, not zero on the second span via a presence map — the present-vs-zero distinction that actually matters for backends gating on key presence.
  • Single span site — this is the one llm.completion usage-attr block; ExecuteStream delegates to Execute, so there is no divergent path silently missing the new attrs. Stamped on llmSpan before the AfterLLMCall hook / span close, same timing as input/output.
  • attrs.go + docs — three gen_ai.usage.* constants (forge prefix, since OTel GenAI has no standard cache-token keys yet), and both doc tables accurately state "only when non-zero / caching contributed."

Non-blocking

  1. Intentional presence asymmetry vs the audit event. In #432 the audit's total_input_tokens is ALWAYS present (equals input_tokens when caching is off) — deliberately, so security-next#36's total_input_tokens ?? input_tokens fallback stays on its fast path. On the span it is conditional (caching only). That is a defensible choice — a span total that just duplicates input_tokens is noise for human trace inspection, and traces have no machine-fallback consumer. The docs describe the span behavior accurately; only the commit message's "matching the audit event's always-present bill-from field" slightly over-claims presence parity. Just worth being explicit that the two surfaces differ on presence by design.
  2. Trivia: the _ = p line in the new test is vestigial (the first-span presence map is captured but unused beyond the value assertions) — trivial cleanup.

Nice, tightly-scoped fix.

usageAttrs = append(usageAttrs, attribute.Int(observability.AttrGenAIUsageCacheCreationInputTokens, resp.Usage.CacheCreationInputTokens))
}
if resp.Usage.CacheReadInputTokens > 0 || resp.Usage.CacheCreationInputTokens > 0 {
usageAttrs = append(usageAttrs, attribute.Int(observability.AttrGenAIUsageTotalInputTokens, resp.Usage.TotalInputTokens()))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conditional is the one spot worth calling out: the spans total_input_tokens is present only when caching contributed, whereas the #432 audit events total_input_tokens is ALWAYS present (it equals input_tokens with no caching). That asymmetry is intentional and correct — the audit always-emits so security-next#36 can read total_input_tokens ?? input_tokens without a branch, while on a span a total that merely duplicates input_tokens is redundant noise. So this is the right call for the span; I only flag it so nobody later assumes the two surfaces have identical presence semantics and writes a trace consumer expecting total_input_tokens to always be there. The value itself (via TotalInputTokens()) matches the audit row exactly, which is what restores the pivot invariant.

@initializ-mk
initializ-mk merged commit 7737dbe into main Sep 5, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LLM span drops prompt-cache token attributes (cache_read/creation not on gen_ai span; diverges from llm_call audit)

1 participant