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/doughty-seer-vellamo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

Align ASGI, Flask, and Django tracing-header capture with peer SDKs. Add capture_tracing_headers and POSTHOG_MW_CAPTURE_TRACING_HEADERS, enabled by default once an integration is installed. Keep the trust-named options as deprecated aliases, honor explicit opt-outs, and prefer the capture-named option when both are configured. Share header sanitization and request-context attribution across integrations; explicit capture identity and session properties retain precedence.
62 changes: 62 additions & 0 deletions posthog/integrations/_tracing_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Shared request-scoped analytics attribution for framework integrations."""

import re
import warnings
from collections.abc import Callable
from typing import Optional

from .. import contexts

_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
_MAX_HEADER_LENGTH = 1000


def _sanitize_tracing_header_value(value: object) -> Optional[str]:
if not isinstance(value, str):
return None
return _CONTROL_CHARS_RE.sub("", value).strip()[:_MAX_HEADER_LENGTH] or None


def _resolve_capture_tracing_headers(
capture: Optional[bool],
trust: Optional[bool],
*,
canonical_name: str = "capture_tracing_headers",
deprecated_name: str = "trust_tracing_headers",
) -> bool:
if trust is not None:
warnings.warn(
f"{deprecated_name} is deprecated; use {canonical_name} instead.",
DeprecationWarning,
stacklevel=3,
)
if capture is not None:
return capture if isinstance(capture, bool) else False
if trust is not None:
return trust if isinstance(trust, bool) else False
return True


def _read_tracing_header(
get_header: Callable[[str], object], name: str
) -> Optional[str]:
try:
return _sanitize_tracing_header_value(get_header(name))
except Exception:
return None


def _apply_tracing_headers(
get_header: Callable[[str], object],
capture_tracing_headers: bool,
fallback_distinct_id: Optional[str] = None,
) -> None:
distinct_id = fallback_distinct_id
if capture_tracing_headers:
header_id = _read_tracing_header(get_header, "X-POSTHOG-DISTINCT-ID")
session_id = _read_tracing_header(get_header, "X-POSTHOG-SESSION-ID")
distinct_id = header_id or distinct_id
if session_id:
contexts.set_context_session(session_id)
if distinct_id:
contexts.identify_context(distinct_id)
60 changes: 39 additions & 21 deletions posthog/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
``another_client.capture``.

``X-PostHog-Distinct-ID`` and ``X-PostHog-Session-ID`` are client-controlled
analytics context, not authentication. They are ignored by default. Set
``trust_tracing_headers=True`` only when deliberately accepting browser analytics
attribution or when a trusted upstream strips and replaces incoming values.
analytics context, not authentication or authorization. They are read by default
once middleware is installed. Set ``capture_tracing_headers=False`` to ignore them.
"""

import inspect
Expand All @@ -39,6 +38,11 @@
_ExceptionCaptureMetadata,
_capture_exception_with_metadata,
)
from ._tracing_headers import (
_apply_tracing_headers,
_resolve_capture_tracing_headers,
_sanitize_tracing_header_value,
)

_ASGIApp = Callable[
[
Expand Down Expand Up @@ -92,9 +96,14 @@ def _headers_from_scope(scope: Mapping[str, Any]) -> dict[bytes, bytes]:
and isinstance(item[0], bytes)
and isinstance(item[1], bytes)
):
# Keep the first value. Tracing headers are singular, and joining arbitrary
# duplicate user input can create misleading identity values.
result.setdefault(item[0].lower(), item[1])
name, value = item[0].lower(), item[1]
# Tracing headers use the first usable value, never a joined identity.
if name in {
b"x-posthog-distinct-id",
b"x-posthog-session-id",
} and not _sanitize_tracing_header_value(value.decode("latin-1")):
continue
result.setdefault(name, value)
return result


Expand Down Expand Up @@ -184,11 +193,11 @@ class PosthogASGIMiddleware:
Returning ``False`` bypasses all instrumentation for that scope.
extra_properties: Optional sync or async callback receiving the ASGI scope and
returning additional event properties.
trust_tracing_headers: Use client-provided PostHog distinct and session ID
headers as analytics context. Disabled by default because these headers
are not authenticated. Enable only when deliberately accepting browser
attribution or when a trusted upstream replaces incoming values. Never
use these identifiers for authorization.
capture_tracing_headers: Read PostHog distinct and session ID headers as
request-scoped analytics context. Defaults to True. Explicit capture IDs
and event properties take precedence. Never use these values for security.
trust_tracing_headers: Deprecated alias for ``capture_tracing_headers``.
The capture-named option takes precedence when both are supplied.
"""

def __init__(
Expand All @@ -198,14 +207,27 @@ def __init__(
capture_exceptions: Optional[bool] = None,
request_filter: Optional[_RequestFilter] = None,
extra_properties: Optional[_ExtraProperties] = None,
trust_tracing_headers: bool = False,
trust_tracing_headers: Optional[bool] = None,
*,
capture_tracing_headers: Optional[bool] = None,
) -> None:
self.app = app
self.client = client
self.capture_exceptions = capture_exceptions
self.request_filter = request_filter
self.extra_properties = extra_properties
self.trust_tracing_headers = trust_tracing_headers
self.capture_tracing_headers = _resolve_capture_tracing_headers(
capture_tracing_headers, trust_tracing_headers
)

@property
def trust_tracing_headers(self) -> bool:
"""Deprecated alias for ``capture_tracing_headers``."""
return self.capture_tracing_headers

@trust_tracing_headers.setter
def trust_tracing_headers(self, enabled: bool) -> None:
self.capture_tracing_headers = enabled

async def __call__(self, scope, receive, send) -> None:
if scope.get("type") not in {"http", "websocket"}:
Expand All @@ -222,14 +244,10 @@ async def __call__(self, scope, receive, send) -> None:
# metadata is preserved. The context itself must not capture a second time.
with contexts.new_context(capture_exceptions=False, client=self.client):
headers = _headers_from_scope(scope)
if self.trust_tracing_headers:
session_id = _decode_header(headers.get(b"x-posthog-session-id"))
if session_id:
contexts.set_context_session(session_id)

distinct_id = _decode_header(headers.get(b"x-posthog-distinct-id"))
if distinct_id:
contexts.identify_context(distinct_id)
_apply_tracing_headers(
lambda name: headers.get(name.lower().encode(), b"").decode("latin-1"),
self.capture_tracing_headers,
)

properties = _extract_properties(scope, headers)
if self.extra_properties:
Expand Down
81 changes: 31 additions & 50 deletions posthog/integrations/django.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import re
from typing import TYPE_CHECKING, Any, Optional, cast

from .. import contexts
Expand All @@ -7,6 +6,7 @@
_ExceptionCaptureMetadata,
_capture_exception_with_metadata,
)
from ._tracing_headers import _apply_tracing_headers, _resolve_capture_tracing_headers

try:
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
Expand All @@ -27,35 +27,6 @@ def markcoroutinefunction(func):
from typing import Callable, Dict, Any, Union, Awaitable # noqa: F401


_MAX_TRACING_HEADER_LENGTH = 1000
_TRACING_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")


def _sanitize_tracing_header_value(value) -> Optional[str]:
"""Return a safe tracing header value, or None if the value is invalid.

Tracing headers come from user-controlled HTTP requests and are copied into event properties.
Match the PostHog app's header sanitization: accept strings only, remove C0/C1 control
characters, trim surrounding whitespace, cap length, and drop empty results.
"""
if not isinstance(value, str) or not value:
return None

return (
_TRACING_HEADER_CONTROL_CHARS_RE.sub("", value).strip()[
:_MAX_TRACING_HEADER_LENGTH
]
or None
)


def _get_sanitized_tracing_header(request, header_name) -> Optional[str]:
try:
return _sanitize_tracing_header_value(request.headers.get(header_name))
except Exception:
return None


class PosthogContextMiddleware:
"""Middleware to automatically track Django requests.

Expand All @@ -78,10 +49,13 @@ class PosthogContextMiddleware:
- `POSTHOG_MW_REQUEST_FILTER`: a callback receiving the request. Returning `False`
disables both enrichment and automatic exception capture for that request.
- `POSTHOG_MW_EXTRA_PROPERTIES`: a callback returning additional event properties.
- `POSTHOG_MW_TRUST_TRACING_HEADERS`: accept the client-controlled PostHog identity
and session headers only when `True` (default: `False`). These values are analytics
context, never authentication or authorization. Authenticated Django user identity
is still available when headers are disabled.
- `POSTHOG_MW_CAPTURE_TRACING_HEADERS`: read PostHog identity and session headers
as request-scoped analytics context (default: `True`). Explicit capture IDs and
event properties take precedence. These values are never authentication or
authorization. Authenticated Django user identity remains the fallback when no
usable header ID is available or header capture is disabled.
- `POSTHOG_MW_TRUST_TRACING_HEADERS`: deprecated alias for
`POSTHOG_MW_CAPTURE_TRACING_HEADERS`; the capture-named setting takes precedence.
- `POSTHOG_MW_PROPERTIES_MAP`: an optional callback transforming request properties.

The old `POSTHOG_MW_EXTRA_TAGS` and `POSTHOG_MW_TAG_MAP` settings remain aliases;
Expand Down Expand Up @@ -159,11 +133,17 @@ def __init__(self, get_response):
if capture_exceptions is None or isinstance(capture_exceptions, bool)
else True
)
capture_tracing_headers = getattr(
settings, "POSTHOG_MW_CAPTURE_TRACING_HEADERS", None
)
trust_tracing_headers = getattr(
settings, "POSTHOG_MW_TRUST_TRACING_HEADERS", False
settings, "POSTHOG_MW_TRUST_TRACING_HEADERS", None
)
self.trust_tracing_headers = (
trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False
self.capture_tracing_headers = _resolve_capture_tracing_headers(
capture_tracing_headers,
trust_tracing_headers,
canonical_name="POSTHOG_MW_CAPTURE_TRACING_HEADERS",
deprecated_name="POSTHOG_MW_TRUST_TRACING_HEADERS",
)

if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
Expand All @@ -173,6 +153,15 @@ def __init__(self, get_response):
else:
self.client = None

@property
def trust_tracing_headers(self) -> bool:
"""Deprecated alias for ``capture_tracing_headers``."""
return self.capture_tracing_headers

@trust_tracing_headers.setter
def trust_tracing_headers(self, enabled: bool) -> None:
self.capture_tracing_headers = enabled

@property
def extra_tags(self):
"""Compatibility alias for ``extra_properties``."""
Expand Down Expand Up @@ -211,19 +200,11 @@ def _build_tags(self, request, user_id, user_email):
"""
tags = {}

# Client-controlled headers are analytics context, not authenticated identity.
session_id = None
distinct_id = user_id
if self.trust_tracing_headers:
session_id = _get_sanitized_tracing_header(request, "X-POSTHOG-SESSION-ID")
distinct_id = (
_get_sanitized_tracing_header(request, "X-POSTHOG-DISTINCT-ID")
or user_id
)
if session_id:
contexts.set_context_session(session_id)
if distinct_id:
contexts.identify_context(distinct_id)
_apply_tracing_headers(
request.headers.get,
self.capture_tracing_headers,
fallback_distinct_id=user_id,
)

# Extract user email
if user_email:
Expand Down
Loading
Loading