Skip to content

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

Description

@initializ-mk

Problem

The LLM span (gen_ai / llm.completion) stamps only input + output token counts. The prompt-cache token counts forge already parses are dropped from the span, so cache stats aren't visible in traces:

forge-core/runtime/loop.go:531-533

llmSpan.SetAttributes(
    attribute.Int(observability.AttrGenAIUsageInputTokens, resp.Usage.InputTokens),
    attribute.Int(observability.AttrGenAIUsageOutputTokens, resp.Usage.OutputTokens),
)

resp.Usage already carries CacheReadInputTokens and CacheCreationInputTokens (added in #431 / merged #432), and the llm_call audit event already emits them — but the span does not. There are no cache-token attribute constants in forge-core/observability/attrs.go (only AttrGenAIUsageInputTokens / AttrGenAIUsageOutputTokens at :53-54).

A user observing an Anthropic response wants these on the span:

"usage": {
    "input_tokens": 27,
    "cache_creation_input_tokens": 0,
    "cache_read_input_tokens": 0,
    "cache_creation": { "ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0 },
    "output_tokens": 5
}

Impact

  1. Cache stats invisible in traces. Operators can see input/output on the span but not how much of the input was a cache hit (cache_read) vs a cache write (cache_creation) — the numbers that explain cost on cache-heavy runs.
  2. Span diverges from the audit event. Since fix(usage): capture Anthropic cache tokens + emit total_input_tokens (#431) #432 the llm_call audit event carries cache_read_input_tokens / cache_creation_input_tokens / total_input_tokens, but the span carries none of them. This breaks the documented trace↔audit consistency: docs/security/audit-logging.md states an llm_call row's span_id "resolves to the llm.completion span carrying matching gen_ai.usage.* tokens" — they no longer match.

Fix

Stamp the cache tokens on the LLM span alongside input/output, mirroring the audit-event parity #432 established.

  1. Add attribute constants in forge-core/observability/attrs.go next to AttrGenAIUsageInputTokens (:53-54). OTel GenAI semconv does not (yet) standardize cache-token attributes, so follow forge's existing gen_ai.usage.* prefix:
    • AttrGenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read_input_tokens"
    • AttrGenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation_input_tokens"
    • (optional) AttrGenAIUsageTotalInputTokens = "gen_ai.usage.total_input_tokens" — the bill-from sum, matching the audit event's total_input_tokens.
  2. Stamp them at loop.go:531-533. Use omitempty-style conditionals so non-caching / non-Anthropic calls don't add zero-valued attributes (keep span cardinality/shape stable), matching how the audit emitter treats the cache fields:
    attrs := []attribute.KeyValue{
        attribute.Int(observability.AttrGenAIUsageInputTokens, resp.Usage.InputTokens),
        attribute.Int(observability.AttrGenAIUsageOutputTokens, resp.Usage.OutputTokens),
    }
    if resp.Usage.CacheReadInputTokens > 0 {
        attrs = append(attrs, attribute.Int(observability.AttrGenAIUsageCacheReadInputTokens, resp.Usage.CacheReadInputTokens))
    }
    if resp.Usage.CacheCreationInputTokens > 0 {
        attrs = append(attrs, attribute.Int(observability.AttrGenAIUsageCacheCreationInputTokens, resp.Usage.CacheCreationInputTokens))
    }
    llmSpan.SetAttributes(attrs...)

Related (secondary — flag, likely a separate follow-up)

Forge parses only the flat cache_read_input_tokens / cache_creation_input_tokens (forge-core/llm/providers/anthropic.go:348-349). The user's example also carries the newer nested breakdown:

"cache_creation": { "ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0 }

i.e. the extended-cache TTL split (5-minute vs 1-hour). Forge does not parse cache_creation.* at all, so even after the span fix the 5m/1h split can't be surfaced. Decide whether v1 stops at the flat counts (recommended — matches the audit event) or also parses the nested TTL breakdown into new UsageInfo fields + span attributes.

Acceptance

  • LLM span carries gen_ai.usage.cache_read_input_tokens and gen_ai.usage.cache_creation_input_tokens when non-zero.
  • Non-caching / non-Anthropic calls emit no zero-valued cache attributes (span shape unchanged for them).
  • Span cache tokens match the llm_call audit event's cache fields for the same call (trace↔audit consistency restored).
  • loop_spans_test.go asserts the new attributes on a cache-heavy call (extend the existing attr-map assertions at :132-134).
  • docs/core-concepts/observability-tracing.md span-attribute table + the docs/security/audit-logging.md trace-link note updated.

Files

File Change
forge-core/observability/attrs.go Add cache-token attribute constants (:53-54 neighborhood).
forge-core/runtime/loop.go Stamp cache attrs on the LLM span (:531-533).
forge-core/runtime/loop_spans_test.go Assert new attrs on a cache-heavy call (:132-134).
docs/core-concepts/observability-tracing.md, docs/security/audit-logging.md Span-attribute table + trace-link consistency note.

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions