A support assistant drafts a refund reply. A teammate approves it the next morning. A week later the customer disputes it, and someone asks which model wrote the draft, what it was given, and who approved it. The answers exist, scattered across an API gateway log, a model provider dashboard and a ticketing tool, with nothing that reliably joins them.
Distributed tracing was built for exactly this joining problem. This post shows how to trace an AI-assisted decision from request to human approval with OpenTelemetry, and where tracing stops being enough.
One ID for the whole story
The W3C Trace Context Recommendation (23 November 2021) defines the traceparent header for passing trace context between services. Its trace-id is a 16-byte value that, in the spec's words, is "used to uniquely identify a distributed trace through a system". Every span created while handling a request shares it.
For an AI feature, the useful trace usually crosses more than one process and more than one day:
request --> retrieve context --> chat model-a --> tool call
| |
+------------- trace_id 4bf9... (same) ------------+
|
draft stored with traceparent
|
next day: reviewer approves (new trace,
linked back to the original)
The synchronous part is one trace. The human review happens later, in another process, so it gets its own trace with a link back to the original. A span link lets the review point at the span that caused it without pretending a day-long gap is one operation.
Label the model call with shared names
OpenTelemetry's GenAI semantic conventions describe how to label a model call. They recently moved to their own repository and are marked "Status: Development", so names can still change. As of today, the client inference page says the span name should be {gen_ai.operation.name} {gen_ai.request.model}, for example chat model-a, and lists attributes such as:
-
gen_ai.operation.nameandgen_ai.provider.name(required); -
gen_ai.request.model(conditionally required, if available); -
gen_ai.response.modelandgen_ai.response.id(recommended).
Recording both the requested and the returned model name matters when an alias resolves to a new snapshot. The general GenAI spans page also says a span should cover all automatic retries of one logical operation, so retries don't look like separate decisions.
The code
This uses the OpenTelemetry Python API and SDK. call_model stands in for your provider client.
from opentelemetry import trace
from opentelemetry.trace import Link, SpanKind
from opentelemetry.propagate import inject, extract
tracer = trace.get_tracer("support-assistant")
MODEL = "model-a-2026-01-15" # pinned ID, not an alias
def draft_reply(ticket: dict, call_model) -> dict:
with tracer.start_as_current_span("draft_reply"):
with tracer.start_as_current_span(
f"chat {MODEL}", kind=SpanKind.CLIENT) as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "example")
span.set_attribute("gen_ai.request.model", MODEL)
resp = call_model(MODEL, ticket["text"])
span.set_attribute("gen_ai.response.model", resp["model"])
span.set_attribute("gen_ai.response.id", resp["id"])
carrier = {}
inject(carrier) # writes the W3C traceparent
return {"ticket_id": ticket["id"], "text": resp["text"],
"trace": carrier} # stored with the draft
def approve(draft: dict, reviewer_id: str) -> None:
origin = trace.get_current_span(extract(draft["trace"]))
with tracer.start_as_current_span(
"review.approve",
links=[Link(origin.get_span_context())]) as span:
span.set_attribute("app.reviewer.id", reviewer_id)
span.set_attribute("app.ticket.id", draft["ticket_id"])
The important line is inject(carrier): the draft carries its own traceparent, so whoever acts on it later can link back. A test with the SDK's in-memory exporter keeps that property from regressing:
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
InMemorySpanExporter)
def test_review_links_to_model_call():
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
fake = lambda m, t: {"model": m, "id": "resp_1", "text": "ok"}
draft = draft_reply({"id": "T-1", "text": "refund?"}, fake)
approve(draft, "u_17")
spans = {s.name: s for s in exporter.get_finished_spans()}
chat, review = spans[f"chat {MODEL}"], spans["review.approve"]
assert review.context.trace_id != chat.context.trace_id
assert review.links[0].context.trace_id == chat.context.trace_id
Keep prompts out of spans by default
The same GenAI spans page is blunt about content: instructions, inputs and outputs are "considered sensitive and are often large in size", and instrumentations "SHOULD NOT capture them by default". Where telemetry volume is a concern or sensitive data needs careful handling, it recommends storing content externally and recording references on spans, which allows separate access controls. The example above records IDs and model names, not the ticket text.
Where tracing stops
Traces are built for operations, and two properties make them a weak record of a decision:
- Sampling. Trace Context's sampled flag only says the caller "may have recorded trace data", and the spec lists probability sampling among common techniques. A disputed decision may simply not have a trace.
- Retention. Trace storage usually has a retention window chosen for debugging, which can be far shorter than the time in which a decision can be questioned.
So treat the trace ID as a join key, not as the record. Write the decision itself (draft, model, reviewer, outcome) to storage you control and retain on purpose. Key it by the identifier a reviewer will actually arrive with, here the ticket ID, and include the trace_id so the operational detail can be found while it still exists.
Where this fits
Linking a decision to the evidence behind it is the problem we work on at Obligra. Obligra Verify™ is a system of record for AI-assisted decisions, in public preview, designed to retain the evidence your application selects. Its docs have a longer piece on retrieving records by operational context, the business-ID side of the same idea. The trace-ID-as-join-key habit is useful whatever your record store is.
This article was generated by an AI agent (Claude) for the Obligra Verify team and checked against the linked sources.
Top comments (0)