From 6b409ad4090c54bbbdc0d264a6c105755dcb9d1d Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 16 Oct 2026 19:25:54 +0100 Subject: [PATCH 1/4] feat(django): add request context to OpenTelemetry spans Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- .sampo/changesets/django-otel-span-context.md | 5 + posthog/client.py | 6 +- posthog/contexts.py | 14 + posthog/integrations/django.py | 3 + posthog/integrations/otel.py | 30 +++ .../test/integrations/test_otel_context.py | 254 ++++++++++++++++++ posthog/tracing/_context.py | 46 ++++ references/middleware_configuration.md | 40 +++ references/public_api_snapshot.txt | 4 + 9 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 .sampo/changesets/django-otel-span-context.md create mode 100644 posthog/integrations/otel.py create mode 100644 posthog/test/integrations/test_otel_context.py create mode 100644 posthog/tracing/_context.py diff --git a/.sampo/changesets/django-otel-span-context.md b/.sampo/changesets/django-otel-span-context.md new file mode 100644 index 000000000..90a517cd3 --- /dev/null +++ b/.sampo/changesets/django-otel-span-context.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add an optional OpenTelemetry span processor for PostHog request IDs and enrich active Django request spans after request context is available. diff --git a/posthog/client.py b/posthog/client.py index 2f33a29ae..f8d4ea824 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -36,6 +36,7 @@ from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE, Consumer, _DrainSignal from posthog.contexts import ( _get_current_context, + _get_active_span_context, get_capture_exception_code_variables_context, get_code_variables_detect_secrets_context, get_code_variables_ignore_patterns_context, @@ -2628,9 +2629,10 @@ def _traces_pipeline(self) -> Optional[PostHogTraces]: def _tracing_context(self) -> Dict[str, Optional[str]]: # The request context's identity, which the Django middleware fills # from the X-POSTHOG-* headers: the span's person/session join keys. + context = _get_active_span_context() return { - "distinct_id": get_context_distinct_id(), - "session_id": get_context_session_id(), + "distinct_id": context.get_distinct_id() if context is not None else None, + "session_id": context.get_session_id() if context is not None else None, } def start_span( diff --git a/posthog/contexts.py b/posthog/contexts.py index f897d1d96..45f9eb9df 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -21,6 +21,7 @@ def __init__( ): self.client: Optional[Client] = client self._generation = _context_generation + self._span_context_active = True self.parent = parent self.fresh = fresh self.capture_exceptions = capture_exceptions @@ -156,6 +157,18 @@ def _get_current_context() -> Optional[ContextScope]: return current_context +def _get_active_span_context() -> Optional[ContextScope]: + current_context = _get_current_context() + scope = current_context + while scope is not None: + if not scope._span_context_active: + return None + if scope.fresh: + break + scope = scope.parent + return current_context + + def _default_capture_exceptions(client: Optional["Client"] = None) -> bool: if client is not None: return client.enable_exception_autocapture @@ -245,6 +258,7 @@ def new_context( capture_exception(e) raise finally: + new_context._span_context_active = False if context_generation == _context_generation: _context_stack.set(new_context.get_parent()) diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index f241c6cb6..9c305a75c 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -2,6 +2,7 @@ from .. import contexts from ..client import Client +from ..tracing._context import _enrich_current_span from ..exception_utils import ( _ExceptionCaptureMetadata, _capture_exception_with_metadata, @@ -348,6 +349,7 @@ def __call__(self, request): for k, v in self.extract_tags(request).items(): contexts.tag(k, v) + _enrich_current_span() return self.get_response(request) async def __acall__(self, request): @@ -368,6 +370,7 @@ async def __acall__(self, request): for k, v in (await self.aextract_tags(request)).items(): contexts.tag(k, v) + _enrich_current_span() return await self.get_response(request) def process_exception(self, request, exception): diff --git a/posthog/integrations/otel.py b/posthog/integrations/otel.py new file mode 100644 index 000000000..fec4b282c --- /dev/null +++ b/posthog/integrations/otel.py @@ -0,0 +1,30 @@ +from typing import Optional + +from opentelemetry.context import Context +from opentelemetry.sdk.trace import Span, SpanProcessor + +from ..tracing._context import _enrich_current_span, _enrich_span + +__all__ = ["PostHogContextSpanProcessor", "enrich_current_span"] + + +def enrich_current_span() -> None: + """Add request IDs to an existing recording span after request context is set. + + Explicit span attributes take precedence. Missing OpenTelemetry support and + context lookup failures do not interrupt request handling. + """ + _enrich_current_span() + + +class PostHogContextSpanProcessor(SpanProcessor): + """Add PostHog request IDs to application-owned OpenTelemetry SDK spans. + + Add this processor to the application's existing TracerProvider before + starting requests. It snapshots child span context at span start, without + changing exporters, sampling or providers. Request integrations must also + enrich an incoming span that started before request context was available. + """ + + def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None: + _enrich_span(span) diff --git a/posthog/test/integrations/test_otel_context.py b/posthog/test/integrations/test_otel_context.py new file mode 100644 index 000000000..c20ba1fc8 --- /dev/null +++ b/posthog/test/integrations/test_otel_context.py @@ -0,0 +1,254 @@ +import asyncio +from contextlib import nullcontext +from unittest.mock import patch + +import pytest +from django.test import override_settings +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from posthog import contexts +from posthog.integrations.django import PosthogContextMiddleware +from posthog.integrations.otel import PostHogContextSpanProcessor, enrich_current_span +from posthog.test.integrations.test_middleware import MockRequest +from posthog.test.tracing.test_client_traces import ( + make_client, + mock_session, + flush_and_capture, + spans_from, +) + + +@pytest.fixture +def tracing(): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(PostHogContextSpanProcessor()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + yield provider.get_tracer(__name__), exporter + provider.shutdown() + + +@pytest.fixture +def native_client(): + client = make_client(traces={}) + with patch("posthog.tracing._transport._get_session", return_value=mock_session()): + yield client + client.shutdown() + + +@pytest.mark.parametrize( + "attributes,expected", + [ + ({}, {"sessionId": "session", "posthogDistinctId": "person"}), + ( + {"sessionId": "own-session", "posthogDistinctId": "own-person"}, + {"sessionId": "own-session", "posthogDistinctId": "own-person"}, + ), + ( + {"user.id": "authenticated"}, + {"user.id": "authenticated", "sessionId": "session"}, + ), + ], +) +def test_child_span_snapshots_context_and_preserves_identity( + tracing, attributes, expected +): + tracer, exporter = tracing + with contexts.new_context(fresh=True, capture_exceptions=False): + contexts.identify_context("person") + contexts.set_context_session("session") + span = tracer.start_span("child", attributes=attributes) + contexts.identify_context("later-person") + contexts.set_context_session("later-session") + span.end() + assert dict(exporter.get_finished_spans()[0].attributes) == expected + + +@pytest.mark.parametrize("failure", [ImportError("optional"), RuntimeError("lookup")]) +def test_current_span_lookup_failure_does_not_escape(failure): + with patch("opentelemetry.trace.get_current_span", side_effect=failure): + enrich_current_span() + + +def test_no_context_omits_identifiers(tracing): + tracer, exporter = tracing + with contexts.new_context(fresh=True, capture_exceptions=False): + tracer.start_span("unlinked").end() + assert dict(exporter.get_finished_spans()[0].attributes) == {} + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("fail", [False, True]) +def test_django_enriches_existing_request_and_child_spans( + tracing, native_client, asynchronous, fail +): + tracer, exporter = tracing + + def view(request): + native_client.start_span("native").end() + tracer.start_span("child").end() + if fail: + raise ValueError("view failed") + return "response" + + async def async_view(request): + return view(request) + + async def run_async(middleware, request): + with tracer.start_as_current_span("request"): + return await middleware(request) + + request = MockRequest( + headers={ + "X-POSTHOG-DISTINCT-ID": "browser-person", + "X-POSTHOG-SESSION-ID": "browser-session", + } + ) + with ( + override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_TRUST_TRACING_HEADERS=True + ), + contexts.new_context(fresh=True, capture_exceptions=False), + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + with pytest.raises(ValueError) if fail else nullcontext(): + if asynchronous: + asyncio.run(run_async(middleware, request)) + else: + with tracer.start_as_current_span("request"): + middleware(request) + assert contexts.get_context_distinct_id() is None + assert contexts.get_context_session_id() is None + tracer.start_span("after-request").end() + spans = {span.name: span for span in exporter.get_finished_spans()} + for name in ("request", "child"): + assert dict(spans[name].attributes) == { + "posthogDistinctId": "browser-person", + "sessionId": "browser-session", + } + assert dict(spans["after-request"].attributes) == {} + payload, _, _ = flush_and_capture(native_client) + (native,) = spans_from(payload) + assert { + attribute["key"]: attribute["value"]["stringValue"] + for attribute in native["attributes"] + } == { + "posthogDistinctId": "browser-person", + "sessionId": "browser-session", + } + + +def test_async_django_requests_keep_separate_span_context(tracing): + tracer, exporter = tracing + both_started = asyncio.Event() + started = 0 + + async def view(request): + nonlocal started + started += 1 + if started == 2: + both_started.set() + await both_started.wait() + tracer.start_span(request.path + "-child").end() + return "response" + + async def run_request(middleware, identity): + request = MockRequest( + path=identity, + headers={ + "X-POSTHOG-DISTINCT-ID": identity, + "X-POSTHOG-SESSION-ID": identity + "-session", + }, + ) + with tracer.start_as_current_span(identity): + await middleware(request) + + async def run(): + await asyncio.gather( + *(run_request(middleware, identity) for identity in ("one", "two")) + ) + + with override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_TRUST_TRACING_HEADERS=True + ): + middleware = PosthogContextMiddleware(view) + asyncio.run(run()) + for span in exporter.get_finished_spans(): + identity = span.name.split("-")[0] + assert dict(span.attributes) == { + "posthogDistinctId": identity, + "sessionId": identity + "-session", + } + + +def test_context_lookup_failure_does_not_break_span_creation(tracing): + tracer, exporter = tracing + with patch.object( + contexts, "_get_active_span_context", side_effect=RuntimeError("context") + ): + tracer.start_span("child").end() + assert len(exporter.get_finished_spans()) == 1 + assert dict(exporter.get_finished_spans()[0].attributes) == {} + + +@pytest.mark.parametrize("nested", [False, True]) +def test_spans_started_after_django_request_do_not_use_inherited_ids( + tracing, native_client, nested +): + tracer, exporter = tracing + + async def run(): + release = asyncio.Event() + tasks = [] + + async def late_work(held, native_held): + await release.wait() + with contexts.new_context() if nested else nullcontext(): + tracer.start_span("late").end() + native_client.start_span("native-late").end() + assert contexts.get_context_session_id() == "session" + held.end() + native_held.end() + + async def view(request): + tasks.append( + asyncio.create_task( + late_work( + tracer.start_span("held"), + native_client.start_span("native-held"), + ) + ) + ) + return "response" + + middleware = PosthogContextMiddleware(view) + await middleware( + MockRequest( + headers={ + "X-POSTHOG-DISTINCT-ID": "person", + "X-POSTHOG-SESSION-ID": "session", + } + ) + ) + release.set() + await asyncio.gather(*tasks) + + with override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_TRUST_TRACING_HEADERS=True + ): + asyncio.run(run()) + spans = {span.name: dict(span.attributes) for span in exporter.get_finished_spans()} + assert spans["late"] == {} + assert spans["held"] == {"sessionId": "session", "posthogDistinctId": "person"} + payload, _, _ = flush_and_capture(native_client) + native = {span["name"]: span for span in spans_from(payload)} + assert "attributes" not in native["native-late"] + assert { + attribute["key"]: attribute["value"]["stringValue"] + for attribute in native["native-held"]["attributes"] + } == { + "sessionId": "session", + "posthogDistinctId": "person", + } diff --git a/posthog/tracing/_context.py b/posthog/tracing/_context.py new file mode 100644 index 000000000..e8b408609 --- /dev/null +++ b/posthog/tracing/_context.py @@ -0,0 +1,46 @@ +import logging +from collections.abc import Mapping + +from .. import contexts + +log = logging.getLogger("posthog") +_IDENTITY_ATTRIBUTES = ( + "posthogDistinctId", + "posthog.distinct_id", + "user.id", + "enduser.id", +) + + +def _enrich_span(span: object) -> None: + try: + if not getattr(span, "is_recording")(): + return + attributes = getattr(span, "attributes", None) + if not isinstance(attributes, Mapping): + return + context = contexts._get_active_span_context() + if context is None: + return + extra = {} + session_id = context.get_session_id() + distinct_id = context.get_distinct_id() + if session_id and "sessionId" not in attributes: + extra["sessionId"] = session_id + if distinct_id and not any(key in attributes for key in _IDENTITY_ATTRIBUTES): + extra["posthogDistinctId"] = distinct_id + if extra: + getattr(span, "set_attributes")(extra) + except Exception: + log.debug("Failed to add PostHog context to span", exc_info=True) + + +def _enrich_current_span() -> None: + try: + from opentelemetry.trace import get_current_span + + _enrich_span(get_current_span()) + except ImportError: + pass + except Exception: + log.debug("Failed to read current OpenTelemetry span", exc_info=True) diff --git a/references/middleware_configuration.md b/references/middleware_configuration.md index 2a065adaa..724b717a3 100644 --- a/references/middleware_configuration.md +++ b/references/middleware_configuration.md @@ -90,3 +90,43 @@ request scope it is restored. Background threads do not automatically inherit request context; async tasks follow Python's context-variable propagation rules. Flask isolates identity and request properties while retaining effective enclosing exception code-variable privacy controls. + +## OpenTelemetry spans + +Native PostHog spans read the active request IDs when `start_span` runs. +For application-owned OpenTelemetry spans, install `opentelemetry-sdk` and add +`PostHogContextSpanProcessor` to the application's existing `TracerProvider`: + +```python +from posthog.integrations.otel import PostHogContextSpanProcessor + +provider.add_span_processor(PostHogContextSpanProcessor()) +``` + +Register the processor before requests start. Keep the existing provider, +exporters, and sampler. The processor adds `sessionId` and `posthogDistinctId` +from the active PostHog context when each recording span starts. These values +stay on the span after the request ends. Explicit span attributes take precedence. +An existing application identity, such as `user.id`, prevents fallback identity +from being added. Absent IDs are omitted. OpenTelemetry remains optional for +applications that do not use this integration. + +Django's `PosthogContextMiddleware` also adds IDs to the active OpenTelemetry SDK +request span after it reads the request context. This covers a request span that +starts before the PostHog middleware. Put the OpenTelemetry request middleware +outside the PostHog middleware. Put Django's authentication middleware before +PostHog if you want authenticated user identity. Both sync and async Django +request handlers use the same bridge. Tracing headers still need +`POSTHOG_MW_TRUST_TRACING_HEADERS = True`; they are untrusted analytics context. + +For a custom request integration, call `enrich_current_span()` from +`posthog.integrations.otel` once after setting the request IDs. This call handles +an already active recording SDK span. The processor handles spans started later +in the request. A context lookup failure does not interrupt the request. +No provider, exporter, trace, session ID, or recording is created by this bridge. + +The bridge does not propagate context into a separate process or a new thread. +Only spans started during the supported request lifetime inherit request IDs. +Spans started by an inherited async task after the request ends omit request IDs. +A background task can set its own IDs in a fresh `new_context(fresh=True)` scope. +The AI OpenTelemetry processor is a separate integration with different attributes. diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 29a1c477e..bbeb8eb3b 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1224,6 +1224,7 @@ class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Opt class posthog.integrations.celery.PosthogCeleryIntegration(client: Optional[Client] = None, capture_exceptions: bool = True, capture_task_lifecycle_events: bool = True, propagate_context: bool = True, task_filter: Optional[Callable[[Optional[str], dict[str, Any]], bool]] = None) class posthog.integrations.django.PosthogContextMiddleware(get_response) class posthog.integrations.flask.PosthogFlaskIntegration(app: Optional[Flask] = None, *, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, request_filter: Optional[Callable[[Request], bool]] = None, extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None, trust_tracing_headers: Optional[bool] = None, capture_tracing_headers: Optional[bool] = None) +class posthog.integrations.otel.PostHogContextSpanProcessor class posthog.mcp.McpAnalytics(key: Any) class posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware(app: Any) class posthog.mcp.constants.PostHogMCPAnalyticsEvent @@ -1430,6 +1431,7 @@ function posthog.identify_context(distinct_id: str) function posthog.integrations.django.markcoroutinefunction(func) function posthog.integrations.drf.create_exception_handler(handler: Optional[Callable[[Exception, Mapping[str, Any]], Any]] = None, *, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, capture_4xx: bool = False, exception_filter: Optional[Callable[[Exception, Any, Mapping[str, Any]], bool]] = None) -> Callable[[Exception, Mapping[str, Any]], Any] function posthog.integrations.drf.exception_handler(exc: Exception, context: Mapping[str, Any]) -> Any +function posthog.integrations.otel.enrich_current_span() -> None function posthog.join() -> None function posthog.load_feature_flags() function posthog.mcp.asgi.autowire_stateless_mint(server: Any) -> None @@ -1697,6 +1699,7 @@ method posthog.integrations.django.PosthogContextMiddleware.extract_request_user method posthog.integrations.django.PosthogContextMiddleware.extract_tags(request) method posthog.integrations.django.PosthogContextMiddleware.process_exception(request, exception) method posthog.integrations.flask.PosthogFlaskIntegration.init_app(app: Flask) -> None +method posthog.integrations.otel.PostHogContextSpanProcessor.on_start(span: Span, parent_context: Optional[Context] = None) -> None method posthog.mcp.McpAnalytics.capture(event: str, properties: Optional[dict] = None) -> None method posthog.mcp.McpAnalytics.flush() -> None method posthog.mcp.posthog_mcp.PostHogMCP.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] @@ -1809,6 +1812,7 @@ module posthog.integrations.celery module posthog.integrations.django module posthog.integrations.drf module posthog.integrations.flask +module posthog.integrations.otel module posthog.mcp module posthog.mcp.asgi module posthog.mcp.constants From 61ee9227252626dc7be47810bb0abdd4b8b7badb Mon Sep 17 00:00:00 2001 From: pauldambra Date: Fri, 16 Oct 2026 23:48:21 +0100 Subject: [PATCH 2/4] fix(tracing): preserve span attributes and active context values Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- posthog/client.py | 6 +- posthog/contexts.py | 16 ++--- .../test/integrations/test_otel_context.py | 63 +++++++++++++++++-- posthog/tracing/_context.py | 16 +++-- references/middleware_configuration.md | 6 +- 5 files changed, 83 insertions(+), 24 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index f8d4ea824..1fa30fa13 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -2629,11 +2629,7 @@ def _traces_pipeline(self) -> Optional[PostHogTraces]: def _tracing_context(self) -> Dict[str, Optional[str]]: # The request context's identity, which the Django middleware fills # from the X-POSTHOG-* headers: the span's person/session join keys. - context = _get_active_span_context() - return { - "distinct_id": context.get_distinct_id() if context is not None else None, - "session_id": context.get_session_id() if context is not None else None, - } + return _get_active_span_context() def start_span( self, diff --git a/posthog/contexts.py b/posthog/contexts.py index 45f9eb9df..5811b732c 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -157,16 +157,18 @@ def _get_current_context() -> Optional[ContextScope]: return current_context -def _get_active_span_context() -> Optional[ContextScope]: - current_context = _get_current_context() - scope = current_context - while scope is not None: - if not scope._span_context_active: - return None +def _get_active_span_context() -> Dict[str, Optional[str]]: + values: Dict[str, Optional[str]] = {"distinct_id": None, "session_id": None} + scope = _get_current_context() + while scope is not None and scope._span_context_active: + if values["distinct_id"] is None: + values["distinct_id"] = scope.distinct_id + if values["session_id"] is None: + values["session_id"] = scope.session_id if scope.fresh: break scope = scope.parent - return current_context + return values def _default_capture_exceptions(client: Optional["Client"] = None) -> bool: diff --git a/posthog/test/integrations/test_otel_context.py b/posthog/test/integrations/test_otel_context.py index c20ba1fc8..ee14b9dc2 100644 --- a/posthog/test/integrations/test_otel_context.py +++ b/posthog/test/integrations/test_otel_context.py @@ -4,7 +4,7 @@ import pytest from django.test import override_settings -from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace import SpanLimits, TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter @@ -194,8 +194,17 @@ def test_context_lookup_failure_does_not_break_span_creation(tracing): @pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize( + "own_ids", + [ + {}, + {"distinct_id": "background-person"}, + {"session_id": "background-session"}, + {"distinct_id": "background-person", "session_id": "background-session"}, + ], +) def test_spans_started_after_django_request_do_not_use_inherited_ids( - tracing, native_client, nested + tracing, native_client, nested, own_ids ): tracer, exporter = tracing @@ -206,9 +215,15 @@ async def run(): async def late_work(held, native_held): await release.wait() with contexts.new_context() if nested else nullcontext(): + if "distinct_id" in own_ids: + contexts.identify_context(own_ids["distinct_id"]) + if "session_id" in own_ids: + contexts.set_context_session(own_ids["session_id"]) tracer.start_span("late").end() native_client.start_span("native-late").end() - assert contexts.get_context_session_id() == "session" + assert contexts.get_context_session_id() == own_ids.get( + "session_id", "session" + ) held.end() native_held.end() @@ -240,11 +255,22 @@ async def view(request): ): asyncio.run(run()) spans = {span.name: dict(span.attributes) for span in exporter.get_finished_spans()} - assert spans["late"] == {} + expected = { + key: own_ids[field] + for field, key in ( + ("distinct_id", "posthogDistinctId"), + ("session_id", "sessionId"), + ) + if nested and field in own_ids + } + assert spans["late"] == expected assert spans["held"] == {"sessionId": "session", "posthogDistinctId": "person"} payload, _, _ = flush_and_capture(native_client) native = {span["name"]: span for span in spans_from(payload)} - assert "attributes" not in native["native-late"] + assert { + attribute["key"]: attribute["value"]["stringValue"] + for attribute in native["native-late"].get("attributes", []) + } == expected assert { attribute["key"]: attribute["value"]["stringValue"] for attribute in native["native-held"]["attributes"] @@ -252,3 +278,30 @@ async def view(request): "sessionId": "session", "posthogDistinctId": "person", } + + +@pytest.mark.parametrize("limit", [0, 1, 2, 3, 128]) +@pytest.mark.parametrize("current_span", [False, True]) +def test_enrichment_does_not_evict_application_attributes(limit, current_span): + provider = TracerProvider(span_limits=SpanLimits(max_span_attributes=limit)) + provider.add_span_processor(PostHogContextSpanProcessor()) + tracer = provider.get_tracer(__name__) + attributes = {"posthogDistinctId": "application-person", "other": "keep"} + baseline = tracer.start_span("baseline", attributes=attributes) + baseline.end() + try: + with contexts.new_context(fresh=True, capture_exceptions=False): + if not current_span: + contexts.set_context_session("session") + with tracer.start_as_current_span("span", attributes=attributes) as span: + if current_span: + contexts.set_context_session("session") + enrich_current_span() + retained = dict(list(attributes.items())[-limit:]) if limit else {} + expected = retained | ( + {"sessionId": "session"} if limit > len(attributes) else {} + ) + assert dict(span.attributes) == expected + assert span.dropped_attributes == baseline.dropped_attributes + finally: + provider.shutdown() diff --git a/posthog/tracing/_context.py b/posthog/tracing/_context.py index e8b408609..2a333c714 100644 --- a/posthog/tracing/_context.py +++ b/posthog/tracing/_context.py @@ -20,17 +20,21 @@ def _enrich_span(span: object) -> None: if not isinstance(attributes, Mapping): return context = contexts._get_active_span_context() - if context is None: - return extra = {} - session_id = context.get_session_id() - distinct_id = context.get_distinct_id() + session_id = context["session_id"] + distinct_id = context["distinct_id"] if session_id and "sessionId" not in attributes: extra["sessionId"] = session_id if distinct_id and not any(key in attributes for key in _IDENTITY_ATTRIBUTES): extra["posthogDistinctId"] = distinct_id - if extra: - getattr(span, "set_attributes")(extra) + if not extra: + return + # The SDK exposes no public capacity getter and evicts old attributes + # when full. Skip enrichment if its private limit is unavailable. + limit = getattr(getattr(span, "_limits", None), "max_span_attributes", None) + if not isinstance(limit, int) or len(attributes) + len(extra) > limit: + return + getattr(span, "set_attributes")(extra) except Exception: log.debug("Failed to add PostHog context to span", exc_info=True) diff --git a/references/middleware_configuration.md b/references/middleware_configuration.md index 724b717a3..a51a24b33 100644 --- a/references/middleware_configuration.md +++ b/references/middleware_configuration.md @@ -107,6 +107,8 @@ Register the processor before requests start. Keep the existing provider, exporters, and sampler. The processor adds `sessionId` and `posthogDistinctId` from the active PostHog context when each recording span starts. These values stay on the span after the request ends. Explicit span attributes take precedence. +The bridge skips enrichment when there is not enough attribute capacity or the +SDK does not expose its limit. OpenTelemetry SDK 1.20.0 or later is supported. An existing application identity, such as `user.id`, prevents fallback identity from being added. Absent IDs are omitted. OpenTelemetry remains optional for applications that do not use this integration. @@ -128,5 +130,7 @@ No provider, exporter, trace, session ID, or recording is created by this bridge The bridge does not propagate context into a separate process or a new thread. Only spans started during the supported request lifetime inherit request IDs. Spans started by an inherited async task after the request ends omit request IDs. -A background task can set its own IDs in a fresh `new_context(fresh=True)` scope. +A background task can set its own IDs in a new `new_context()` scope. +Only IDs from active scopes are used. Set `fresh=True` to prevent inheritance +from any parent scope. The AI OpenTelemetry processor is a separate integration with different attributes. From 0cdbf2c260a15c985fa7e090788f1fcfb5675287 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 14:06:53 +0100 Subject: [PATCH 3/4] chore: use current tracing header settings in span checks Generated-By: PostHog Desktop Task-Id: 5681be44-4986-48db-b140-1bda88f75f96 --- posthog/test/integrations/test_otel_context.py | 6 +++--- references/middleware_configuration.md | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/posthog/test/integrations/test_otel_context.py b/posthog/test/integrations/test_otel_context.py index ee14b9dc2..3898f1d50 100644 --- a/posthog/test/integrations/test_otel_context.py +++ b/posthog/test/integrations/test_otel_context.py @@ -108,7 +108,7 @@ async def run_async(middleware, request): ) with ( override_settings( - POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_TRUST_TRACING_HEADERS=True + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_CAPTURE_TRACING_HEADERS=True ), contexts.new_context(fresh=True, capture_exceptions=False), ): @@ -171,7 +171,7 @@ async def run(): ) with override_settings( - POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_TRUST_TRACING_HEADERS=True + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_CAPTURE_TRACING_HEADERS=True ): middleware = PosthogContextMiddleware(view) asyncio.run(run()) @@ -251,7 +251,7 @@ async def view(request): await asyncio.gather(*tasks) with override_settings( - POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_TRUST_TRACING_HEADERS=True + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, POSTHOG_MW_CAPTURE_TRACING_HEADERS=True ): asyncio.run(run()) spans = {span.name: dict(span.attributes) for span in exporter.get_finished_spans()} diff --git a/references/middleware_configuration.md b/references/middleware_configuration.md index a51a24b33..70cc236e3 100644 --- a/references/middleware_configuration.md +++ b/references/middleware_configuration.md @@ -118,8 +118,9 @@ request span after it reads the request context. This covers a request span that starts before the PostHog middleware. Put the OpenTelemetry request middleware outside the PostHog middleware. Put Django's authentication middleware before PostHog if you want authenticated user identity. Both sync and async Django -request handlers use the same bridge. Tracing headers still need -`POSTHOG_MW_TRUST_TRACING_HEADERS = True`; they are untrusted analytics context. +request handlers use the same bridge. Tracing headers are captured by default. +Set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` to disable extraction. These +headers remain untrusted analytics context. For a custom request integration, call `enrich_current_span()` from `posthog.integrations.otel` once after setting the request IDs. This call handles From edacaa000e692fdc85ca333dc1448f1cb1353547 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sat, 17 Oct 2026 23:53:36 +0100 Subject: [PATCH 4/4] fix(tracing): respect disabled context owners Generated-By: PostHog Desktop Task-Id: bfd18848-e498-4352-a58a-b9ba85de4198 --- posthog/contexts.py | 5 +- .../test/integrations/test_otel_context.py | 120 +++++++++++++++++- posthog/tracing/_context.py | 18 ++- 3 files changed, 138 insertions(+), 5 deletions(-) diff --git a/posthog/contexts.py b/posthog/contexts.py index 5811b732c..36ec8adaa 100644 --- a/posthog/contexts.py +++ b/posthog/contexts.py @@ -207,8 +207,9 @@ def new_context( client: Optional client instance to use for capturing exceptions (default: None). If provided, the client will be used to capture exceptions within the context. If not provided, the default (global) client will be used. Note that the passed - client is only used to capture exceptions within the context - other events captured - within the context via `Client.capture` or `posthog.capture` will still carry the context + client controls exception capture and OpenTelemetry ID enrichment within the context. + Nested span enrichment keeps the nearest supplied client, including in fresh scopes. + Other events captured via `Client.capture` or `posthog.capture` still carry the context state (tags, identity, session id), but will be captured by the client directly used (or the global one, in the case of `posthog.capture`) diff --git a/posthog/test/integrations/test_otel_context.py b/posthog/test/integrations/test_otel_context.py index 3898f1d50..a8aac9935 100644 --- a/posthog/test/integrations/test_otel_context.py +++ b/posthog/test/integrations/test_otel_context.py @@ -21,7 +21,8 @@ @pytest.fixture -def tracing(): +def tracing(monkeypatch): + monkeypatch.setattr("posthog.project_api_key", "phc_key") exporter = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(PostHogContextSpanProcessor()) @@ -66,6 +67,118 @@ def test_child_span_snapshots_context_and_preserves_identity( assert dict(exporter.get_finished_spans()[0].attributes) == expected +@pytest.mark.parametrize("disabled", [False, True]) +@pytest.mark.parametrize( + "owner", + ["scope", "inherited", "fresh", "global-client", "global-setting", "global-no-key"], +) +def test_enrichment_respects_owning_client_disabled_state( + tracing, monkeypatch, owner, disabled +): + tracer, exporter = tracing + client = make_client(send=False, disabled=disabled) + monkeypatch.setattr( + "posthog.default_client", client if owner == "global-client" else None + ) + monkeypatch.setattr( + "posthog.disabled", + disabled + if owner in {"global-setting", "global-no-key"} + else owner in {"scope", "inherited", "fresh"} and not disabled, + ) + monkeypatch.setattr("posthog.api_key", None) + monkeypatch.setattr( + "posthog.project_api_key", "phc_key" if owner == "global-setting" else " " + ) + disabled = disabled or owner == "global-no-key" + try: + with contexts.new_context( + fresh=True, + capture_exceptions=False, + client=client if owner in {"scope", "inherited", "fresh"} else None, + ): + nested = ( + contexts.new_context(fresh=owner == "fresh", capture_exceptions=False) + if owner in {"inherited", "fresh"} + else nullcontext() + ) + with ( + nested, + tracer.start_as_current_span( + "request", attributes={"posthogDistinctId": "application-person"} + ), + ): + contexts.identify_context("person") + contexts.set_context_session("session") + enrich_current_span() + tracer.start_span("child", attributes={"app.key": "keep"}).end() + finally: + client.shutdown() + spans = {span.name: dict(span.attributes) for span in exporter.get_finished_spans()} + assert spans["request"] == {"posthogDistinctId": "application-person"} | ( + {} if disabled else {"sessionId": "session"} + ) + assert spans["child"] == {"app.key": "keep"} | ( + {} if disabled else {"posthogDistinctId": "person", "sessionId": "session"} + ) + + +@pytest.mark.parametrize("outer_disabled", [False, True]) +def test_explicit_client_owns_fresh_nested_enrichment( + tracing, monkeypatch, outer_disabled +): + tracer, exporter = tracing + outer = make_client(send=False, disabled=outer_disabled) + inner = make_client(send=False, disabled=not outer_disabled) + monkeypatch.setattr("posthog.default_client", outer) + monkeypatch.setattr("posthog.disabled", outer_disabled) + try: + with contexts.new_context(fresh=True, client=outer, capture_exceptions=False): + contexts.identify_context("outer-person") + with contexts.new_context( + fresh=True, client=inner, capture_exceptions=False + ): + contexts.identify_context("inner-person") + contexts.set_context_session("inner-session") + tracer.start_span("child").end() + finally: + inner.shutdown() + outer.shutdown() + assert dict(exporter.get_finished_spans()[0].attributes) == ( + {"posthogDistinctId": "inner-person", "sessionId": "inner-session"} + if outer_disabled + else {} + ) + + +@pytest.mark.parametrize("initially_disabled", [False, True]) +def test_enrichment_observes_runtime_disabled_changes(tracing, initially_disabled): + tracer, exporter = tracing + client = make_client(send=False, disabled=initially_disabled) + try: + with contexts.new_context(fresh=True, client=client, capture_exceptions=False): + contexts.identify_context("person") + contexts.set_context_session("session") + before = tracer.start_span("before") + client.disabled = True + with tracer.start_as_current_span( + "disabled", attributes={"sessionId": "application-session"} + ): + enrich_current_span() + tracer.start_span("disabled-child").end() + client.disabled = False + tracer.start_span("enabled").end() + before.end() + finally: + client.shutdown() + spans = {span.name: dict(span.attributes) for span in exporter.get_finished_spans()} + ids = {"posthogDistinctId": "person", "sessionId": "session"} + assert spans["before"] == ({} if initially_disabled else ids) + assert spans["disabled"] == {"sessionId": "application-session"} + assert spans["disabled-child"] == {} + assert spans["enabled"] == ids + + @pytest.mark.parametrize("failure", [ImportError("optional"), RuntimeError("lookup")]) def test_current_span_lookup_failure_does_not_escape(failure): with patch("opentelemetry.trace.get_current_span", side_effect=failure): @@ -282,7 +395,10 @@ async def view(request): @pytest.mark.parametrize("limit", [0, 1, 2, 3, 128]) @pytest.mark.parametrize("current_span", [False, True]) -def test_enrichment_does_not_evict_application_attributes(limit, current_span): +def test_enrichment_does_not_evict_application_attributes( + monkeypatch, limit, current_span +): + monkeypatch.setattr("posthog.project_api_key", "phc_key") provider = TracerProvider(span_limits=SpanLimits(max_span_attributes=limit)) provider.add_span_processor(PostHogContextSpanProcessor()) tracer = provider.get_tracer(__name__) diff --git a/posthog/tracing/_context.py b/posthog/tracing/_context.py index 2a333c714..25c5299da 100644 --- a/posthog/tracing/_context.py +++ b/posthog/tracing/_context.py @@ -12,9 +12,25 @@ ) +def _enrichment_is_enabled() -> bool: + scope = contexts._get_current_context() + while scope is not None: + if scope.client is not None: + return not scope.client.disabled + scope = scope.parent + + from .. import api_key, default_client, disabled, project_api_key + + if disabled: + return False + if default_client is not None: + return not default_client.disabled + return bool((project_api_key or "").strip() or (api_key or "").strip()) + + def _enrich_span(span: object) -> None: try: - if not getattr(span, "is_recording")(): + if not _enrichment_is_enabled() or not getattr(span, "is_recording")(): return attributes = getattr(span, "attributes", None) if not isinstance(attributes, Mapping):