fix(tracing): stamp prompt-cache token attributes on the LLM span (#441) - #443
Conversation
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
left a comment
There was a problem hiding this comment.
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_creationeach when> 0, andtotal_input_tokensviaresp.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.completionusage-attr block;ExecuteStreamdelegates toExecute, so there is no divergent path silently missing the new attrs. Stamped onllmSpanbefore 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
- Intentional presence asymmetry vs the audit event. In #432 the audit's
total_input_tokensis ALWAYS present (equalsinput_tokenswhen caching is off) — deliberately, so security-next#36'stotal_input_tokens ?? input_tokensfallback stays on its fast path. On the span it is conditional (caching only). That is a defensible choice — a span total that just duplicatesinput_tokensis 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. - Trivia: the
_ = pline 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())) |
There was a problem hiding this comment.
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.
Fixes #441.
Problem
The
llm.completionspan stamped onlygen_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 thellm_callaudit event, which does carry them. That broke the documented trace↔audit invariant ("span_idresolves to the span carrying matchinggen_ai.usage.*tokens").Fix
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 existinggen_ai.usage.*prefix.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 stampscache_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-lintclean;runtime+observabilitysuites pass.Scope
Flat cache counts only. The nested
cache_creation.{ephemeral_5m,ephemeral_1h}_input_tokensTTL split (from the user's report) is tracked separately in #442 and builds on this plumbing.Docs
observability-tracing.mdspan-attribute table +audit-logging.mdtrace-link note updated.https://claude.ai/code/session_01Hkimw1PDJRY5Dh8BgNQxWJ