Skip to content
Merged
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/span-processor-request-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
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` (sent as `$session_id`), and context tags as properties. Spans started inside `posthog.new_context()` reach PostHog under the right person, so one process can trace many users. Tags set by the request middleware (for example `email`, `$ip`, `$user_agent` and `$current_url`) now appear on AI events too. 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.
146 changes: 146 additions & 0 deletions posthog/ai/otel/_request_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Captures the active PostHog context for a span and applies it to an exported copy."""

import datetime
import decimal
import json
import logging
import math
import uuid
from dataclasses import dataclass
from typing import Any, Dict, Optional, Sequence

from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.util.types import AttributeValue

from ...contexts import get_context_distinct_id, get_context_session_id, get_tags

DISTINCT_ID_ATTRIBUTE = "posthog.distinct_id"
SESSION_ID_ATTRIBUTE = "$session_id"
# Ingestion prefers posthog.distinct_id, so adding the context's would override the span's explicit user.
IDENTITY_ATTRIBUTES = (
DISTINCT_ID_ATTRIBUTE,
"user.id",
"ai.telemetry.metadata.posthog_distinct_id",
"ai.settings.context.posthog_distinct_id",
)
_MAX_INT64 = 2**63 - 1
_MIN_INT64 = -(2**63)
_PRIMITIVE_TYPES = (str, bool, int, float)

log = logging.getLogger("posthog")


@dataclass(frozen=True)
class RequestContext:
distinct_id: Optional[str]
session_id: Optional[str]
tags: Dict[str, Any]


def capture_request_context() -> Optional[RequestContext]:
"""Snapshot the active context's distinct ID, session ID and tags, or None if all are empty."""
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

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.

)
if not (snapshot.distinct_id or snapshot.session_id or snapshot.tags):
return None
return snapshot


def request_context_attributes(context: RequestContext) -> Dict[str, AttributeValue]:
"""Return the snapshot as span attributes. Tags win over the context IDs."""
values: Dict[str, Any] = {
key: value
for key, value in (
(DISTINCT_ID_ATTRIBUTE, context.distinct_id),
(SESSION_ID_ATTRIBUTE, context.session_id),
)
if value
}
values.update(context.tags)

attributes: Dict[str, AttributeValue] = {}
for key, value in values.items():
try:
attribute = to_attribute_value(value)
except Exception:
log.debug("Failed to read PostHog context value %r", key, exc_info=True)
continue
if attribute is not None:
attributes[key] = attribute
return attributes


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.

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.

}
if not extra:
return span
return ReadableSpan(
name=span.name,
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.

events=span.events,
links=span.links,
kind=span.kind,
status=span.status,
start_time=span.start_time,
end_time=span.end_time,
instrumentation_scope=span.instrumentation_scope,
)


def to_attribute_value(value: Any) -> Optional[AttributeValue]:
"""Return ``value`` as an OpenTelemetry attribute value, JSON-encoding anything OTel can't hold."""
if value is None or (isinstance(value, str) and not value):
return None
if isinstance(value, float) and not math.isfinite(value):
return None
if _is_out_of_int64_range(value):
return str(value)
if isinstance(value, _PRIMITIVE_TYPES):
return value
if isinstance(value, (list, tuple)) and _is_homogeneous(value):
return list(value)
Comment on lines +111 to +114

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.

try:
encoded = json.dumps(value, allow_nan=False, default=_encode_string_like)
except (TypeError, ValueError):
return None
if encoded.startswith('"'):
return json.loads(encoded) or None
return encoded


def _encode_string_like(value: Any) -> str:
if isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
return value.isoformat()
if isinstance(value, (uuid.UUID, decimal.Decimal)):
return str(value)
raise TypeError(f"{type(value).__name__} is not JSON serializable")


def _is_out_of_int64_range(value: Any) -> bool:
return (
isinstance(value, int)
and not isinstance(value, bool)
and not _MIN_INT64 <= value <= _MAX_INT64
)


def _is_homogeneous(values: Sequence[Any]) -> bool:
return (
all(isinstance(v, _PRIMITIVE_TYPES) for v in values)
and not any(_is_out_of_int64_range(v) for v in values)
and all(math.isfinite(v) for v in values if isinstance(v, float))
and len({type(v) for v in values}) <= 1
)
2 changes: 2 additions & 0 deletions posthog/ai/otel/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class PostHogTraceExporter(SpanExporter):

Wraps an OTLPSpanExporter configured for PostHog's OTLP endpoint. Spans
that are not AI-related are silently dropped, returning SUCCESS immediately.
Exporters only see finished spans, so they can't add per-request PostHog
context; use ``PostHogSpanProcessor`` for that.

Usage::

Expand Down
106 changes: 101 additions & 5 deletions posthog/ai/otel/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,55 @@
for setups using TracerProvider.add_span_processor().
"""

from typing import Optional
import logging
import os
import threading
import weakref
from typing import Dict, Optional, Tuple

from opentelemetry.context import Context
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter

from ..gateway import warn_if_posthog_ai_gateway_otel_attributes
from ._request_context import (
RequestContext,
capture_request_context,
with_request_context,
)
from .spans import DEFAULT_HOST, is_ai_span


log = logging.getLogger("posthog")


def _span_key(span: ReadableSpan) -> Tuple[int, int]:
# The SDK passes on_end a ReadableSpan snapshot, not the Span object on_start saw.
context = span.get_span_context()
return (context.trace_id, context.span_id)


_processors: "weakref.WeakSet[PostHogSpanProcessor]" = weakref.WeakSet()


def _discard_abandoned(
processor_ref: "weakref.ref[PostHogSpanProcessor]", key: Tuple[int, int]
) -> None:
processor = processor_ref()
if processor is not None:
processor._discard_captured(key)


def _reset_processors_after_fork() -> None:
for processor in list(_processors):
processor._reset_after_fork()


if hasattr(os, "register_at_fork"):
os.register_at_fork(after_in_child=_reset_processors_after_fork)


class PostHogSpanProcessor(SpanProcessor):
"""Span processor that filters AI spans and exports them to PostHog.

Expand All @@ -32,6 +70,19 @@ class PostHogSpanProcessor(SpanProcessor):
provider.add_span_processor(
PostHogSpanProcessor(api_key="phc_...")
)

# Per request: AI spans started inside a context export with its user, session and tags.
with posthog.new_context():
posthog.identify_context(user_id)
posthog.tag("plan", "pro")
handle_request()

Attributes already on a span win over explicit tags, which win over the
context's distinct ID and session ID (exported as ``$session_id``). Threads that don't copy contextvars
carry no user.

The context is captured when a span starts, but tag values are read when the
span ends. Mutating a tagged object while a span is open shows up in the export.
"""

def __init__(
Expand All @@ -52,15 +103,50 @@ def __init__(
headers={"Authorization": f"Bearer {self._api_key}"},
)
self._processor = BatchSpanProcessor(exporter)
self._captured: Dict[
Tuple[int, int], Tuple[RequestContext, weakref.finalize]
] = {}
# Reentrant: cyclic GC can run a span finalizer on a thread that already holds the lock.
self._captured_lock = threading.RLock()
_processors.add(self)

def _reset_after_fork(self) -> None:
# A lock held by another thread at fork time would never release in the child.
self._captured_lock = threading.RLock()
self._captured = {}

def _discard_captured(self, key: Tuple[int, int]) -> None:
with self._captured_lock:
self._captured.pop(key, None)

def _take_captured(self, key: Tuple[int, int]) -> Optional[RequestContext]:
with self._captured_lock:
entry = self._captured.pop(key, None)
if entry is None:
return None
captured, finalizer = entry
finalizer.detach()
return captured

def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
"""
Handle span start notifications.
Remember the active PostHog context for the span.

This processor does not need to do work at span start; filtering happens
in ``on_end``.
The span itself is left untouched, so other processors on the provider
never see the context. The values are added to the copy exported to
PostHog in ``on_end``.
"""
pass
try:
captured = capture_request_context()
if captured:
key = _span_key(span)
finalizer = weakref.finalize(
span, _discard_abandoned, weakref.ref(self), key
)
with self._captured_lock:
self._captured[key] = (captured, finalizer)
except Exception:
log.debug("Failed to capture PostHog context for span", exc_info=True)

def on_end(self, span: ReadableSpan) -> None:
"""
Expand All @@ -69,14 +155,24 @@ def on_end(self, span: ReadableSpan) -> None:
Args:
span: The ended OpenTelemetry span.
"""
captured = self._take_captured(_span_key(span))
if not is_ai_span(span):
return
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.

except Exception:
log.debug("Failed to add PostHog context to span", exc_info=True)
self._processor.on_end(span)

def shutdown(self) -> None:
"""Shut down the underlying batch span processor."""
self._processor.shutdown()
with self._captured_lock:
for _, finalizer in self._captured.values():
finalizer.detach()
self._captured.clear()

def force_flush(self, timeout_millis: Optional[int] = None) -> bool:
"""
Expand Down
Loading
Loading