DEV Community

Cover image for Designing LLM Trace Spans: What to Capture, What to Drop
Gabriel Anhaia
Gabriel Anhaia

Posted on

Designing LLM Trace Spans: What to Capture, What to Drop


You add tracing to your LLM service in an afternoon. You wrap the model call in a span, stuff the full prompt and the full response into attributes, ship it, and move on. Three weeks later two things have happened. Your tracing bill has a comma in it that was not there before, and a customer's social security number is sitting in plaintext in a span your whole company can read.

Both are span-design problems. A span is not a log line you can dump everything into. It is a fixed-shape record you query, alert on, and pay per byte to store. What you put in it decides whether your traces answer questions in six months or just sit there as expensive sludge.

This is the attribute set worth capturing, the payloads worth dropping, and the size and sampling rules that keep the whole thing affordable.

Start from the questions, not the data

The trap is capturing everything because you can. Storage is cheap until it is not, and a span crammed with fields nobody queries is pure cost. Work backward from the questions you will ask at 2am instead.

You will ask: which model served this, how many tokens, how long, how much did it cost, which prompt version, did it error, and which user session does it belong to. Every one of those is a single scalar. None of them is the prompt text.

So the spine of a good LLM span is a handful of cheap, high-cardinality-aware scalars. The full prompt and response are a separate decision, made later, under different rules.

The attributes that pay rent

These map onto the OpenTelemetry GenAI semantic conventions. The names have churned over the last year. gen_ai.system became gen_ai.provider.name, and the token usage fields moved from prompt_tokens/completion_tokens to input_tokens/output_tokens. So check the GenAI semconv registry rather than copying an old blog post.

gen_ai.request.model        "gpt-4o-2024-11-20"
gen_ai.provider.name        "openai", "anthropic", "aws.bedrock"
gen_ai.usage.input_tokens   int
gen_ai.usage.output_tokens  int
gen_ai.request.temperature  float
gen_ai.response.finish_reasons  array
gen_ai.conversation.id      stable id across turns
Enter fullscreen mode Exit fullscreen mode

Then the fields the spec does not cover yet. They go in a project-prefixed namespace so nobody confuses them with the standard:

app.llm.cost_usd            float, computed at emit time
app.llm.prompt_version      "support-v7", or a git sha
app.llm.judge.score         float 0-1, if you run an eval step
app.rag.retrieved_count     int
app.rag.top_score           float
Enter fullscreen mode Exit fullscreen mode

Two of these earn their place loudly. app.llm.cost_usd, computed from your own price table at emit time, because the provider's billing endpoint will never settle fast enough to drive an alert. And app.llm.prompt_version, because when judge scores drop you will want to slice by the exact prompt that shipped, and a free-text prompt blob will not let you do that. A version string will.

A minimal emitter

This assumes the OTel SDK is already initialized.

from opentelemetry import trace

tracer = trace.get_tracer("app.llm")

# USD per 1K tokens (in, out). Verify against the
# provider's pricing page; these rotate.
COSTS = {
    "gpt-4o-2024-11-20": (0.0025, 0.0100),
    "claude-sonnet-4-5": (0.003, 0.015),
}


def usd(model, in_tok, out_tok):
    cin, cout = COSTS.get(model, (0.0, 0.0))
    return (in_tok / 1000) * cin + (out_tok / 1000) * cout


def emit_llm_span(model, provider, usage,
                  conv_id, prompt_version):
    with tracer.start_as_current_span("gen_ai.chat") as s:
        s.set_attribute("gen_ai.request.model", model)
        s.set_attribute("gen_ai.provider.name", provider)
        s.set_attribute(
            "gen_ai.usage.input_tokens", usage["in"]
        )
        s.set_attribute(
            "gen_ai.usage.output_tokens", usage["out"]
        )
        s.set_attribute("gen_ai.conversation.id", conv_id)
        s.set_attribute(
            "app.llm.prompt_version", prompt_version
        )
        s.set_attribute(
            "app.llm.cost_usd",
            usd(model, usage["in"], usage["out"]),
        )
Enter fullscreen mode Exit fullscreen mode

Notice what is not here: the prompt text and the model output. Those follow a different set of rules.

What to redact before anything leaves the process

If you capture prompt and response content at all, assume both contain whatever your users typed, which means email addresses, names, card numbers, occasionally a password someone pasted by accident. That data lands in a tracing backend a lot of people in your company can read, often a third-party SaaS. Treat the span content like any other PII sink.

The rule that holds up: redact at emit time, in your own process, before the data hits the exporter. Do not rely on the backend to scrub it — by then it has already crossed a network boundary you do not control.

A scrubber that runs on the content before it becomes an attribute:

import re

PATTERNS = {
    "email": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
    "card": re.compile(r"\b(?:\d[ -]*?){13,16}\b"),
    "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
}


def redact(text):
    for label, pat in PATTERNS.items():
        text = pat.sub(f"[{label}]", text)
    return text
Enter fullscreen mode Exit fullscreen mode

Run this on the content, then truncate, then set the attribute. Regex scrubbing is a floor, not a ceiling (names and addresses slip past it), so for regulated data the safer default is to not capture raw content at all and instead store a hash or a structured summary. The OpenTelemetry GenAI conventions reflect this: capturing message content is opt-in for exactly this reason, and you turn it on deliberately, per environment.

Payload-size limits

Spans are not built for large blobs. A long agent conversation can carry tens of kilobytes of prompt history per turn, and OTel exporters have default attribute-value length limits that will silently truncate you anyway — often at 128 characters unless you raise the span limits. Discovering that truncation after an incident, when the field you needed got cut, is a bad time.

So decide truncation on purpose. Cap content attributes at a length you choose, and record that you did:

MAX_CHARS = 4096


def capped(text, span, key):
    if len(text) > MAX_CHARS:
        span.set_attribute(f"{key}.truncated", True)
        span.set_attribute(f"{key}.original_len", len(text))
        text = text[:MAX_CHARS]
    span.set_attribute(key, text)
Enter fullscreen mode Exit fullscreen mode

For anything genuinely large — full RAG context, multi-turn transcripts — keep it out of the span entirely. Write it to object storage keyed by trace id and store only the pointer in the span. Your traces stay small and queryable, while the bulky payload lives somewhere built for bulk and priced for it. The span answers what happened. The blob store answers show-me-everything, only when you ask.

Sampling hooks

Capturing every span at full fidelity is the line item that surprises people. LLM traffic is expensive to store because the payloads are big, and most of it is unremarkable — successful, cheap, fast requests you will never look at.

Head-based sampling decides at the start of a trace, before you know anything interesting, so it throws away failures at the same rate as successes. For LLM systems that is backwards. The 0.3% of traces that errored, cost a lot, or scored badly on the judge are exactly the ones worth keeping.

Tail-based sampling, run in the collector, decides after the trace finishes and can read its attributes. That is the hook that matters. Keep everything that is interesting, sample the boring remainder hard:

processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      - name: keep-errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: keep-expensive
        type: numeric_attribute
        numeric_attribute:
          key: app.llm.cost_usd
          min_value: 1
      - name: sample-the-rest
        type: probabilistic
        probabilistic: {sampling_percentage: 5}
Enter fullscreen mode Exit fullscreen mode

That config keeps every error, keeps every trace that cost more than a dollar, and keeps 5% of everything else. The cheap scalar attributes you set at emit time are what make this possible — the sampler reads app.llm.cost_usd to make the keep decision, which is one more reason to compute cost in-process rather than after the fact.

The shape that holds up

Cheap scalars for everything you query and alert on. Content captured deliberately, redacted in-process, truncated on purpose, with the big stuff pushed to object storage. Tail sampling that keeps the interesting traces and drops the boring majority. That is a span you can still afford and still query in six months, instead of one you regret.

The hard part was never wrapping the call. It is deciding what not to keep.

If you want the full attribute set, the redaction patterns by data class, and the sampling configs worked through against real backends, the LLM Observability Pocket Guide covers what a healthy span looks like and how to keep it that way as model versions and conventions rotate underneath you.

LLM Observability Pocket Guide

Top comments (0)