Telemetry & cost
LiveDDK answers two questions about every governed call: what happened? and who pays for it? It does so with OpenTelemetry GenAI spans, per-run correlation IDs, cost-attribution tags, and routing and resilience signals.
OpenTelemetry GenAI spans
Every governed call produces a span following the OpenTelemetry GenAI
semantic conventions — gen_ai.system, gen_ai.request.model,
gen_ai.usage.input_tokens, gen_ai.usage.output_tokens — plus attributes
for the governance layer that generic instrumentation cannot know about:
donkey.policy.decision = allow | refuse
donkey.policy.type = pii_detected | token_budget | injection | …
donkey.budget.remaining = 18450
donkey.correlation_id = …
donkey.cost.team = support
donkey.cost.project = triage-v2
donkey.cost.env = prod
donkey.cost.enduser.id = user-42
donkey.routing.type = ModelBased # how the gateway routed
donkey.routing.fallback = false # did it fail over?
gen_ai.response.model = gpt-5.1 # the model that actually served
donkey.cache.status = hit # semantic-cache outcome (on a cached proxy)
donkey.cache.score = 0.9518 # similarity score, hit only
donkey.usage.cached_tokens = 512 # omitted when the provider reports none
donkey.usage.cache_write_tokens = 128
donkey.usage.reasoning_tokens = 96The three donkey.usage.* counts carry the cost-relevant detail tokens the
semantic conventions have no pinned key for — cached / cache-write prompt tokens
and reasoning-model thinking tokens. They are read from the response usage
block’s detail sub-objects and are omitted, never 0, when the provider
reports no detail counts. When the response passes through the SDK’s shared HTTP
client, the same counts are exposed per-call on donkey.last_call.
A refused request still produces a span, with donkey.policy.decision=refuse
and otel.status_code=ERROR. A streaming response produces exactly one span,
with token counts filled in at stream end.
Export goes over OTLP to wherever you already send spans. Nothing in the emit path is Anypoint-specific, so if your team already runs Grafana, Elastic, Honeycomb, New Relic, Datadog, Dynatrace, Langfuse or Phoenix, “policy refusals per hour by type” and “tokens per ticket” show up in the dashboard you already have. See Send spans to your observability backend.
Zero-config export
Set the standard OpenTelemetry endpoint env var and spans flow — there is no SDK-specific variable.
pip install "donkey-kit[otel]"
export OTEL_EXPORTER_OTLP_ENDPOINT=https://langfuse.acme.internal
export OTEL_SERVICE_NAME=support-triage # standard OTel var, honoured for free
python -m my_app # spans flow, refused calls includedDonkey.from_env() reads OTEL_EXPORTER_OTLP_ENDPOINT (or the traces-specific
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) and, when one is set, installs an OTLP
exporter behind a batch processor. The network flush runs on that background
thread, off your request path, keeping instrumentation overhead under
1 ms per call. The [otel] extra ships the http/protobuf exporter;
OTEL_EXPORTER_OTLP_PROTOCOL=grpc is honoured only if you also install
opentelemetry-exporter-otlp-proto-grpc.
With no endpoint set, the export path is inert and silent — no exporter is
built, nothing connects, nothing is printed. If your process already configures
its own OpenTelemetry provider (say via opentelemetry-instrument), DDK rides it
rather than replacing it, so your spans flow through the pipeline you already
set up.
Opt out of telemetry entirely with a single flag:
export DONKEY_TELEMETRY=false # or telemetry = false in .donkey-kit.tomlSend spans to your observability backend
Any backend that accepts OTLP works with the same three standard variables:
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS for the vendor’s
credentials, and OTEL_SERVICE_NAME. There is no DDK-specific setting and no
code change between backends. Pick yours:
Grafana Cloud
Grafana Cloud stores traces in Tempo. In the Grafana Cloud portal, open your
stack and choose Configure on the OpenTelemetry tile. It generates the
endpoint for your region and a token, already base64-encoded as
instanceID:token.
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otlp-gateway-prod-<region>.grafana.net/otlp"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<base64 instanceID:token>"
export OTEL_SERVICE_NAME="support-triage"Grafana’s own note for Python: write the space after Basic as %20. Find the
spans in Explore → Tempo or in Application Observability. For
production, Grafana recommends sending through Grafana Alloy (its
OpenTelemetry Collector) rather than straight from the app.
A few rules hold for every backend:
- Base endpoint vs traces endpoint.
OTEL_EXPORTER_OTLP_ENDPOINTis a base URL: the exporter appends/v1/traces. If you setOTEL_EXPORTER_OTLP_TRACES_ENDPOINTinstead, give the full URL including/v1/traces, or the backend answers404. - Encode spaces in headers. Header values such as
Basic <token>orApiKey <key>should be written with%20in place of the space; separate several headers with commas. - Protocol. DDK exports
http/protobuf, which every backend above accepts. For gRPC, setOTEL_EXPORTER_OTLP_PROTOCOL=grpcand installopentelemetry-exporter-otlp-proto-grpc. - In production, consider a Collector. Sending to an OpenTelemetry Collector (or a vendor distribution such as Grafana Alloy) lets you batch, retry, redact and fan out to several backends without touching the app.
- Prompts stay private by default. None of these backends receives prompt or completion text unless you opt in; see Message content stays off spans by default.
Semantic-convention stability and sinks
The GenAI conventions are still Development status upstream, so
attribute names can change. DDK pins the semconv version and
dual-emits: gen_ai.* at the pinned version, plus a stable donkey.*
namespace under this project’s control. Your dashboards do not break when
upstream renames something.
DDK exports standard OTLP and leaves the sink to you. Whether Anypoint Monitoring or Agent Visualizer ingests OTLP GenAI spans is not publicly documented, so don’t rely on spans appearing there.
Message content stays off spans by default
Spans carry metadata only — model, token counts, policy decision, cost tags, correlation id. They do not carry prompt or completion text.
Spans are emitted upstream of the gateway’s PII masking. The Omni Gateway masks sensitive content in its logs; a DDK span is created inside your process, before the request reaches the gateway. Putting message text on the span would re-export the very content the platform masks — straight to whatever OTLP collector you have wired up. So capturing content is opt-in, and opting in is you assuming that obligation.
Turn it on only when your collector is a trusted sink and you have accepted responsibility for the content that lands there:
# kwarg, or DONKEY_TELEMETRY_CAPTURE_CONTENT=true, or
# telemetry_capture_content = true in .donkey-kit.toml
donkey = Donkey.from_env(telemetry_capture_content=True)telemetry_capture_content resolves along the standard precedence
(kwarg → env → .donkey-kit.toml → default) and defaults to False. When
enabled, content is emitted under the pinned semconv attribute names —
gen_ai.prompt and gen_ai.completion — and no others. When off, those
attributes never reach a span, and the allowlist that builds every span drops
any content-shaped attribute a call site hands it, so there is no accidental
path for message text to leak.
Routing & resilience
The gateway can fail over between providers when one degrades (“Enhanced
Resilience for Intelligent Routing”). It reports what it did with each request
on the response — which provider and model served it, how it routed, and whether
that was a fallback. DDK reads those signals off the shared transport, so
you get them with no framework required — the raw donkey.llm.client() path
benefits just as the deep adapters do.
Every governed call through the SDK’s shared HTTP client exposes them on
donkey.last_call, beside the usage and identity fields:
donkey = Donkey.from_env()
await donkey.openai().responses.create(model="gpt-5.1", input="…")
r = donkey.last_call
r.requested_model # "gpt-5.1" — what you asked for
r.served_model # "gpt-5.1" — or a substitute after failover
r.served_provider # "openai"
r.routing_type # "ModelBased" — or "Semantic" on a semantic-routing proxy
r.fallback # False — True if the gateway failed over
r.substituted # False — served_model != requested_model
r.matched_topic # None — the topic a Semantic proxy matched (else None)
r.routing_score # None — that match's similarity score (else None)On a semantic-routing proxy (routing_type == "Semantic"), the gateway also
reports why it picked a provider: the topic your prompt matched and the
similarity score behind that match. Those land on r.matched_topic /
r.routing_score. On a model-based proxy both are None — the gateway emits no
semantic header, and DDK never fabricates a value for a signal it did not
observe.
They also land on the span (gen_ai.response.model, donkey.routing.type,
donkey.routing.fallback, and — on a semantic route —
donkey.routing.matched_topic / donkey.routing.score) — the most useful thing
to have on hand when latency spikes: it tells an operator whether a slow call was
routed normally or recovered from a degraded provider.
For the complete list of last_call fields — observability status, gateway
identity, routing, and token usage — see the last_call field
reference.
The same record also carries the call’s identity and usage: request_id (the
upstream provider’s id), api_instance_id and environment_id (which gateway
instance served it), and the token counts including cached and reasoning
tokens. The gateway identity example walks
through every field.
When last_call is unavailable
donkey.last_call is populated only when the governed response passes through
the SDK’s shared httpx client. Four connection_kwargs()-only adapters route
outside that response path: ADK and CrewAI send requests through LiteLLM, while
LlamaIndex and Microsoft Agent Framework receive only default_headers.
That static snapshot excludes the correlation ID bound later by
donkey.run(id=...), so those two adapters also do not propagate the run’s
correlation ID.
When every adapter resolved on a Donkey is one of those four, a cold read
reports the limitation explicitly. For a Donkey that resolved only ADK:
r = donkey.last_call
r.status # LastCallStatus.UNAVAILABLE
r.available # False
r.surface # "adk"This is different from UNOBSERVED, which means the current context has not
yet received a governed response. On an unavailable surface the SDK cannot
observe any response-derived last_call field, including gateway identity,
routing, fallback, and usage. If multiple non-observing adapters were resolved,
surface lists their names.
Two behaviours worth knowing
The SDK never double-retries a fallback. DDK retries 502/503/504 with
backoff, but if the gateway already failed over internally, a 503 it marked as
a fallback is not retried again — a second recovery layer stacked on a
working first one just multiplies latency against an outage the gateway already
handled.
Opt in to model determinism. A silent substitution is surfaced passively on
last_call.substituted by default. When a substitution is not acceptable — your
evaluation, cost model and token assumptions are all pinned to one model — opt
into a hard error:
donkey = Donkey.from_env(on_model_substitution="raise")
# raises ModelSubstituted when the served model differs from the requested oneon_model_substitution resolves along the standard precedence (kwarg → env
DONKEY_ON_MODEL_SUBSTITUTION → .donkey-kit.toml → default) and defaults to
"off".
On a model-based routing proxy you address models as provider/model (for
example openai/gpt-5-mini), and the gateway reports the served model without
the prefix (gpt-5-mini), with the provider in its own header. A prefix that
names the served provider is not counted as a difference, so asking for
openai/gpt-5-mini and being served gpt-5-mini by openai is not a
substitution. The same model served by a different provider still is.
Semantic caching & semantic routing Live
Omni Gateway can answer a request from its semantic cache (a similar
prompt was answered before, so no provider round-trip and no token cost) and
can route semantically (pick the model by matching the prompt to a
topic). Both happen at the gateway. DDK caches nothing and computes no
embeddings itself; it lets you steer the gateway’s cache per request — skip it,
not store a response, or override the TTL or similarity threshold via
donkey.cache(...) — and reports what
happened:
- the cache outcome (
hit,miss,bypass,no-store) and similarity score ondonkey.last_call.cache_status/.cache_scoreand the span (donkey.cache.status/donkey.cache.score); - the matched routing topic and its score, beside the routing fields above.
A cache hit is a verbatim replay with no provider round-trip, so it never
advances the token budget — the replayed usage is not fresh spend.
TypeScript parity Roadmap
The same signals will surface as donkey.lastRouting.servedModel / .fallback
in the TypeScript SDK.
Correlation IDs
Set a per-run id once, and every call inside the block carries it — on the wire, on every span, and on every exception — with nothing threaded through your framework state.
async with donkey.run(id=ticket.id):
await triage_agent.run(ticket)donkey.run(id=…) binds two ids:
- a run id → the
X-Correlation-Idrequest header → the span’sdonkey.correlation_id→DonkeyError.correlation_id. Shared by every call in the block, so a client-side log line joins to the gateway’s own record. - a fresh per-call id → the
X-Donkey-Request-Idrequest header →DonkeyError.call_id. Unique per logical request, stable across that request’s retries, so one call is pinpointable within a run.
The gateway reads the inbound X-Correlation-Id and echoes it verbatim on the
response, so the request and response x-correlation-id are the same value.
X-Donkey-Request-Id is a client-owned per-call id the gateway does not consume.
Both header names are overridable (correlation_header / call_id_header) for a
gateway that expects different ones.
Propagation is contextvar-based, so it reaches through framework nodes (every
LangGraph node, for instance) without threading an argument through every
function, and concurrent runs never leak into each other. It works with or
without OpenTelemetry installed. donkey.run(...) is a dual sync/async
context manager (plain with works too); nested blocks rebind then restore.
The decorator form
When a whole function should be one run, @donkey.governed is the decorator
equivalent of wrapping its body in donkey.run():
@donkey.governed(team="support")
async def handle_ticket(ticket):
await triage_agent.run(ticket)Each call opens its own run — a fresh run/correlation id — and binds the
optional per-run cost tags, the OTel span, and typed refusals, exactly the scope
donkey.run() establishes. It wraps both sync and async callables and is
usable bare (@donkey.governed) or parametrised. There is deliberately
no id=: pinning one id across every call would collapse unrelated runs into
a single correlation, so when you need a specific id, use donkey.run(id=…)
directly.
Cost-attribution tags
A small, fixed set of tags — team, project, env, enduser.id — set once
and emitted on every call, both as request headers and as donkey.cost.* span
attributes:
donkey = Donkey.from_env(team="support", project="triage-v2", env="prod")
async with donkey.run(id=ticket.id, enduser_id=agent_user.id):
await triage_agent.run(ticket)The tags resolve along the standard precedence — Donkey.from_env(team=…)
kwargs, then DONKEY_COST_* env vars, then a [donkey.cost] table in
.donkey-kit.toml. Per-run overrides layer on top: donkey.run(team=…, project=…, env=…, enduser_id=…) wins per field for its block and the rest
fall back to the configured tags. The key set is fixed — an unknown
dimension is a configuration error, never a silently-dropped header. Values are
validated — fixed keys, bounded length — so nobody stuffs a JSON blob into a
header.
The Anypoint LLM Gateway does not ingest cost tags from request headers — it
meters cost from token usage per API instance and consuming client
application. The authoritative carrier is the donkey.cost.* OTel span
attribute. The X-Anypoint-Cost-* request headers are a convention nothing
currently reads; their names are overridable (cost_*_header) for a gateway
that does read one.
The question this answers
Finance asks what the support agent cost last month versus the HR bot. Without
tags, both agents share one client_id and there is no way to split the bill.
With tags it is a group-by.
For compliance — “prove the HR bot’s answer to user X on date Y went through
the content-safety policy” — the correlation ID on the log line joins to the
gateway record, and the span carries enduser.id and donkey.policy.type. An
EU AI Act Article 12 log request becomes one query, not an investigation.