Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/django-otel-span-context.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 2 additions & 4 deletions posthog/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2628,10 +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.
return {
"distinct_id": get_context_distinct_id(),
"session_id": get_context_session_id(),
}
return _get_active_span_context()

def start_span(
self,
Expand Down
21 changes: 19 additions & 2 deletions posthog/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +157,20 @@ def _get_current_context() -> Optional[ContextScope]:
return current_context


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 values


def _default_capture_exceptions(client: Optional["Client"] = None) -> bool:
if client is not None:
return client.enable_exception_autocapture
Expand Down Expand Up @@ -192,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`)

Expand Down Expand Up @@ -245,6 +261,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())

Expand Down
3 changes: 3 additions & 0 deletions posthog/integrations/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
30 changes: 30 additions & 0 deletions posthog/integrations/otel.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading