Repository navigation
feat: copy aws.agent.lineage baggage to GenAI inference spans and support key:scope baggage entries - #965
feat: copy aws.agent.lineage baggage to GenAI inference spans and support key:scope baggage entries#965syed-ahsan-ishtiaque wants to merge 13 commits into
Conversation
With AGENT_OBSERVABILITY_ENABLED, copy the aws.agent.lineage baggage entry only to GenAI inference spans: CLIENT spans whose gen_ai.operation.name is chat, text_completion, generate_content or embeddings. The backend uses it to attribute model cost to agents. ScopedBaggageSpanProcessor captures the baggage in on_start and applies it in on_end, when instrumentations have set gen_ai.operation.name. It runs after GenAINestedClientSpanProcessor, so only the innermost CLIENT span matches, and before the redacting and exporter processors. It never overwrites an existing attribute and never evicts one to make room. session.id and keys in OTEL_BAGGAGE_SPAN_ATTRIBUTE_KEYS are still copied to every span at start. If a customer lists aws.agent.lineage there, it goes to every span and the scoped processor is not added.
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Found one pending-state lifetime issue; see the inline comment. The scoped baggage, nested CLIENT, redaction, and configurator suites passed (104 tests and 45 subtests). A separate garbage-collection reproduction confirmed that pending baggage survives after unended CLIENT spans are collected.
Pending baggage was keyed by span ID and removed only in on_end or shutdown, so CLIENT spans abandoned without end() kept their entry after they were garbage-collected. Tie each entry to the span's lifetime with weakref.finalize, and detach the finalizer when the span ends. Also stop filtering by span kind at start. Kind can change after start, so the scope is now checked only when the span ends.
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Verified that the original pending-baggage leak is fixed: the 10,000-span garbage-collection reproduction now leaves zero pending entries, and normal end/shutdown detach finalizers. The focused suites passed (106 tests and 45 subtests). The finalizer change introduces a separate shutdown deadlock, reproduced below.
Garbage collection can run the weakref finalizer at any allocation, including while the processor holds its non-reentrant lock, for example in shutdown(). The finalizer then blocked on the same lock and hung shutdown and process exit. The finalizer now only appends the span key to a thread-safe deque. The locked paths remove queued entries. Replace DictWithLock with a plain dict guarded by a processor lock, and add a regression test that collects an abandoned span while the lock is held.
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Verified the shutdown deadlock fix at 442b2e6: the original default-GC-threshold reproduction completes, collection while the processor lock is held completes, and an eight-thread check exported 4,000 spans with correct lineage and no deadlock. Broader unit suites passed: Python 2,181 tests plus 351 subtests (51 skipped), JS 1,737 tests; a real Vercel AI generateText reproduction also still passes. The deferred cleanup introduces a separate baggage retention regression described inline.
Queued removals from the span finalizer were only processed when a new span with scoped baggage started. If abandoned spans were collected and later traffic had no lineage, their pending baggage stayed in memory. Process the queue in on_start for every span, in on_end, and in force_flush. The queue is checked without the lock first, so spans with nothing to clean up stay lock-free.
Add an undocumented hook so a customer can scope their own baggage keys without a new release: register_scoped_baggage_key(key, scope) in Python and registerScopedBaggageKey(key, scope) in JS, exported from ./register. scope is a built-in scope name such as "inference" or a predicate that receives the ending span. Registration replaces the key map (copy-on-write), so span processing reads it without a lock. A predicate that raises skips the key for that span. The scoped processor is now installed whenever agent observability is enabled, even when the customer allowlist removes the lineage default, so registrations have a processor to attach to.
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Verified the entire change and the new registration hook at 53bf96b against the proposed handbook contract. The actual Python import and distro startup work with both the built-in inference scope and an ending-span predicate. Registration replacement, unknown-scope/disabled-mode warnings, predicate exceptions, existing-attribute preservation, and explicit baggage allowlist precedence behave as described. Registration also works when the default lineage key is allowlisted and the scoped processor starts with no default keys.
The main distro unit suite passes: 2,190 tests and 351 subtests, with 51 skips. I also reran the previous 10,000-span cleanup reproductions, the shutdown/GC deadlock reproduction, and the eight-thread 4,000-span GC stress check; all pass. No additional confirmed Python issues found in this revision.
If the distro configuration runs twice, the second tracer provider is not the global one, so its processor never sees spans. Claim the hook only if no processor holds it, and release it when the processor shuts down. This matches the JS fix for a second copy of /register.
OTEL_BAGGAGE_SPAN_ATTRIBUTE_KEYS entries of the form key:scope copy the baggage key only to spans that match the scope. The built-in inference scope works without code. Applications can define more scopes with an undocumented register hook; scope names are resolved when each span ends, so the hook can run after the distro starts. This replaces the per-key registration hook.
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Reviewed the full PR and the revised scope-only registration API against the new handbook. The examples work with agent observability both on and off through actual distro startup. Built-in inference matching, custom tools, registration after span start, scope replacement, rejected built-in overrides, unknown-scope warnings, predicate exceptions, explicit unscoped precedence, and existing attributes all behave as expected.
The comment below records a reproduced reserved-key behavior for verification: an explicit session.id scope is overridden by the automatic all-span default with agent observability enabled. The author considers this likely intentional because the key is reserved; it is not being reported as a confirmed defect. No other confirmed issues found in this revision.
Local validation: 2,192 main distro tests and 351 subtests pass (51 skipped). The prior 10,000-span cleanup checks, default-GC-threshold shutdown reproduction, and eight-thread 4,000-span GC stress check still pass.
Agent observability copies session.id to every span, so a session.id:scope entry had no effect in that mode. Drop the scope explicitly and log a warning, and cover session.id:scope with agent observability on and off.
…ineage # Conflicts: # CHANGELOG.md
|
Claude finished @syed-ahsan-ishtiaque's task in 3m 24s —— View job Claude PR Review
No new substantive findings. The author's own inline reviews have already covered the significant risk areas with reproductions and fixes: the GC retention of pending state ( A few spot checks that look fine:
|
…es, reserved aws. prefix - The built-in inference scope now matches only the innermost of nested CLIENT inference spans with the same operation. The scoped processor tracks this itself, so key:aws.inference gives the same result with agent observability off, where GenAINestedClientSpanProcessor is not installed. - A key can list several scopes and is copied when any of them matches. An entry without a scope means every span. The env var is parsed once into key -> scopes, which also handles session.id and the lineage default for both processors. - Built-in scopes use the reserved aws. prefix (aws.inference). Names starting with aws. cannot be registered, so a future built-in cannot collide with an application scope.
|
Claude finished @syed-ahsan-ishtiaque's task in 6m 35s —— View job Claude PR Review
No new substantive findings. The latest commit ( Spot checks that look fine:
|
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Reviewed the complete PR and the newly published f5451db revision. No new confirmed issues found in Python.
Verified the aws.inference scope with agent observability on and off through actual ADOT startup and exported spans: two and three nested same-operation spans copy the key only to the innermost span, different inference operations both match, and reconstructed parent contexts also work. With agent observability off, span kinds stay CLIENT. An inference child that carries no mapped baggage still excludes its matching parent.
The built-in and custom-scope examples, OR matching, throwing-predicate fallback, reserved aws. names, custom unprefixed inference, late registration and replacement, unknown-scope warnings, existing attributes, prototype names, unscoped precedence, reserved session IDs, and explicit scoped/unscoped lineage overrides all passed runtime checks with both flag settings.
Local validation: 2,199 core distro tests plus 361 subtests pass (51 skipped; optional framework suites excluded). The earlier pending-state and GC regression tests pass. The companion JS review records two reproduced issues in its new parent-link bookkeeping; the Python reconstructed-parent control passes.
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Re-reviewed the complete PR at f5451db against its description and the revised JS companion at 091a1b7. The Python head is unchanged since the previous review. All 20 Python runtime checks rerun through actual tracing startup pass: built-in/custom scope registration with agent observability on/off, OR and unscoped precedence, reserved names and session/lineage rules, and inference nesting using original or reconstructed parent contexts, including children without baggage. The results agree with JS. I found no new confirmed issues or regressions.
Install GenAINestedClientSpanProcessor whether or not agent observability is enabled. It demotes the outer span of nested CLIENT inference spans, so aws.inference matches only the innermost span in both modes. This removes the nesting tracking from ScopedBaggageSpanProcessor. The processor is generic again: aws.inference is a plain built-in predicate in the scope table, looked up by name like a registered scope. Defaults (session.id and aws.agent.lineage) still apply only with agent observability.
|
Claude finished @syed-ahsan-ishtiaque's task in 5m 37s —— View job Claude PR Review
No new substantive findings. The one new commit since the previous review (
Spot checks that look fine:
Minor nit (not blocking, not posting inline): the PR description still contains the older paragraph stating the processor "does not change span kind, so installing the scoped processor without agent observability does not affect Application Signals", which the new unconditional nested-processor install supersedes. The CHANGELOG entry is the authoritative description now. |
syed-ahsan-ishtiaque
left a comment
There was a problem hiding this comment.
Reviewed fe5f40e against the updated description and commit message, including the companion JS change. Installing the nested processor with agent observability off and demoting the outer inference span is an intentional behavior change; the runtime checks confirm it. I found one P2 regression in the now-unconditional nested processor's parent-operation cache, described inline.
The core distro suite passes 2,197 tests and 361 subtests (51 skipped; optional framework suites excluded locally). All 20 Python functional runtime checks pass and agree with JS on built-in/custom scopes with agent observability on/off, OR and unscoped precedence, reserved names and defaults, reconstructed parents, three nesting levels, different operations, and baggage-free children. Additional lifetime comparisons against the previous heads confirm the new orphaned nesting-record growth. No other confirmed findings.
| # processors to ensure exporters observe the updated kind. | ||
| if is_agent_observability_enabled(): | ||
| trace_provider.add_span_processor(GenAINestedClientSpanProcessor()) | ||
| trace_provider.add_span_processor(GenAINestedClientSpanProcessor()) |
There was a problem hiding this comment.
[P2] Bound nested-parent state before enabling the processor globally
Installing this processor outside the agent-observability gate exposes its unbounded parent-operation cache to every ADOT application. GenAINestedClientSpanProcessor.on_end() records an entry for any inference child with a parent ID, and only the parent's local on_end() or shutdown removes it. A valid remote parent represented by NonRecordingSpan never receives that local callback, so otherwise completed model calls permanently grow the cache.
I reproduced this at fe5f40e through _init_tracing with AGENT_OBSERVABILITY_ENABLED=false and no baggage-key configuration. Two batches of 10,000 CLIENT/chat spans, each under a distinct remote parent context, leave 10,000 then 20,000 entries in _parent_span_id_and_operation_to_gen_ai_client_child, although every child ended. GC, force_flush(), and 1,000 ordinary completed requests leave all 20,000 entries; only shutdown clears them. The same startup/workload using f5451db retains zero. Collected local parents and local parents ending before their children show the same growth; normal child-before-parent completion leaves zero. The JS companion has the same regression.
Please keep records only for tracked local parents and release them with the parent's lifetime, including GC, without recreating records for already-ended parents. Add regressions for remote/non-recording parents, collected parents, and children ending after their parents before installing this processor unconditionally.
Garbage collection can run while the processor holds its lock and end a span on the same thread, for example by closing an abandoned streaming generator that holds a span open. Build the pending entry and span key before taking the lock, and use an RLock so a re-entrant on_end cannot deadlock. Same hardening as the GenAINestedClientSpanProcessor fix.
|
Claude finished @syed-ahsan-ishtiaque's task in 5m 42s —— View job Claude PR Review
No new substantive findings. The one new commit since the previous review (
Why this is correct: a tracked-object allocation under the old Spot checks that look fine:
The author's own open P2 against |
Why
The X-Ray and AgentCore backend will compute model cost from the token counts on GenAI inference spans and attribute it to agents. It needs the agent lineage on those spans. The lineage travels in the
aws.agent.lineagebaggage entry. Today the distro can only copy a baggage entry to every span (OTEL_BAGGAGE_SPAN_ATTRIBUTE_KEYS). Copying lineage to every span would add an attribute that no other span needs. It would also put cost attribution on spans that carry no cost.Companion JS PR: aws-observability/aws-otel-js-instrumentation#586. Both PRs implement the same behavior.
What changes for users
session.id(agent observability) andkeyentries inOTEL_BAGGAGE_SPAN_ATTRIBUTE_KEYSaws.agent.lineage(agent observability only)key:scopeentries inOTEL_BAGGAGE_SPAN_ATTRIBUTE_KEYSaws.inferenceis the only built-in scope. It matches a CLIENT span whosegen_ai.operation.nameischat,text_completion,generate_contentorembeddings. When a framework span wraps the model SDK span with the same operation,GenAINestedClientSpanProcessordemotes the outer span to INTERNAL first, so only the innermost span matches.OTEL_BAGGAGE_SPAN_ATTRIBUTE_KEYS=session.id,my.key:aws.inferencecopiessession.idto every span andmy.keyonly to inference spans.aws.are reserved for built-in scopes, so a future built-in cannot change the meaning of an application's scope.:. W3C baggage keys cannot contain:or,, so existing values parse as before.Precedence
OTEL_BAGGAGE_SPAN_ATTRIBUTE_KEYSk,k:a(any order)k:a,k:baorbaws.agent.lineagenot listedaws.inferencespansaws.agent.lineageaws.agent.lineage:aaasession.id:aaaws.agent.lineagedefault.session.idis reserved under agent observability, because AgentCore groups spans by session.GenAINestedClientSpanProcessornow runs with or without agent observability. Before, it ran only with agent observability. Without agent observability, the outer span of a nested same-operation inference call is now INTERNAL instead of CLIENT, so the call is not counted twice as a dependency in Application Signals. This also givesaws.inferencethe same result in both modes. It is listed in the CHANGELOG.Backward compatibility: apart from the change above, nothing changes without agent observability and without
key:scopeentries, and the scoped processor is not installed. With agent observability, the only change is the newaws.agent.lineageattribute on inference spans. Thesession.idandaws.agent.lineagedefaults apply only with agent observability.Undocumented support hook:
register_baggage_scope(name, predicate)inamazon.opentelemetry.distro.scoped_baggage_span_processorlets application code define more scopes without a release. Keys are still mapped to the scope in the env var. It is intentionally left out of the public docs.It returns False for an empty name, a name with surrounding whitespace,
:or,, a name starting withaws., or a predicate that is not callable. A predicate that raises does not match, and the key's other scopes are still checked.How it works
ScopedBaggageSpanProcessor(new file):on_startcaptures the scoped baggage entries from the parent context. The context active when the span ends may not hold the same baggage (streaming, callbacks).on_endchecks the key's scopes against the ending span and writes the attribute if any matches. Scopes are checked only at end, because instrumentations setgen_ai.operation.nameafter start and processors can change span kind.Points worth a close look:
BoundedAttributesis immutable byon_end, and also during_on_ending._set_attribute_if_absentwritesBoundedAttributes._dictunder its lock, the same approachAttributeRedactingSpanProcessoralready uses. It keeps the count and length limits.weakref.finalizeon the span. A garbage-collection finalizer can run while this processor holds its lock, so the finalizer only appends the span key to a lock-freedeque. Garbage collection can also end a span on the same thread (for example by closing an abandoned streaming generator), so the pending entry and key are built before the lock is taken and the lock is anRLock.on_start,on_endandforce_flushdrain the deque, so collected spans are removed even if later spans carry no scoped baggage.shutdowndetaches the remaining finalizers._parse_baggage_span_attribute_keysparses the env var once into key -> scopes, with an empty scope meaning every span._get_baggage_span_attribute_keysapplies thesession.idand lineage rules and splits the result betweenBaggageSpanProcessorandScopedBaggageSpanProcessor.GenAINestedClientSpanProcessor, thenScopedBaggageSpanProcessor, thenAttributeRedactingSpanProcessor, then the exporter processors. The nested processor demotes the outer framework CLIENT span to INTERNAL first, so only the innermost CLIENT span gets lineage, and redaction rules apply to it.Files
scoped_baggage_span_processor.py: new processor, built-in scope, registration hook.aws_opentelemetry_configurator.py: installsGenAINestedClientSpanProcessorwith or without agent observability. Parseskey:scopeentries, adds the lineage default and thesession.idrule, installs the processor.gen_ai_nested_client_span_processor.py: the inference operation list becomes a shared constantGEN_AI_INFERENCE_OPERATIONS. No behavior change.CHANGELOG.md, tests.Testing
GenAINestedClientSpanProcessor; unscoped keys still on every spanon_endre-entered on the same threadaws.prefix; an unknown scope warns once._init_tracingwith agent observability off:tenant.id:aws.inferencereaches only the inner span of a framework chat span wrapping an SDK chat span, and the outer span is INTERNAL.session.id:<scope>with agent observability on and off, processor installed forkey:scopewithout agent observability.By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.