From 6bb3ed777d28abaef04c1c9e728fcc3e793c007c Mon Sep 17 00:00:00 2001 From: Dustin Byrne Date: Fri, 16 Oct 2026 11:48:48 -0700 Subject: [PATCH 1/2] fix(integrations): align tracing-header capture across frameworks (#1061) (cherry picked from commit 780cba9aeea0d990a65fafe88860905bb4941d81) --- .sampo/changesets/doughty-seer-vellamo.md | 5 + posthog/integrations/_tracing_headers.py | 62 +++++ posthog/integrations/asgi.py | 60 +++-- posthog/integrations/django.py | 81 +++--- posthog/integrations/flask.py | 59 ++--- .../integrations/test_asgi_integration.py | 36 ++- .../integrations/test_flask_integration.py | 14 +- posthog/test/integrations/test_middleware.py | 7 +- .../test/integrations/test_middleware_api.py | 19 +- .../test/integrations/test_tracing_headers.py | 230 ++++++++++++++++++ references/middleware_configuration.md | 37 ++- references/public_api_snapshot.txt | 13 +- 12 files changed, 479 insertions(+), 144 deletions(-) create mode 100644 .sampo/changesets/doughty-seer-vellamo.md create mode 100644 posthog/integrations/_tracing_headers.py create mode 100644 posthog/test/integrations/test_tracing_headers.py diff --git a/.sampo/changesets/doughty-seer-vellamo.md b/.sampo/changesets/doughty-seer-vellamo.md new file mode 100644 index 000000000..d4f66dab3 --- /dev/null +++ b/.sampo/changesets/doughty-seer-vellamo.md @@ -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. diff --git a/posthog/integrations/_tracing_headers.py b/posthog/integrations/_tracing_headers.py new file mode 100644 index 000000000..084bab609 --- /dev/null +++ b/posthog/integrations/_tracing_headers.py @@ -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) diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index 670be43ec..5d381c4c0 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -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 @@ -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[ [ @@ -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 @@ -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__( @@ -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"}: @@ -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: diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index 7b973c0b7..f241c6cb6 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -1,4 +1,3 @@ -import re from typing import TYPE_CHECKING, Any, Optional, cast from .. import contexts @@ -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 @@ -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. @@ -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; @@ -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( @@ -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``.""" @@ -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: diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index b23b62095..8c416e5dc 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -19,7 +19,6 @@ from __future__ import annotations -import re from contextlib import AbstractContextManager from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, cast @@ -30,6 +29,10 @@ _ExceptionCaptureMetadata, _capture_exception_with_metadata, ) +from ._tracing_headers import ( + _apply_tracing_headers, + _resolve_capture_tracing_headers, +) if TYPE_CHECKING: from flask import Flask, Request @@ -37,25 +40,10 @@ __all__ = ["PosthogFlaskIntegration"] -_MAX_TRACING_HEADER_LENGTH = 1000 -_TRACING_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") _EXTENSION_KEY = "posthog" _REQUEST_STATE_KEY = "_posthog_integration_state" -def _sanitize_tracing_header_value(value: object) -> Optional[str]: - """Return a bounded tracing header value safe for event properties.""" - 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 - ) - - @dataclass class _RequestState: scope: AbstractContextManager[None] @@ -118,11 +106,11 @@ class PosthogFlaskIntegration: extra_properties: Optional callback returning additional event properties. This is useful for application-specific metadata such as an authenticated user's role. Values should not contain secrets or request bodies. - 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. The integration intentionally does not capture exceptions handled by an application error handler, expected HTTP exceptions, request or response @@ -139,17 +127,29 @@ def __init__( capture_exceptions: Optional[bool] = None, request_filter: Optional[Callable[[Request], bool]] = None, extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None, - trust_tracing_headers: bool = False, + trust_tracing_headers: Optional[bool] = None, + capture_tracing_headers: Optional[bool] = None, ) -> None: 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 + ) if app is not None: self.init_app(app) + @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 + def init_app(self, app: Flask) -> None: """Register the integration with a Flask application once.""" try: @@ -190,18 +190,7 @@ def _before_request(self) -> None: privacy_settings.apply() setattr(g, _REQUEST_STATE_KEY, _RequestState(scope=scope)) - if self.trust_tracing_headers: - session_id = _sanitize_tracing_header_value( - request.headers.get("X-POSTHOG-SESSION-ID") - ) - if session_id: - contexts.set_context_session(session_id) - - distinct_id = _sanitize_tracing_header_value( - request.headers.get("X-POSTHOG-DISTINCT-ID") - ) - if distinct_id: - contexts.identify_context(distinct_id) + _apply_tracing_headers(request.headers.get, self.capture_tracing_headers) for key, value in self._request_properties(request).items(): contexts.tag(key, value) diff --git a/posthog/test/integrations/test_asgi_integration.py b/posthog/test/integrations/test_asgi_integration.py index 63ddf938e..922f6fcde 100644 --- a/posthog/test/integrations/test_asgi_integration.py +++ b/posthog/test/integrations/test_asgi_integration.py @@ -59,7 +59,7 @@ async def send(message): with contexts.new_context(fresh=True): contexts.identify_context("parent-user") contexts.tag("parent-property", "kept") - middleware = PosthogASGIMiddleware(app, trust_tracing_headers=True) + middleware = PosthogASGIMiddleware(app, capture_tracing_headers=True) await middleware(http_scope(method="POST"), noop_receive, send) assert contexts.get_context_distinct_id() == "parent-user" @@ -85,14 +85,16 @@ async def send(message): @pytest.mark.asyncio -async def test_ignores_client_controlled_tracing_headers_by_default(): +async def test_tracing_header_capture_can_be_disabled(): observed = {} async def app(scope, receive, send): observed["session_id"] = contexts.get_context_session_id() observed["distinct_id"] = contexts.get_context_distinct_id() - await PosthogASGIMiddleware(app)(http_scope(), noop_receive, noop_send) + await PosthogASGIMiddleware(app, capture_tracing_headers=False)( + http_scope(), noop_receive, noop_send + ) assert observed == {"session_id": None, "distinct_id": None} @@ -113,7 +115,7 @@ async def app(scope, receive, send): (b"x-posthog-distinct-id", b" user\t-456 "), ] ) - await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + await PosthogASGIMiddleware(app, capture_tracing_headers=True)( scope, noop_receive, noop_send ) @@ -122,6 +124,28 @@ async def app(scope, receive, send): assert observed["properties"]["$ip"] == "198.51.100.8" +@pytest.mark.asyncio +@pytest.mark.parametrize("first", [b"", b" \t\r\n\x85 ", b"first"]) +async def test_duplicate_tracing_headers_use_first_usable_value(first): + observed = {} + + async def app(scope, receive, send): + observed["distinct_id"] = contexts.get_context_distinct_id() + observed["session_id"] = contexts.get_context_session_id() + + scope = http_scope( + headers=[ + (b"x-posthog-distinct-id", first), + (b"X-POSTHOG-DISTINCT-ID", b"second"), + (b"x-posthog-session-id", first), + (b"X-POSTHOG-SESSION-ID", b"second"), + ] + ) + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + expected = "first" if first == b"first" else "second" + assert observed == {"distinct_id": expected, "session_id": expected} + + @pytest.mark.asyncio async def test_malformed_and_duplicate_headers_are_handled_safely(): observed = {} @@ -137,7 +161,7 @@ async def app(scope, receive, send): (b"incomplete",), ] ) - await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + await PosthogASGIMiddleware(app, capture_tracing_headers=True)( scope, noop_receive, noop_send ) @@ -391,7 +415,7 @@ async def app(scope, receive, send): observed["path"] = contexts.get_tags()["$request_path"] scope = http_scope(type="websocket", scheme="wss", method=None, path="/socket") - await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + await PosthogASGIMiddleware(app, capture_tracing_headers=True)( scope, noop_receive, noop_send ) diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py index 9ce95dd3f..33a116631 100644 --- a/posthog/test/integrations/test_flask_integration.py +++ b/posthog/test/integrations/test_flask_integration.py @@ -8,10 +8,8 @@ import posthog from posthog import contexts from posthog.client import Client -from posthog.integrations.flask import ( - PosthogFlaskIntegration, - _sanitize_tracing_header_value, -) +from posthog.integrations.flask import PosthogFlaskIntegration +from posthog.integrations._tracing_headers import _sanitize_tracing_header_value def _app() -> Flask: @@ -25,7 +23,7 @@ def test_adds_request_context_and_restores_parent_context() -> None: PosthogFlaskIntegration( app, extra_properties=lambda request: {"tenant": "acme"}, - trust_tracing_headers=True, + capture_tracing_headers=True, ) @app.get("/users/") @@ -319,10 +317,10 @@ def capture_event(): exception_client.capture.assert_not_called() -def test_tracing_headers_are_ignored_by_default_and_can_be_trusted() -> None: - def request_identity(trust_tracing_headers: bool) -> dict: +def test_tracing_header_capture_can_be_disabled() -> None: + def request_identity(capture_tracing_headers: bool) -> dict: app = _app() - PosthogFlaskIntegration(app, trust_tracing_headers=trust_tracing_headers) + PosthogFlaskIntegration(app, capture_tracing_headers=capture_tracing_headers) @app.get("/identity") def identity(): diff --git a/posthog/test/integrations/test_middleware.py b/posthog/test/integrations/test_middleware.py index d58065bf3..336d7e017 100644 --- a/posthog/test/integrations/test_middleware.py +++ b/posthog/test/integrations/test_middleware.py @@ -56,7 +56,7 @@ def create_middleware( tag_map=None, capture_exceptions=True, get_response=None, - trust_tracing_headers=True, + capture_tracing_headers=True, ): """Helper to create middleware instance with mock Django settings""" if get_response is None: @@ -69,7 +69,8 @@ def create_middleware( mock_settings.POSTHOG_MW_TAG_MAP = tag_map mock_settings.POSTHOG_MW_CAPTURE_EXCEPTIONS = capture_exceptions mock_settings.POSTHOG_MW_CLIENT = None - mock_settings.POSTHOG_MW_TRUST_TRACING_HEADERS = trust_tracing_headers + mock_settings.POSTHOG_MW_CAPTURE_TRACING_HEADERS = capture_tracing_headers + mock_settings.POSTHOG_MW_TRUST_TRACING_HEADERS = None # Make hasattr work correctly def mock_hasattr(obj, name): @@ -474,7 +475,7 @@ class TestPosthogContextMiddlewareAsync(unittest.TestCase): def setUp(self): # Header propagation tests explicitly opt in to analytics attribution. - configuration = override_settings(POSTHOG_MW_TRUST_TRACING_HEADERS=True) + configuration = override_settings(POSTHOG_MW_CAPTURE_TRACING_HEADERS=True) configuration.enable() self.addCleanup(configuration.disable) diff --git a/posthog/test/integrations/test_middleware_api.py b/posthog/test/integrations/test_middleware_api.py index 23bb43ede..789e552dc 100644 --- a/posthog/test/integrations/test_middleware_api.py +++ b/posthog/test/integrations/test_middleware_api.py @@ -89,8 +89,9 @@ def test_omitted_capture_setting_preserves_legacy_default(): @pytest.mark.parametrize("asynchronous", [False, True]) -@pytest.mark.parametrize("trust", [False, True]) -def test_tracing_headers_require_opt_in_and_user_fallback(asynchronous, trust): +@pytest.mark.parametrize("capture", [None, False, True]) +@pytest.mark.parametrize("header_id", ["browser-user", " \r\n ", None]) +def test_tracing_header_capture_and_user_fallback(asynchronous, capture, header_id): observed = {} def view(request): @@ -103,7 +104,7 @@ async def async_view(request): request = MockRequest( headers={ - "X-POSTHOG-DISTINCT-ID": "browser-user", + "X-POSTHOG-DISTINCT-ID": header_id, "X-POSTHOG-SESSION-ID": "browser-session", } ) @@ -113,18 +114,18 @@ async def auser(): return request.user request.auser = auser - with override_settings( - POSTHOG_MW_CAPTURE_EXCEPTIONS=False, - POSTHOG_MW_TRUST_TRACING_HEADERS=trust, - ): + options = {} if capture is None else {"POSTHOG_MW_CAPTURE_TRACING_HEADERS": capture} + with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False, **options): middleware = PosthogContextMiddleware(async_view if asynchronous else view) with contexts.new_context(fresh=True, capture_exceptions=False): result = middleware(request) assert (asyncio.run(result) if asynchronous else result) == "response" assert observed == { - "distinct_id": "browser-user" if trust else "42", - "session_id": "browser-session" if trust else None, + "distinct_id": "browser-user" + if capture is not False and header_id == "browser-user" + else "42", + "session_id": "browser-session" if capture is not False else None, } diff --git a/posthog/test/integrations/test_tracing_headers.py b/posthog/test/integrations/test_tracing_headers.py new file mode 100644 index 000000000..39edf7c21 --- /dev/null +++ b/posthog/test/integrations/test_tracing_headers.py @@ -0,0 +1,230 @@ +"""Shared tracing-header contract through each framework's public entry point.""" + +from unittest.mock import Mock, patch +from uuid import UUID + +import pytest +import django +from django.conf import settings as django_settings +from django.test import RequestFactory, override_settings +from flask import Flask + +from posthog import contexts +from posthog.client import Client +from posthog.integrations.asgi import PosthogASGIMiddleware +from posthog.integrations.django import PosthogContextMiddleware +from posthog.integrations.flask import PosthogFlaskIntegration + + +@pytest.fixture(params=["asgi", "django", "django-async", "flask"]) +def run_request(request): + if not django_settings.configured: + django_settings.configure(SECRET_KEY="test-secret", INSTALLED_APPS=[]) + django.setup() + + async def run(callback, headers=None, **options): + headers = headers or {} + if request.param == "asgi": + + async def app(scope, receive, send): + callback() + + scope = { + "type": "http", + "headers": [ + (k.encode(), v.encode("latin-1")) for k, v in headers.items() + ], + } + await PosthogASGIMiddleware(app, **options)(scope, Mock(), Mock()) + elif request.param.startswith("django"): + + def view(request): + callback() + return "response" + + async def async_view(request): + return view(request) + + settings = {"POSTHOG_MW_" + k.upper(): v for k, v in options.items()} + with override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, + ALLOWED_HOSTS=["testserver"], + **settings, + ): + middleware = PosthogContextMiddleware( + async_view if request.param == "django-async" else view + ) + result = middleware(RequestFactory().get("/", headers=headers)) + if request.param == "django-async": + await result + else: + app = Flask(__name__) + PosthogFlaskIntegration(app, **options) + + @app.get("/") + def view(): + callback() + return "response" + + # WSGI values can contain controls even though Werkzeug's header builder + # rejects them. Exercise the integration's own sanitization as well. + environ = { + "HTTP_" + k.upper().replace("-", "_"): v for k, v in headers.items() + } + assert ( + app.test_client().get("/", environ_overrides=environ).status_code == 200 + ) + + return run + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [None, False, True]) +async def test_capture_tracing_headers_configuration(run_request, enabled): + observed = [] + options = {} if enabled is None else {"capture_tracing_headers": enabled} + await run_request( + lambda: observed.append( + (contexts.get_context_distinct_id(), contexts.get_context_session_id()) + ), + {"x-Posthog-Distinct-ID": "browser", "X-posthog-SESSION-id": "session"}, + **options, + ) + assert observed == ( + [(None, None)] if enabled is False else [("browser", "session")] + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("legacy", [False, True]) +@pytest.mark.parametrize("canonical", [None, False, True]) +async def test_deprecated_alias_and_canonical_precedence( + run_request, legacy, canonical +): + observed = [] + options = {"trust_tracing_headers": legacy} + if canonical is not None: + options["capture_tracing_headers"] = canonical + with pytest.warns( + DeprecationWarning, match="CAPTURE_TRACING_HEADERS|capture_tracing_headers" + ): + await run_request( + lambda: observed.append(contexts.get_context_distinct_id()), + {"X-PostHog-Distinct-ID": "browser"}, + **options, + ) + assert observed == ( + ["browser"] if (legacy if canonical is None else canonical) else [None] + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "value, expected", + [(" user\n-\t1\x85 ", "user-1"), ("x" * 1001, "x" * 1000), (" \r\n ", None)], +) +async def test_sanitization_is_identical(run_request, value, expected): + observed = [] + await run_request( + lambda: observed.append( + (contexts.get_context_distinct_id(), contexts.get_context_session_id()) + ), + {"X-PostHog-Distinct-ID": value, "X-PostHog-Session-ID": value}, + ) + assert observed == [(expected, expected)] + + +@pytest.fixture +def client(): + instance = Client("test-key", send=False) + yield instance + instance.shutdown() + + +@pytest.mark.asyncio +async def test_explicit_capture_values_override_headers(run_request, client): + with patch.object(client, "_enqueue") as enqueue: + + def capture(): + client.capture("implicit") + client.capture( + "explicit", + distinct_id="explicit-user", + properties={"$session_id": "explicit-session"}, + ) + contexts.identify_context("application-user") + client.capture("application") + + await run_request( + capture, + { + "X-PostHog-Distinct-ID": "header-user", + "X-PostHog-Session-ID": "header-session", + }, + ) + messages = [call.args[0] for call in enqueue.call_args_list] + assert [m["distinct_id"] for m in messages] == [ + "header-user", + "explicit-user", + "application-user", + ] + assert [m["properties"]["$session_id"] for m in messages] == [ + "header-session", + "explicit-session", + "header-session", + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("process_person_profile", [None, False, True]) +async def test_session_only_capture_is_personless_and_flags_are_empty( + run_request, client, process_person_profile +): + with ( + patch.object(client, "_enqueue") as enqueue, + patch("requests.Session.request") as network, + ): + + def capture(): + properties = ( + {} + if process_person_profile is None + else {"$process_person_profile": process_person_profile} + ) + client.capture("session-only", properties=properties) + flags = client.evaluate_flags() + assert flags.is_enabled("unknown") is False + assert flags.get_flag("unknown") is None + + await run_request(capture, {"X-PostHog-Session-ID": "header-session"}) + network.assert_not_called() + enqueue.assert_called_once() + event = enqueue.call_args.args[0] + assert UUID(event["distinct_id"]) + assert event["properties"]["$session_id"] == "header-session" + assert event["properties"]["$process_person_profile"] is ( + process_person_profile if process_person_profile is not None else False + ) + + +@pytest.mark.asyncio +async def test_request_context_is_restored_and_not_reused(run_request): + observed = [] + + def observe(): + observed.append( + (contexts.get_context_distinct_id(), contexts.get_context_session_id()) + ) + + with contexts.new_context(fresh=True, capture_exceptions=False): + await run_request( + observe, + { + "X-PostHog-Distinct-ID": "first-user", + "X-PostHog-Session-ID": "first-session", + }, + ) + assert contexts.get_context_distinct_id() is None + assert contexts.get_context_session_id() is None + await run_request(observe) + assert observed == [("first-user", "first-session"), (None, None)] diff --git a/references/middleware_configuration.md b/references/middleware_configuration.md index 1612117d4..2a065adaa 100644 --- a/references/middleware_configuration.md +++ b/references/middleware_configuration.md @@ -12,7 +12,7 @@ request event or redirect manually captured events to a different client. | `capture_exceptions` | `POSTHOG_MW_CAPTURE_EXCEPTIONS` | `True`: capture automatically; `False`: context only; `None`: inherit the effective client's `enable_exception_autocapture`. | | `request_filter` | `POSTHOG_MW_REQUEST_FILTER` | Callback returning `False` to skip both enrichment and automatic capture for a request. | | `extra_properties` | `POSTHOG_MW_EXTRA_PROPERTIES` | Callback returning additional event properties. Explicit properties passed to event capture take precedence. | -| `trust_tracing_headers` | `POSTHOG_MW_TRUST_TRACING_HEADERS` | Accept `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` only when `True`; defaults to `False`. | +| `capture_tracing_headers` | `POSTHOG_MW_CAPTURE_TRACING_HEADERS` | Read `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` into request-scoped analytics context; defaults to `True` once the integration is installed. | ASGI callbacks receive the ASGI scope and can be synchronous or asynchronous. Flask callbacks receive the Flask request. Django callbacks receive the Django @@ -38,12 +38,35 @@ old and new settings are present, the new setting takes precedence (including when set to `None` to disable the callback). Existing tag-named middleware methods and attributes remain compatibility aliases. -Tracing headers now require opt-in in Django and Flask, matching ASGI's secure -default. Applications intentionally relying on browser-to-backend analytics -attribution must enable the trust option. Sanitization limits malformed input; -it does not establish authenticity. Never use these headers for authentication or -authorization. Django can still use its authenticated request user as identity -when tracing headers are ignored. +## Tracing headers + +All three integrations capture tracing headers by default once installed. Disable +extraction with `capture_tracing_headers=False`, or +`POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` in Django. This does not disable request +metadata or automatic exception capture. + +`trust_tracing_headers` and `POSTHOG_MW_TRUST_TRACING_HEADERS` remain deprecated +aliases and emit `DeprecationWarning` when configured. Explicit `False` on either +alias continues to disable extraction. The capture-named option takes precedence +when both names are supplied; `None` means the option was not supplied. + +Header names are case-insensitive. Values must be strings; control characters are +removed, surrounding whitespace is trimmed, and values are capped at 1000 +characters. Empty or invalid values are ignored. Repeated ASGI tracing headers use +the first usable value rather than combining identities. A usable header distinct ID +sets the request context identity, replacing inherited context identity; Django +falls back to its authenticated request user ID when no usable header ID is +available or extraction is disabled. A usable session header sets the context +session and is attached to captured events as `$session_id`. + +Explicit per-call distinct IDs and event properties, including `$session_id`, +always take precedence over header-derived context. Application code may also +replace request context identity with `identify_context`. Context changes remain +request-local and are restored when request handling ends. + +These headers are client-controlled analytics context, never authentication or +authorization evidence. Sanitization limits malformed input; it does not establish +authenticity. ## Django REST Framework diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index ac48257d0..262287f3b 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -859,10 +859,11 @@ attribute posthog.host = None attribute posthog.in_app_modules = None attribute posthog.integrations.asgi.PosthogASGIMiddleware.app = app attribute posthog.integrations.asgi.PosthogASGIMiddleware.capture_exceptions = capture_exceptions +attribute posthog.integrations.asgi.PosthogASGIMiddleware.capture_tracing_headers = _resolve_capture_tracing_headers(capture_tracing_headers, trust_tracing_headers) attribute posthog.integrations.asgi.PosthogASGIMiddleware.client = client attribute posthog.integrations.asgi.PosthogASGIMiddleware.extra_properties = extra_properties attribute posthog.integrations.asgi.PosthogASGIMiddleware.request_filter = request_filter -attribute posthog.integrations.asgi.PosthogASGIMiddleware.trust_tracing_headers = trust_tracing_headers +attribute posthog.integrations.asgi.PosthogASGIMiddleware.trust_tracing_headers: bool attribute posthog.integrations.celery.PosthogCeleryIntegration.capture_exceptions = capture_exceptions attribute posthog.integrations.celery.PosthogCeleryIntegration.capture_task_lifecycle_events = capture_task_lifecycle_events attribute posthog.integrations.celery.PosthogCeleryIntegration.client = client @@ -870,6 +871,7 @@ attribute posthog.integrations.celery.PosthogCeleryIntegration.propagate_context attribute posthog.integrations.celery.PosthogCeleryIntegration.task_filter = task_filter attribute posthog.integrations.django.PosthogContextMiddleware.async_capable = True attribute posthog.integrations.django.PosthogContextMiddleware.capture_exceptions: Optional[bool] = capture_exceptions if (capture_exceptions is None or isinstance(capture_exceptions, bool)) else True +attribute posthog.integrations.django.PosthogContextMiddleware.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') attribute posthog.integrations.django.PosthogContextMiddleware.client = cast('Optional[Client]', settings.POSTHOG_MW_CLIENT) attribute posthog.integrations.django.PosthogContextMiddleware.extra_properties = cast('Callable[[HttpRequest], Dict[str, Any]]', extra_properties) if callable(extra_properties) else None attribute posthog.integrations.django.PosthogContextMiddleware.extra_tags @@ -878,12 +880,13 @@ attribute posthog.integrations.django.PosthogContextMiddleware.properties_map = attribute posthog.integrations.django.PosthogContextMiddleware.request_filter = cast('Optional[Callable[[HttpRequest], bool]]', settings.POSTHOG_MW_REQUEST_FILTER) attribute posthog.integrations.django.PosthogContextMiddleware.sync_capable = True attribute posthog.integrations.django.PosthogContextMiddleware.tag_map -attribute posthog.integrations.django.PosthogContextMiddleware.trust_tracing_headers = trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False +attribute posthog.integrations.django.PosthogContextMiddleware.trust_tracing_headers: bool attribute posthog.integrations.flask.PosthogFlaskIntegration.capture_exceptions = capture_exceptions +attribute posthog.integrations.flask.PosthogFlaskIntegration.capture_tracing_headers = _resolve_capture_tracing_headers(capture_tracing_headers, trust_tracing_headers) attribute posthog.integrations.flask.PosthogFlaskIntegration.client = client attribute posthog.integrations.flask.PosthogFlaskIntegration.extra_properties = extra_properties attribute posthog.integrations.flask.PosthogFlaskIntegration.request_filter = request_filter -attribute posthog.integrations.flask.PosthogFlaskIntegration.trust_tracing_headers = trust_tracing_headers +attribute posthog.integrations.flask.PosthogFlaskIntegration.trust_tracing_headers: bool attribute posthog.is_server = True attribute posthog.log_captured_exceptions = False attribute posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware.app = app @@ -1208,10 +1211,10 @@ class posthog.feature_flags.InconclusiveMatchError class posthog.feature_flags.RequiresServerEvaluation class posthog.flag_definition_cache.FlagDefinitionCacheData class posthog.flag_definition_cache.FlagDefinitionCacheProvider -class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, request_filter: Optional[_RequestFilter] = None, extra_properties: Optional[_ExtraProperties] = None, trust_tracing_headers: bool = False) +class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, request_filter: Optional[_RequestFilter] = None, extra_properties: Optional[_ExtraProperties] = None, trust_tracing_headers: Optional[bool] = None, *, capture_tracing_headers: Optional[bool] = None) 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: bool = False) +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.mcp.McpAnalytics(key: Any) class posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware(app: Any) class posthog.mcp.constants.PostHogMCPAnalyticsEvent From eadf5a3d09af5bc64cd543a449e40b6262dab8e0 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 22 Oct 2026 10:02:49 -0700 Subject: [PATCH 2/2] fix: adapt the tracing header port to v1 and note it in the 8.0 guide v1 turns person processing off for a generated distinct ID with an event option, which beats the legacy property, so the ported test checks the derived option. The 8.0 migration guide notes the default and the off switch. Co-Authored-By: Claude Opus 5.5 --- docs/migration-7.x-to-8.0.md | 10 ++++++++++ posthog/test/integrations/test_tracing_headers.py | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index 2592007c9..fccf12440 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -26,6 +26,7 @@ You need to change code if your app does any of these: - passes a personal API key (`phx_...`) as the project API key - reads event content from the SDK's debug log - calls `alias()` without a `distinct_id` and relies on the context distinct ID +- runs the Django, Flask or ASGI integration on 7.68 to 7.70 without tracing headers turned on (they are now read by default) ## Endpoints @@ -141,6 +142,15 @@ The SDK moves them out of `properties` for you. - `alias()` needs an explicit `distinct_id`. It no longer falls back to the context distinct ID, which can come from a request header the client controls. Without one, the call is dropped with a warning. - Context tags and context options now apply to `alias` and `group_identify`, below the call's own values, as they do for `capture`. +## Tracing headers + +The Django, Flask and ASGI integrations read the `X-PostHog-Distinct-Id` and `X-PostHog-Session-Id` headers by default, as in 7.71 and later. +The headers only fill a missing distinct ID or session ID, and any client can set them, so never use them for authentication. + +- To turn this off, set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` in Django, or pass `capture_tracing_headers=False` to the Flask or ASGI integration. +- The 7.68 `trust_tracing_headers` and `POSTHOG_MW_TRUST_TRACING_HEADERS` names still work, but are deprecated. +- If you upgrade from 7.68, 7.69 or 7.70 and never turned these headers on, they are now read. + ## Exceptions - `capture_exception` takes a `level=` argument on `Client`, `AsyncPosthog` and the module. It sets `$exception_level`, for example `"warning"` or `"fatal"`. The default is `"error"`, and an unknown value counts as unset. diff --git a/posthog/test/integrations/test_tracing_headers.py b/posthog/test/integrations/test_tracing_headers.py index 39edf7c21..a48331a24 100644 --- a/posthog/test/integrations/test_tracing_headers.py +++ b/posthog/test/integrations/test_tracing_headers.py @@ -202,9 +202,9 @@ def capture(): event = enqueue.call_args.args[0] assert UUID(event["distinct_id"]) assert event["properties"]["$session_id"] == "header-session" - assert event["properties"]["$process_person_profile"] is ( - process_person_profile if process_person_profile is not None else False - ) + assert enqueue.call_args.kwargs["derived_options"] == { + "process_person_profile": False + } @pytest.mark.asyncio