Skip to content

feat: promote allowlisted caller context onto every agent span - #2575

Open
rtemperini wants to merge 5 commits into
kagent-dev:mainfrom
rtemperini:feat/trace-caller-context-attributes
Open

feat: promote allowlisted caller context onto every agent span#2575
rtemperini wants to merge 5 commits into
kagent-dev:mainfrom
rtemperini:feat/trace-caller-context-attributes

Conversation

@rtemperini

@rtemperini rtemperini commented Aug 26, 2026

Copy link
Copy Markdown

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:

  • An authenticating proxy in front of kagent knows the signed-in user's OIDC
    sub. That opaque identifier should appear on every downstream operation as
    user.id — tool calls, A2A delegations, MCP calls, model calls.
  • A chat integration or other programmatic A2A caller knows the invoking user,
    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.metadata into a2a.message.metadata.* attributes. That
left two gaps:

  1. Go only. The Python runtimes ignore inbound message.metadata entirely.
  2. Root span only. SetMessageMetadataAttributes writes to the span current
    at 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, and
merge them into the request-scoped attribute bag that
KagentAttributesSpanProcessor / kagentAttributesSpanProcessor already stamps
onto every span of the request.

otel:
  tracing:
    enabled: true
    contextKeys:
      - {from: sub, to: user.id}
      - {from: thread_id, to: kagent.thread_id}
      - channel
baggage: sub=opaque-subject           →  user.id                   = "opaque-subject"
metadata: {"thread_id": "1717171.42"} →  kagent.thread_id          = "1717171.42"
metadata: {"channel": "C0AB1"}        →  kagent.context.channel    = "C0AB1"

Registry names (user.*, enduser.*, session.id) are left unprefixed so
operators can use the semantic convention names. Names already in the kagent.
namespace are left as-is. Everything else is emitted as kagent.context.<name>.

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 + baggage propagator. A value set once at the edge
survives 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.metadata remains supported as the complement, for callers that can
set 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/channel
fields (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
OnStart for every span, including spans created by upstream ADK, the MCP
client, 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: true with an empty allowlist is a state that does nothing but looks
like 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

Name KAGENT_TRACE_CONTEXT_KEYS (env) / otel.tracing.contextKeys (Helm)
Type Comma-separated keys, or a JSON array of strings and {from, to, hash} objects
Default Empty — promotion disabled
HMAC key KAGENT_TRACE_CONTEXT_HASH_KEY / otel.tracing.contextHashKeySecret for hash: hmac-sha256

The controller forwards both variables to the agents it creates. They need
explicit forwarding because collectOtelEnvFromProcess carries only OTEL_
prefixed names.

Which caller 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 at all; there is a test
for exactly that. The HMAC key is the same class of policy: a Harness cannot
set or replace it.

Security considerations

Caller-supplied context is untrusted input on both paths, so promotion is
constrained on every axis:

Risk Control
Cardinality explosion Only allowlisted keys are read; the allowlist is capped at 32 entries
Oversized spans Values truncated to 256 characters, keys to 64 (rune-based on both runtimes)
Log / trace injection Control characters stripped from values
Shadowing semantic conventions Custom keys are namespaced under kagent.context.; only user.*, enduser.*, and session.id pass through unprefixed
Secret leakage Nothing is promoted unless an operator names the key; hashed entries are omitted when the HMAC key is unset rather than falling back to plaintext
Malformed attribute names Allowlist entries containing whitespace or control characters are dropped
A tenant widening the allowlist Operator-level configuration only; a same-named Harness env entry is dropped, not inherited

Non-scalar metadata (objects, arrays) is skipped: unbounded in size, meaningless
as an attribute value.

Prefer an opaque identifier such as an OIDC sub for user.id. Do not put
names or email addresses on spans
(OTel guidance).
If a stable identifier must be derived from a value that should not appear on a
span, map it with hash: hmac-sha256 onto user.hash. Hashing at promotion time
only affects the span: baggage travels on HTTP headers, so a value placed in
baggage is still visible to every downstream hop, including model providers and
HTTP MCP servers.

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.

  • Default is off. With no contextKeys set, the ConfigMap key is absent, the env
    var is unset, the helper returns immediately, and not a single span attribute
    changes.
  • No existing behaviour is modified or removed. The 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.
  • No new dependencies. Baggage comes from go.opentelemetry.io/otel and
    opentelemetry-api, both already required.
  • No API, CRD, or protobuf changes.

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_control deliberately matches Go's
unicode.IsControl), rune-based rather than byte-based truncation on both sides,
and the same prefix rules. Both are covered by equivalent test cases so the two
cannot drift silently.

The ADK, LangGraph, and CrewAI Python executors all promote context. Reading a
Message's protobuf Struct metadata was extracted into
kagent.core.a2a.read_message_metadata rather than repeated three times.

Testing

Added, in both runtimes:

  • empty allowlist → no behaviour change (asserted against exported spans, not just the
    helper's return value)
  • flag on, baggage path
  • flag on, A2A message.metadata path
  • metadata overrides baggage on conflict
  • keys outside the allowlist ignored
  • scalar rendering (string / bool / int / float, including the protobuf Struct
    float-integer case) and non-scalar skipping
  • control-character stripping, value truncation, key-length and duplicate
    rejection, allowlist cap
  • service.name cannot be shadowed
  • user.*, enduser.*, and session.id stay unprefixed; other session.* keys do not
  • {from, to} mappings onto registry and kagent. names
  • hash: hmac-sha256 emits user.hash and never the original; missing key or
    unknown algorithm emit nothing
  • attribute-on-every-span: a root → tool → model span tree, asserting the
    attribute is present on all three

Plus: Helm unittest for ConfigMap rendering of string keys and JSON mappings, and
for injecting the HMAC key from a Secret; Go tests that the controller forwards
both variables and that a Harness can neither widen nor enable the allowlist
nor supply the HMAC key; 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.

Not in scope

  • E2E coverage. The behaviour is span-attribute shaping with no CRD, API, or
    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.
  • Controller-side baggage injection. The controller already propagates
    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 prefix rules, hashing, the baggage-on-HTTP-headers
warning, the safety properties, and the Collector rename recipe.

Commits

feat(adk) Go runtime: allowlist, sanitisation, A2A executor wiring
feat(python) Python runtimes: parity across ADK, LangGraph, CrewAI
feat(core) Helm value, ConfigMap, controller forwarding
docs(architecture) Documentation
fix Review: unprefixed registry names, {from, to, hash} mappings, HMAC-SHA256

All 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 is
preferred for a change of this size.

@rtemperini
rtemperini requested review from a team and supreme-gg-gg as code owners August 26, 2026 13:44
@github-actions github-actions Bot added the enhancement New feature or request label Aug 26, 2026

@krisztianfekete krisztianfekete left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, added a few initial comments!

Comment thread docs/architecture/trace-context.md Outdated
Comment on lines +3 to +7
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@rtemperini rtemperini Aug 28, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review, I agree, examples now use an OIDC sub mapped to user.id rather than an email. Docs, Helm values, and the PR description all follow that. Locked by TestCallerContextAttributes/maps_source_keys_onto_registry_and_kagent_names (Go) and test_maps_source_keys_onto_registry_and_kagent_names (Python).

Comment thread helm/kagent/values.yaml
# 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: []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use subs everywhere as exampels as mentioned in the md file above.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. values.yaml now shows {from: sub, to: user.id} (and thread_id / channel), not names or addresses. Helm unittest should join otel.tracing.contextKeys / should encode contextKeys mappings as JSON lock the rendered forms.

Comment thread docs/architecture/trace-context.md Outdated
Comment on lines +68 to +72
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, let's not document it like this as it's an anti-pattern.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. The landing example is now sub → user.id. The "Sensitive values" section points at the OTel guidance and tells operators not to put names or addresses on spans.

Comment thread docs/architecture/trace-context.md Outdated
| 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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented, and the implementation follows that. Hashing at promotion time only changes the span attribute — baggage is still an HTTP header, so a value placed there still reaches model providers and HTTP MCP servers. The docs say to hash or replace at the edge before the request enters the cluster, and hash: hmac-sha256 never falls back to writing the original onto the span if the HMAC key is missing (TestCallerContextAttributes_HashWithoutKeyEmitsNothing / test_hash_without_key_emits_nothing).

Comment thread go/core/pkg/env/otel.go
Comment on lines +41 to +48
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,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented this mapping shape: {from, to, hash}. hash: hmac-sha256 writes user.hash using KAGENT_TRACE_CONTEXT_HASH_KEY (Helm contextHashKeySecret). If the key is unset or the algorithm is unknown, the attribute is omitted rather than emitted in plaintext. A Harness cannot supply or replace the key.

Tests: TestCallerContextAttributes_HashesWithHMACSHA256, TestCallerContextAttributes_HashWithoutKeyEmitsNothing, TestCallerContextAttributes_UnknownHashEmitsNothing, and the matching Python cases. Tenant-cannot-set is TestCompileAgentTemplateForwardsTraceContextPolicy/harness_cannot_supply_the_HMAC_key.

Comment on lines +19 to +22
// 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."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also reflect this in tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. user.*, enduser.*, and session.id pass through unprefixed; names already in the kagent. namespace stay as-is; everything else is still kagent.context.<name>, so service.name cannot be shadowed.

Tests: TestCallerContextAttributes/registry_attributes_stay_unprefixed, session.id is unprefixed but session.foo is not, CannotShadowSemanticConventions, and the matching Python cases.

rtemperini and others added 5 commits August 28, 2026 12:09
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>
Address review of the caller-context tracing allowlist: use an OIDC
subject as the user.id example, leave user.*, enduser.*, and session.id
unprefixed, and hash mapped values with HMAC-SHA256 instead of putting
the original on the span.

Signed-off-by: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@rtemperini
rtemperini force-pushed the feat/trace-caller-context-attributes branch from 7c4da91 to 2056591 Compare August 28, 2026 10:21
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 28, 2026
@rtemperini

Copy link
Copy Markdown
Author

Thanks, added a few initial comments!

Thanks for the review @krisztianfekete , just updated with your comments, let me know if anything else required

}
// Allowlisted caller context joins the request-scoped bag rather than a
// single span, so tool, sub-agent, and model spans all carry it.
maps.Copy(spanAttributes, telemetry.CallerContextAttributes(ctx, reqCtx.Message.Metadata))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The registry passthrough is great, but kagent. names now pass through too and maps.Copy lets caller context override them.

It'ss the exact thing the prefix was protecting. Could caller context only fill keys that aren't already set, instead of copying over them?

Comment on lines +191 to +203
// spanAttributeName is the name written onto the span.
//
// user.*, enduser.*, and session.id pass through unprefixed so operators can
// use the semantic convention names. Names already in the kagent. namespace
// are left as-is. Everything else is placed under kagent.context. so a
// caller-supplied service.name cannot shadow the real one.
func spanAttributeName(name string) string {
if isRegistryAttribute(name) || strings.HasPrefix(name, "kagent.") {
return name
}
return contextAttributePrefix + name
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the merge above in executor gets fixed this should be fine, but specifying the actual attrs kagent.* has can make it more robust.

Comment on lines +204 to +208
func isRegistryAttribute(name string) bool {
return strings.HasPrefix(name, "user.") ||
strings.HasPrefix(name, "enduser.") ||
name == "session.id"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we just match the user. prefix then user.asdasd goes through unprefixed.

Can we use an explicit set of allowed names here?

Comment on lines +73 to +80
for _, mapping := range mappings {
value := sanitizeContextValue(bag.Member(mapping.source).Value())
if scalar, ok := scalarString(metadata[mapping.source]); ok {
value = sanitizeContextValue(scalar)
}
if value == "" {
continue
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's only override when the sanitised scalar is non-empty.

Comment on lines +78 to +85
for mapping in mappings:
value = _sanitize_context_value(bag.get(mapping.source))
if metadata is not None:
scalar = _scalar_string(metadata.get(mapping.source))
if scalar is not None:
value = _sanitize_context_value(scalar)
if not value:
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +217 to +235
// scalarString renders a JSON scalar from A2A message metadata as a string.
// Objects and arrays are skipped: they are unbounded in size and carry no
// useful meaning as a span attribute value.
func scalarString(value any) (string, bool) {
switch v := value.(type) {
case string:
return v, true
case bool:
return strconv.FormatBool(v), true
case float64:
return strconv.FormatFloat(v, 'g', -1, 64), true
case int:
return strconv.Itoa(v), true
case int64:
return strconv.FormatInt(v, 10), true
default:
return "", false
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'g' flips to scientific notation and int() doesn't in the matching logic on Python side, so these disagree on ordinary values.

strconv.FormatFloat(v, 'f', -1, 64) matches what Python already does. Also worth noting the int/int64 cases below are unreachable.

Comment on lines +155 to +172
func newContextMapping(from, to, hash string) (contextMapping, bool) {
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
hash = strings.TrimSpace(hash)
if from == "" || len(from) > maxContextKeyLength || !isAttributeKey(from) {
return contextMapping{}, false
}
if to == "" {
to = from
}
if len(to) > maxContextKeyLength || !isAttributeKey(to) {
return contextMapping{}, false
}
if hash != "" && hash != hashHMACSHA256 {
return contextMapping{}, false
}
return contextMapping{source: from, attribute: to, hash: hash}, true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

len() is bytes here and code points in _new_context_mapping, so a 40-character CJK key (120 bytes) is dropped by Go and kept by Python.

len([]rune(from))

on both from and to should fix it.

Comment on lines +136 to +139
# Allowlisted caller context joins the request-scoped bag rather
# than a single span, so tool, sub-agent, and model spans all
# carry it.
span_attributes.update(caller_context_attributes(read_message_metadata(context.message)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's pass the Message and decode lazily after the allowlist check to avoid running these when this is off.
This should be also fixed for crewai and langgraph as well.

Comment on lines +101 to +114
// allowedContextMappings parses the KAGENT_TRACE_CONTEXT_KEYS allowlist.
// Entries that are empty, over-long, or contain whitespace or control
// characters are dropped, and the list is capped at maxContextKeys so a
// misconfigured allowlist cannot inflate span cardinality without bound.
func allowedContextMappings() []contextMapping {
raw := strings.TrimSpace(os.Getenv(traceContextKeysEnvVar))
if raw == "" {
return nil
}
if strings.HasPrefix(raw, "[") {
return capMappings(parseJSONAllowlist(raw))
}
return capMappings(parseCommaAllowlist(raw))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reparses on every request now that there's JSON in it. Wrap it in sync.OnceValue? The allowlist can't change without a restart.

Comment on lines +22 to +29
// Accepts a comma-separated list of source keys, or a JSON array of strings
// and {from, to, hash} objects. See allowedContextMappings.
traceContextKeysEnvVar = "KAGENT_TRACE_CONTEXT_KEYS"

// traceContextHashKeyEnvVar is the HMAC key used when a mapping sets
// hash: hmac-sha256. Required for those entries; without it the hashed
// attribute is skipped rather than emitted in plaintext.
traceContextHashKeyEnvVar = "KAGENT_TRACE_CONTEXT_HASH_KEY"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These names are also declared in go/core/pkg/env/otel.go. Can we use env.KagentTraceContextKeys.Name()` here so there's one source of truth?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants