Skip to content

otelprovider: agent spans carry no token usage or model, so there is no cost visibility (the data is already in the stream) #493

Description

Summary

otelprovider's span carries the agent's identity but not its cost. There is currently no way to see token usage or the model on a trace, even though both are already available at the point where the span is written.

The result is a stopwatch with good labels: you can see that an agent ran and for how long, but not what it spent.

What the span emits today

Running an agent with otelprovider.NewMiddleware(...) attached:

span: BrainAgent   18.14s
  gen_ai.operation.name    = invoke_agent
  gen_ai.provider.name     = anthropic
  gen_ai.agent.id          = e7f04186-...
  gen_ai.agent.name        = BrainAgent
  gen_ai.agent.description =

That is the whole attribute set.

What is missing, and why it is easy

The OpenTelemetry GenAI semantic conventions define all of these, and the constants are already in the semconv package this repo imports:

  • gen_ai.usage.input_tokens (semconv.GenAIUsageInputTokens)
  • gen_ai.usage.output_tokens (semconv.GenAIUsageOutputTokens)
  • gen_ai.request.model (semconv.GenAIRequestModel)

And the data is already flowing past the middleware. anthropicprovider emits usage into the response stream (provider/anthropicprovider/agent.go:109):

contents = append(contents, &message.UsageContent{
    Details: toUsageDetails(resp.Usage),
})

message.UsageDetails already carries InputTokenCount, OutputTokenCount, TotalTokenCount, CachedInputTokenCount, ReasoningTokenCount.

So the middleware, which already iterates the stream, can accumulate *message.UsageContent and set the attributes on the span it already opened. I grepped the repo: input_tokens currently appears only inside serialization helpers, never as a span attribute.

Why this is the attribute set people actually want

Token count is cost, and cost is the main reason to trace an agent at all. Concretely, from a real run of a 5-agent workflow once I added these attributes locally:

workflow:ticket-to-brief   303s
  researcher   66s  turns=6  tool_calls=14  in=197,057  out=3,195
  grapher      45s  turns=2  tool_calls=5   in=111,940  out=2,512
  author       27s  turns=2  tool_calls=5   in= 10,493  out=1,957
  critic       84s  turns=4  tool_calls=12  in= 75,790  out=5,515
  publisher    35s  turns=2  tool_calls=2   in= 15,692  out=3,414
  ────────────────────────────────────────────────────────────
  TOTAL  410,972 in / 16,593 out

The researcher costing ~2x the author is not visible without this, and it is the single most useful thing the trace can tell you.

Suggested addition

In the middleware's stream loop, accumulate message.UsageDetails and set on span end:

  • gen_ai.request.model (from AgentConfig.Model; the middleware would need it passed via MiddlewareConfig, or the provider could set it)
  • gen_ai.usage.input_tokens / gen_ai.usage.output_tokens / gen_ai.usage.total_tokens
  • optionally gen_ai.usage.cached_input_tokens and gen_ai.usage.reasoning_tokens when non-zero

Deliberately NOT suggesting cost. Prices go stale and are deployment-specific; a framework asserting a stale price is worse than one asserting none. Exposing usage is the right primitive — let people price it themselves.

I have this working locally and am happy to open a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions