feat: promote allowlisted caller context onto every agent span - #2575
feat: promote allowlisted caller context onto every agent span#2575rtemperini wants to merge 4 commits into
Conversation
Agent spans record what an agent did but not who asked for it, so traces cannot be filtered or grouped by the calling user, Slack thread, or ticket that triggered them. Read an operator-defined allowlist of context keys from W3C baggage and A2A message metadata and merge them into the request-scoped attribute bag. The existing span processor then stamps them on every span of the request, which is what trace-level filtering in Langfuse and comparable backends requires; attaching them to the root span alone leaves most views unfilterable. Baggage is the primary source because it already survives the controller, agent, sub-agent, and tool hops under the composite propagator, so a value set once at the edge needs no further plumbing. A2A message metadata covers callers that cannot set headers and, being per-message, takes precedence. Caller data is untrusted, so only allowlisted keys are read, the allowlist is capped, values are truncated and stripped of control characters, and every attribute is namespaced under kagent.context. so it cannot shadow a semantic convention attribute such as service.name. The allowlist is empty by default, which disables promotion entirely. Signed-off-by: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Bring the Python runtimes to parity with the Go ADK. The Go runtime already reads A2A message metadata into span attributes (kagent-dev#1734, kagent-dev#1737); the Python runtimes ignored inbound metadata entirely, so agents on the Python runtime had no way to get caller identity onto their traces. Mirror the Go implementation exactly: the same KAGENT_TRACE_CONTEXT_KEYS allowlist, the same baggage-then-metadata precedence, the same limits, the same control character stripping, and the same kagent.context. namespace, so the two runtimes cannot drift. Values are merged into the request-scoped attribute bag that KagentAttributesSpanProcessor stamps onto every span. The ADK, LangGraph, and CrewAI executors all promote context through the shared helper. Reading a Message's protobuf Struct metadata moves into kagent.core.a2a.read_message_metadata rather than being repeated per package. Also assert that the OTel SDK's default propagator carries baggage: the Python runtime relies on that default rather than configuring a propagator, so an SDK change that dropped it would silently break the baggage path. Signed-off-by: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Expose the allowlist as the Helm value otel.tracing.contextKeys, rendered into the controller ConfigMap as KAGENT_TRACE_CONTEXT_KEYS and forwarded to the agents the controller creates. The variable needs explicit forwarding because collectOtelEnvFromProcess carries only OTEL_ prefixed names. Which caller-supplied data reaches a trace backend is cluster-wide operator policy, so the value is applied after the Harness environment, and any inherited entry of the same name is dropped first. Without that second step a Harness could enable promotion whenever the operator had configured nothing. The value defaults to an empty list, so the ConfigMap key is absent and the runtimes promote nothing unless an operator opts in. Signed-off-by: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Cover the configuration knob, why baggage is the primary propagation mechanism, why the attributes are stamped on every span rather than the root, the safety properties that bound untrusted caller input, and how to rename attributes in the OTel Collector for a backend that expects its own names. Signed-off-by: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
krisztianfekete
left a comment
There was a problem hiding this comment.
Thanks, added a few initial comments!
| Agent spans describe *what the agent did*, but they say nothing about *who asked | ||
| for it*. Kagent can promote a configurable allowlist of caller-supplied values — | ||
| the signed-in user's email, a Slack thread, a support ticket ID — onto every | ||
| span of a request, so traces can be filtered and grouped by the caller in | ||
| Langfuse, Jaeger, Grafana Tempo, or any other OTLP backend. |
There was a problem hiding this comment.
Totally agree with the goal, but as per the OTel's recommendations: https://opentelemetry.io/docs/security/handling-sensitive-data/, email addresses and names should never be attributes at all.
OIDC already hands you an opaque sub, which is what user.id should use. Could we make sub the example instead of the email?
| # as kagent.context.<key>. Values are read from W3C baggage and A2A message | ||
| # metadata. Empty (the default) disables promotion. | ||
| # e.g. ["user.email", "user.name", "thread_id", "channel"] | ||
| contextKeys: [] |
There was a problem hiding this comment.
Let's use subs everywhere as exampels as mentioned in the md file above.
| Every promoted value becomes a span attribute named `kagent.context.<key>`: | ||
|
|
||
| ``` | ||
| baggage: user.email=ada@example.com → kagent.context.user.email = "ada@example.com" | ||
| metadata: {"thread_id": "1717171.42"} → kagent.context.thread_id = "1717171.42" |
There was a problem hiding this comment.
Same as above, let's not document it like this as it's an anti-pattern.
| | Oversized spans | Values are truncated to 256 characters, keys to 64 | | ||
| | Log or trace injection | Control characters are stripped from values | | ||
| | Shadowing semantic conventions | Every attribute is namespaced under `kagent.context.`, so `service.name` and friends cannot be overwritten even if an operator allowlists them | | ||
| | Leaking secrets into a trace backend | Nothing is promoted unless an operator names the key; raw values are never logged | |
There was a problem hiding this comment.
Again, emails and username should not be here as per the guidance because it's a compliance question. Operators should also not do this. Also baggage is on HTTP headers, and today this data goes to api.openai.com and every HTTP MCP server too.
| KagentTraceContextKeys = RegisterStringVar( | ||
| "KAGENT_TRACE_CONTEXT_KEYS", | ||
| "", | ||
| "Comma-separated allowlist of caller-supplied context keys promoted onto every agent span as "+ | ||
| "kagent.context.<key>. Values are read from W3C baggage and A2A message metadata. "+ | ||
| "Empty (the default) disables promotion.", | ||
| ComponentAgentRuntime, | ||
| ) |
There was a problem hiding this comment.
If you don't want to lose the OIDC format the recommendation is to hash it, and user.hash is a registry attribute that exists for exactly that. Something like:
contextKeys:
- {from: sub, to: user.id}
- {from: email, to: user.hash, hash: hmac-sha256}
- {from: thread_id, to: kagent.thread_id}
| // contextAttributePrefix namespaces every promoted value. Because the prefix | ||
| // is applied unconditionally, caller-supplied data cannot shadow a semantic | ||
| // convention attribute such as service.name. | ||
| contextAttributePrefix = "kagent.context." |
There was a problem hiding this comment.
The prefix is right for custom keys e.g. channel, but it also blocks the names that do exist in the registry. user.id, enduser.id, session.id are all real attributes, and we should use the semconv names before inventing new ones.
Could we let a small fixed set through unprefixed (user.*, enduser.*, session.id) and prefix everything else?
There was a problem hiding this comment.
Also reflect this in tests.
Motivation
Agent spans record what an agent did but not who asked for it. Once a request
enters kagent the caller's identity and context are gone, so traces cannot be
filtered or grouped by the calling user, the conversation thread, or the ticket
that triggered the run.
Two common deployment shapes hit this:
from an OIDC token. That email should appear on every downstream operation —
tool calls, A2A delegations, MCP calls, model calls.
the thread, and the channel. None of it reaches the trace.
#1734 raised the second case
and was closed by #1737, which
promotes A2A
message.metadataintoa2a.message.metadata.*attributes. Thatleft two gaps:
message.metadataentirely.SetMessageMetadataAttributeswrites to the span currentat A2A entry, so descendant spans inherit nothing.
Gap 2 is the one that breaks the use case: Langfuse and comparable backends
resolve trace-level filters against the attributes present on each span, so an
attribute on the invocation span alone leaves most views unfilterable.
What this changes
An operator names the context keys they want traced. Both runtimes read those
keys from W3C Baggage and from A2A
message.metadata, sanitise them, andmerge them into the request-scoped attribute bag that
KagentAttributesSpanProcessor/kagentAttributesSpanProcessoralready stampsonto every span of the request.
Adding a new traced value is a configuration change, not a code change.
Design rationale
Why baggage
Baggage is the vendor-neutral OTel answer to this problem, and the plumbing
already exists: the controller, both runtimes, and every instrumented HTTP client
run a composite
tracecontext + baggagepropagator. A value set once at the edgesurvives controller → agent → sub-agent → tool without kagent adding any
hop-specific mechanism, and it requires no kagent-specific knowledge from the
caller — any OTel SDK or proxy can set it.
A2A
message.metadataremains supported as the complement, for callers that canset message fields but not transport headers. Because it is scoped to one message
it is the more specific source, so it wins on conflict.
Alternatives considered and rejected: a fixed set of
user/thread/channelfields (not extensible, needs a code change per new field); custom HTTP headers
(reinvents baggage, does not cross hops); stamping only the root span (does not
satisfy the per-span requirement above).
Why the attributes go in the request-scoped bag
The bag is the only place where a value is applied by the span processor at
OnStartfor every span, including spans created by upstream ADK, the MCPclient, and the model instrumentation — code kagent does not own and cannot
instrument individually.
Why one knob instead of an enable flag plus a list
An
enabled: truewith an empty allowlist is a state that does nothing but lookslike it should. Making the allowlist itself the switch removes that state: empty
means off, non-empty means on, and a contradiction cannot be expressed.
Feature flag
KAGENT_TRACE_CONTEXT_KEYS(env) /otel.tracing.contextKeys(Helm)The controller forwards the variable to the agents it creates. It needs explicit
forwarding because
collectOtelEnvFromProcesscarries onlyOTEL_prefixednames.
Which caller data reaches a trace backend is cluster-wide operator policy, so the
value is applied after the
Harnessenvironment and any inherited entry of thesame name is dropped first. Without that second step a
Harnesscould enablepromotion whenever the operator had configured nothing at all; there is a test
for exactly that.
Security considerations
Caller-supplied context is untrusted input on both paths, so promotion is
constrained on every axis:
kagent.context., soservice.nameand friends cannot be overwritten even if an operator allowlists them — structural, not a denylistHarnessenv entry is dropped, not inheritedNon-scalar metadata (objects, arrays) is skipped: unbounded in size, meaningless
as an attribute value.
The docs state plainly that anything allowlisted is visible to everyone with
access to the trace backend, and that callers control the values.
Backwards compatibility
Fully backwards compatible.
contextKeysset, the ConfigMap key is absent, the envvar is unset, the helper returns immediately, and not a single span attribute
changes.
a2a.message.metadata.*attributes from feat(go-adk): propagate A2A message metadata as OTEL span attributes #1737 are untouched and still unconditional in the Go runtime.
go.opentelemetry.io/otelandopentelemetry-api, both already required.Runtime parity
The Go and Python implementations share the same allowlist parsing, the same
baggage-then-metadata precedence, the same limits, the same control-character
definition (Python's
_is_controldeliberately matches Go'sunicode.IsControl), rune-based rather than byte-based truncation on both sides,and the same
kagent.context.namespace. Both are covered by equivalent testcases so the two cannot drift silently.
The ADK, LangGraph, and CrewAI Python executors all promote context. Reading a
Message's protobufStructmetadata was extracted intokagent.core.a2a.read_message_metadatarather than repeated three times.Testing
Added, in both runtimes:
helper's return value)
message.metadatapathStructfloat-integer case) and non-scalar skipping
rejection, allowlist cap
attribute is present on all three
Plus: Helm unittest for ConfigMap rendering with and without
contextKeys; Gotests that the controller forwards the variable and that a
Harnesscan neitherwiden nor enable the allowlist; and a regression test that the OTel Python SDK's
default propagator still carries baggage, since the Python runtime relies on that
default rather than configuring a propagator.
Run locally against this branch:
make -C go lintgo test -race(5 touched packages)ruff format --diffruff check(files touched here)pytest ./packages/*/testshelm unittest helm/kagentThe one Python failure is
test_tls_e2e.py::test_e2e_with_system_and_custom_ca,which fails identically on an unmodified
maincheckout (1 failed, 10 passed, 1 skippedon both). A fullgo test -race -skip 'TestE2E.*' ./...also hitsTestFetchSourceReusesExistingMaterializationincore/v2/agentplugins, a macOS/private/varvs/varsymlink artefact that likewise fails unmodified onmain. Neither package is touched by this PR.Not in scope
lifecycle surface, and it is fully covered by unit tests against a real
TracerProvider. Happy to add an E2E case if maintainers would prefer one.kagent.context.prefix iswhat makes shadowing structurally impossible. Backends that need their own
names should rename in the OTel Collector; the docs include a recipe.
baggage it receives. Deriving baggage from an OIDC token at the edge is the
gateway's job, not kagent's.
Docs
New
docs/architecture/trace-context.md,linked from the architecture index. Covers configuration, why baggage, the
per-span guarantee, the safety properties, and the Collector rename recipe.
Commits
feat(adk)feat(python)feat(core)docs(architecture)All four commits are DCO signed off.
This follows on from #1734 / #1737 rather than starting a new discussion, but
happy to write it up as an enhancement proposal under
design/first if that ispreferred for a change of this size.