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 7d3ccc754..af68247a7 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 b66a265f1..29a1c477e 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -867,10 +867,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 @@ -878,6 +879,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 @@ -886,12 +888,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 @@ -1217,10 +1220,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