Skip to content

feat(ai): add per-request context to span processor - #1060

Merged
carlos-marchal-ph merged 3 commits into
mainfrom
feat(aio)/span-processor-request-context
Oct 14, 2026
Merged

carlos-marchal-ph merged 3 commits into
mainfrom
feat(aio)/span-processor-request-context

Conversation

@carlos-marchal-ph

@carlos-marchal-ph carlos-marchal-ph commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

  • A server that traces LLM calls for many users through PostHogSpanProcessor sends every AI event under one person, or none.
  • The only documented way to set the user was a resource attribute, which is fixed for the whole process.
  • PostHogSpanProcessor now adds the active PostHog context to the AI spans it exports:
    • identify_context → posthog.distinct_id
    • set_context_session → $session_id
    • each tag → a property under its own key
  • Usage: wrap the work in posthog.new_context(), as for any other capture.
with posthog.new_context():
    posthog.identify_context(user_id)
    posthog.tag("plan", "pro")
    handle_request()  # AI spans started here carry the user, session and tag

How it works

  • on_start stores the active context for the span, keyed by trace and span ID. It does nothing when no context is active.
  • on_end releases the entry for every span. For AI spans it forwards a copy of the span with the context attributes merged in.
  • If the span already carries a user (posthog.distinct_id, user.id or the ai.telemetry/ai.settings distinct ID keys), no posthog.distinct_id is added from the context or from a tag.
  • The context session maps to $session_id, the same as capture, so AI events link to replays. $ai_session_id stays free for the instrumentation's own conversation ID, or an explicit $ai_session_id tag.
  • Fork-safe: the store and its lock reset in the child after os.fork. A span that is never ended releases its entry when it is garbage-collected.
  • The live span is never changed, so other processors and exporters on the same provider never see the user, session or tags.
  • Precedence: the span's own attribute wins, then the tag, then the context distinct ID and session ID.
  • Value rules:
    • strings, numbers, bools and homogeneous lists pass through
    • integers outside the signed 64-bit range become strings
    • datetime, UUID and Decimal become strings, with ISO format for dates
    • NaN, ±inf, None, empty strings and values that can't be encoded are skipped
    • other values are sent as JSON strings
  • Stamping can never raise into the caller. Errors are caught per value and logged at debug level.
  • shutdown clears the store.
  • PostHogTraceExporter can't do this, because an exporter only sees finished spans. Its docstring now says so.

Note

Apps that already use PostHog contexts, for example through the Django, Flask or ASGI request middleware, will see their AI events attributed to the context's person after upgrading. The changeset says so.

Same change in posthog-js (@posthog/ai) and posthog-go (otel).

💚 How did you test it?

  • New tests in posthog/test/ai/otel/test_processor.py run a real TracerProvider. They cover:
    • two concurrent contexts on threads, each exported with its own values
    • attributes set by the span itself are kept
    • another processor on the same provider sees no context attributes
    • a non-AI span is dropped and leaves no stored entry
    • an unencodable tag doesn't break export, and the other tags survive
    • the value rules
  • End to end against a local PostHog with an OTel-instrumented LLM SDK: two parallel requests with different users each produced an $ai_generation event with the right distinct ID, $session_id and tag. The same run passed for Node and Go.
  • The ReadableSpan constructor used for the copy is identical on opentelemetry-sdk 1.20, 1.27 and 1.40.
  • Not run: the test suite on SDK 1.20. Its old protobuf doesn't import on Python 3.14 locally.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code (claude-opus-5-5) and subagents, directed by @carlos-marchal-ph.
  • Design chosen with Carlos: reuse the existing PostHog context API instead of adding a resolver callback option.
  • The first version wrote onto the live span. Independent reviews flagged that this leaks the user ID and tags to every other exporter on the provider. It now stamps only the copy sent to PostHog.
  • Stored by (trace_id, span_id) rather than a weak reference to the span, because the SDK passes on_end a fresh ReadableSpan object.
  • The copy reports dropped attribute, event and link counts as 0. PostHog ingestion doesn't read them.

🤖 Generated with Claude Code

@carlos-marchal-ph
carlos-marchal-ph requested a review from a team as a code owner October 14, 2026 10:14
@carlos-marchal-ph carlos-marchal-ph self-assigned this Oct 14, 2026
@carlos-marchal-ph
carlos-marchal-ph requested a review from a team October 14, 2026 10:14

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed 8161d56: 1 Must fix, 3 Should fix.

Comment thread posthog/ai/otel/processor.py Outdated
)
self._processor = BatchSpanProcessor(exporter)
self._captured: Dict[Tuple[int, int], RequestContext] = {}
self._captured_lock = threading.Lock()

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.

P1 · Reinitialize the capture lock after a fork

When a process forks while another thread holds _captured_lock, the child inherits a locked mutex without the thread that releases it. Every subsequent on_end blocks, including spans without PostHog context, hanging application work despite the underlying batch processor's fork handling. Reinitialize the lock and clear captured state after fork.

snapshot = RequestContext(
distinct_id=get_context_distinct_id() or None,
session_id=get_context_session_id() or None,
tags=dict(get_tags()),

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.

P2 · Freeze mutable tag values when capturing the context

When a dictionary or list passed to contexts.tag changes before the span ends, dict(get_tags()) retains the same mutable value. with_request_context then exports the updated value instead of the context at span start; a dictionary tagged with tier='pro' exports tier='free' after mutation. Copy or normalize each value during capture.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

leaving this one on purpose. values are read when the span ends so spans that turn out not to be AI spans (http, db...) cost almost nothing. snapshotting at start would bring that cost back to guard against code mutating a tagged list while an llm call is in flight, which we don't expect. noted in the docstring that tag values are read at span end

Comment thread posthog/ai/otel/processor.py Outdated
Comment on lines +94 to +96
if captured:
with self._captured_lock:
self._captured[_span_key(span)] = captured

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.

P2 · Release captured contexts when abandoned spans are collected

When a manually started span is abandoned without end(), _captured retains its context even after the span is garbage collected. This applies to non-AI spans too, and retains arbitrary tag objects until processor shutdown, causing memory growth in long-lived services. Tie entries to span lifetime with weak-reference cleanup or enforce bounded retention.

Comment on lines +99 to +102
if isinstance(value, _PRIMITIVE_TYPES):
return value
if isinstance(value, (list, tuple)) and _is_homogeneous(value):
return list(value)

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.

P2 · Validate integer tags against OTLP's signed 64-bit range

A context tag containing an integer outside the signed 64-bit range, such as 2**63, passes through to_attribute_value unchanged, including inside homogeneous lists. OTLP's AnyValue.int_value cannot represent it, so the exporter logs an encoding error and drops the entire property. Validate integer bounds and stringify oversized values before export.

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-10-14T11:19:35.903198+00:00
Duration: 260180ms

✅ All Tests Passed!

121/121 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 519ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 512ms
Required Headers.Has Authorization Bearer Header ✅ 512ms
Required Headers.Has Content Type Json ✅ 513ms
Required Headers.Has Posthog Sdk Info Format ✅ 511ms
Required Headers.Has Posthog Attempt Header ✅ 511ms
Required Headers.Has Posthog Request Id ✅ 512ms
Required Headers.Has Posthog Request Timestamp ✅ 512ms
Required Headers.Has User Agent ✅ 511ms
Body Format.Body Has Created At And Batch ✅ 511ms
Body Format.No Api Key In Body ✅ 512ms
Body Format.No Sent At In Body ✅ 512ms
Event Format.Event Has Required Root Fields ✅ 513ms
Event Format.Event Uuid Is Valid ✅ 510ms
Event Format.Event Timestamp Is Rfc3339 ✅ 512ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 518ms
Event Format.Distinct Id Is String ✅ 512ms
Event Format.Distinct Id At Root Not Properties ✅ 511ms
Event Format.Custom Properties Preserved ✅ 512ms
Event Format.Set Properties Preserved ✅ 513ms
Event Format.Set Once Properties Preserved ✅ 511ms
Event Format.Groups Properties Preserved ✅ 511ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 511ms
Event Format.Event Has Required Root Fields Batch ✅ 516ms
Event Format.Event Uuid Is Valid Batch ✅ 516ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 516ms
Event Format.Distinct Id Is String Batch ✅ 516ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 514ms
Event Format.Custom Properties Preserved Batch ✅ 517ms
Event Format.Set Properties Preserved Batch ✅ 516ms
Event Format.Set Once Properties Preserved Batch ✅ 516ms
Event Format.Groups Properties Preserved Batch ✅ 514ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 516ms
Batch Behavior.Multiple Events In Single Batch ✅ 518ms
Batch Behavior.Batch Envelope Smoke ✅ 519ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 507ms
Batch Behavior.Flush At Triggers Batch ✅ 1013ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 513ms
Deduplication.Generates Unique Uuids ✅ 519ms
Deduplication.Different Events Same Content Different Uuids ✅ 514ms
Deduplication.Preserves Uuid On Retry ✅ 6521ms
Deduplication.Preserves Timestamp On Retry ✅ 6522ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6520ms
Deduplication.No Duplicate Events In Batch ✅ 521ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 512ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 13530ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6518ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3022ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6523ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 511ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 515ms
Response Format Validation.Success No Retry After When All Ok ✅ 515ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1518ms
Response Format Validation.Success No Retry After When Drop Only ✅ 516ms
Response Format Validation.Response Echoes Request Id ✅ 512ms
Retry Behavior.Retries On 408 ✅ 6523ms
Retry Behavior.Retries On 500 ✅ 6519ms
Retry Behavior.Retries On 503 ✅ 8525ms
Retry Behavior.Retries On 504 ✅ 6521ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3520ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11518ms
Retry Behavior.Does Not Retry On 400 ✅ 2517ms
Retry Behavior.Does Not Retry On 401 ✅ 2515ms
Retry Behavior.Does Not Retry On 402 ✅ 2515ms
Retry Behavior.Does Not Retry On 413 ✅ 2513ms
Retry Behavior.Does Not Retry On 415 ✅ 2513ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2513ms
Retry Behavior.Implements Backoff ✅ 22538ms
Retry Behavior.Max Retries Respected ✅ 22539ms
Partial Batch Handling.Handles 200 Full Success ✅ 2512ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3518ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3516ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3514ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6522ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6521ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6526ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6524ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6524ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6518ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8518ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3517ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3519ms
Compression.Sends Gzip Content Encoding ✅ 512ms
Compression.No Content Encoding When Disabled ✅ 511ms
Compression.Compressed Body Is Decompressible ✅ 511ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2513ms
Event Options.Cookieless Mode Override ✅ 512ms
Event Options.Disable Skew Correction Override ✅ 510ms
Event Options.Process Person Profile Override ✅ 511ms
Event Options.Product Tour Id Override ✅ 511ms
Event Options.Unset Options Omitted ✅ 511ms
Event Options.Options Override In Batch ✅ 517ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 513ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 511ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 513ms

Capture_Ai Tests

✅ 5/5 tests passed

View Details
Test Status Duration
Routing.Capture Ai Posts To Ai Endpoint ✅ 511ms
Routing.Capture Does Not Reroute Ai Named Events ✅ 512ms
Identity.Capture Ai Event Has Uuid ✅ 511ms
Identity.Capture Ai Keeps Supplied Uuid ✅ 512ms
Timestamp Format.Non Utc Event Timestamp Is Converted To Utc ✅ 512ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 14ms
Request Payload.Flags Request Uses V2 Query Param ✅ 14ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 12ms
Request Payload.Flags Request Omits Authorization Header ✅ 11ms
Request Payload.Token In Flags Body Matches Init ✅ 13ms
Request Payload.Groups Round Trip ✅ 12ms
Request Payload.Groups Default To Empty Object ✅ 12ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 13ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 11ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 13ms
Request Lifecycle.No Flags Request On Init Alone ✅ 4ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 513ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 19ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 14ms
Retry Behavior.Retries Flags On 502 ✅ 317ms
Retry Behavior.Retries Flags On 504 ✅ 317ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 515ms

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 94ms
Versioned Boolean Matching.Matching Version 1 ✅ 86ms
Versioned Boolean Matching.Matching Version 2 ✅ 88ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 47ms

@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[Medium impact] Adds per-request context capture to the AI span processor.

Fix the inherited lock before merging; forked workers can hang when spans end.

Reviews (1) · Last reviewed commit: "feat: span processor request context" · Reviewed by Greptile

Comment thread posthog/ai/otel/processor.py Outdated
)
self._processor = BatchSpanProcessor(exporter)
self._captured: Dict[Tuple[int, int], RequestContext] = {}
self._captured_lock = threading.Lock()

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.

P1 Forked workers can hang

If another parent thread holds _captured_lock when a worker forks, the child inherits the locked object but not the thread that can release it. Every subsequent on_end() waits forever, even for non-AI spans or spans without PostHog context, so requests can hang.

Reset the lock and clear _captured in a child-process fork hook.

Knowledge Base Used: Framework integrations

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/ai/otel/processor.py
Line: 82

Comment:
**Forked workers can hang**

If another parent thread holds `_captured_lock` when a worker forks, the child inherits the locked object but not the thread that can release it. Every subsequent `on_end()` waits forever, even for non-AI spans or spans without PostHog context, so requests can hang.

Reset the lock and clear `_captured` in a child-process fork hook.

**Knowledge Base Used:** [Framework integrations](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-python/-/docs/framework-integrations.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

snapshot = RequestContext(
distinct_id=get_context_distinct_id() or None,
session_id=get_context_session_id() or None,
tags=dict(get_tags()),

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.

P2 Saved tags change later

dict(get_tags()) copies the keys but keeps references to mutable tag values. If a caller tags a list, starts a span, then appends to that list before ending the span, the exported tag contains the appended value rather than the value captured at span start.

Convert each tag to an independent attribute value during on_start(), preserving the per-value error guards, and add a mutation test.

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog/ai/otel/_request_context.py
Line: 36

Comment:
**Saved tags change later**

`dict(get_tags())` copies the keys but keeps references to mutable tag values. If a caller tags a list, starts a span, then appends to that list before ending the span, the exported tag contains the appended value rather than the value captured at span start.

Convert each tag to an independent attribute value during `on_start()`, preserving the per-value error guards, and add a mutation test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

leaving this one on purpose. values are read when the span ends so spans that turn out not to be AI spans (http, db...) cost almost nothing. snapshotting at start would bring that cost back to guard against code mutating a tagged list while an llm call is in flight, which we don't expect. noted in the docstring that tag values are read at span end

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.

That makes sense. Reading tags at span end keeps the common non-AI span path cheap, and the documented end-time semantics make the mutable-value behavior an intentional tradeoff rather than an unnoticed snapshot bug. No change needed.

Comment thread posthog/ai/otel/_request_context.py Outdated
from ...contexts import get_context_distinct_id, get_context_session_id, get_tags

DISTINCT_ID_ATTRIBUTE = "posthog.distinct_id"
SESSION_ID_ATTRIBUTE = "$ai_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.

Should this be $session_id? The rest of the SDK treats the context session as the web/replay session. add_context_tags writes it as $session_id (client.py:269), and the Django and ASGI middleware fill it from the posthog-js X-POSTHOG-SESSION-ID header. Stamping it as $ai_session_id has three effects:

  • AI events get no $session_id, so the trace view's replay link never shows (getSessionID reads only $session_id).
  • Ingestion fills $ai_session_id from the instrumentation's own session ID (e.g. gen_ai.conversation.id, anthropic.session.id) only while it's unset, so this replaces the real conversation grouping with the browser session.
  • The native wrappers in posthog.ai capture under the same context and send $session_id, so OTel and wrapper events disagree.

Would mapping it to $session_id, and leaving $ai_session_id to span attributes or an explicit tag, work? posthog-js#5299 and posthog-go#371 use the same mapping, so all three would change together.

Comment thread posthog/test/ai/otel/test_processor.py Outdated
def request(user):
with new_request_context():
contexts.identify_context(user)
both_inside.wait()

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 barrier releases before either span starts, and each span starts and ends straight away, so the two spans can run one after the other. A single shared "last captured" slot would still pass, and so would moving the capture from on_start to on_end, since every test ends its span inside the context. Could this start both spans inside their contexts, wait on the barrier, and then end them after the contexts exit? That covers concurrent spans and the streaming case the on_start snapshot exists for.

pypi/posthog: minor
---

`PostHogSpanProcessor` now adds the active PostHog context to the AI spans it exports: the distinct ID from `identify_context`, the session ID from `set_context_session`, and context tags as properties. Spans started inside `posthog.new_context()` reach PostHog under the right person, so one process can trace many users. Attributes already on a span are kept, and other processors on the provider never see the context. Apps already using PostHog contexts (for example the Django, Flask or ASGI request middleware) will see their AI events attributed to the context's person after upgrading.

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.

Could this also say that the middleware tags (email, $ip, $user_agent, $current_url, ...) now land on every AI event? The Go changeset mentions it, and it's the part of the upgrade a privacy-minded user would want to know about.

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed a1dbaf0: 1 Must fix, 3 Should fix.

warn_if_posthog_ai_gateway_otel_attributes(span.attributes)
if captured:
try:
span = with_request_context(span, captured)

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.

P1 · Provide a privacy filter before automatically exporting context tags

With Django middleware and a Client.before_send scrubber removing email and $current_url, this enrichment newly uploads those raw properties through OTLP. with_request_context copies middleware tags directly, and the batch processor bypasses the client's scrubber, exposing previously filtered request data. Provide a filtering hook or explicit opt-in before exporting tags.

extra = {
key: value
for key, value in request_context_attributes(context).items()
if key not in own and not (has_identity and key == DISTINCT_ID_ATTRIBUTE)

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.

P2 · Preserve span identity across all context identity aliases

With posthog.distinct_id='bob' on a span and an ai.telemetry.metadata.posthog_distinct_id='alice' context tag, this guard adds the tag because it only excludes the canonical key. Ingestion prioritizes the metadata alias and attributes Bob's event to Alice, violating span precedence. Suppress all context identity aliases when the span already has an identity.

def with_request_context(span: ReadableSpan, context: RequestContext) -> ReadableSpan:
"""Return a copy of ``span`` with the context's values added, keeping attributes it already has."""
own = span.attributes or {}
has_identity = any(key in own for key in IDENTITY_ATTRIBUTES)

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.

P2 · Check that existing identity attributes contain usable IDs

When a span contains user.id='' or user.id=42, has_identity becomes true and blocks a valid ID from identify_context('alice'). Ingestion accepts only nonempty strings for identity, so it ignores the span value and assigns a resource or anonymous identity instead of Alice. Only treat identity attributes as set when ingestion can use their values.

context=span.get_span_context(),
parent=span.parent,
resource=span.resource,
attributes={**own, **extra},

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.

P2 · Apply configured span limits to context attributes

When a provider configures SpanLimits or OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT, context tags bypass those safeguards because with_request_context supplies a plain dictionary to ReadableSpan. Large strings and arbitrary tag counts reach the batch queue and OTLP encoder unbounded, increasing memory and export costs. Apply the originating span's limits when merging context attributes.

@carlos-marchal-ph
carlos-marchal-ph merged commit 6916499 into main Oct 14, 2026
66 of 72 checks passed
@carlos-marchal-ph
carlos-marchal-ph deleted the feat(aio)/span-processor-request-context branch October 14, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants