From 98ba4d745630c5ffc5349417f68149c6f2a8a368 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 11:39:47 +0200 Subject: [PATCH 1/4] feat(asgi): add error tracking middleware --- .sampo/changesets/asgi-error-tracking.md | 5 + posthog/integrations/asgi.py | 231 +++++++++++++ .../integrations/test_asgi_integration.py | 314 ++++++++++++++++++ references/public_api_snapshot.txt | 9 + 4 files changed, 559 insertions(+) create mode 100644 .sampo/changesets/asgi-error-tracking.md create mode 100644 posthog/integrations/asgi.py create mode 100644 posthog/test/integrations/test_asgi_integration.py diff --git a/.sampo/changesets/asgi-error-tracking.md b/.sampo/changesets/asgi-error-tracking.md new file mode 100644 index 00000000..5f5a1cc4 --- /dev/null +++ b/.sampo/changesets/asgi-error-tracking.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add framework-independent ASGI middleware for automatic request context and unhandled exception capture, supporting FastAPI, Starlette, Litestar, WebSockets, sync or async filters, and additional request tags without requiring a framework dependency. diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py new file mode 100644 index 00000000..74aa598c --- /dev/null +++ b/posthog/integrations/asgi.py @@ -0,0 +1,231 @@ +"""Framework-independent ASGI request context and exception capture. + +The middleware speaks the ASGI protocol directly and has no dependency on an ASGI +framework. It can therefore be used with FastAPI, Starlette, Litestar, or a raw +ASGI application:: + + from fastapi import FastAPI + from posthog.integrations.asgi import PosthogASGIMiddleware + + app = FastAPI() + app.add_middleware(PosthogASGIMiddleware) + +It can also wrap an application directly:: + + app = PosthogASGIMiddleware(app) + +Only HTTP and WebSocket connections are instrumented. Lifespan and custom ASGI +scope types pass through unchanged. +""" + +import inspect +import re +from collections.abc import Awaitable, Callable, Mapping +from typing import Any, Optional, Union, cast + +from .. import contexts +from ..client import Client +from ..exception_utils import _capture_exception_with_metadata + +_ASGIApp = Callable[ + [ + dict[str, Any], + Callable[[], Awaitable[dict[str, Any]]], + Callable[[dict[str, Any]], Awaitable[None]], + ], + Awaitable[None], +] +_RequestFilterResult = Union[bool, Awaitable[bool]] +_RequestFilter = Callable[[dict[str, Any]], _RequestFilterResult] +_ExtraTagsResult = Union[ + Optional[Mapping[str, Any]], Awaitable[Optional[Mapping[str, Any]]] +] +_ExtraTags = Callable[[dict[str, Any]], _ExtraTagsResult] + +_MAX_HEADER_LENGTH = 1000 +_MAX_PATH_LENGTH = 2048 +_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") +_CAPTURE_METADATA = { + "level": "error", + "source": "asgi.middleware", + "mechanism": {"type": "middleware", "handled": False}, +} + + +def _sanitize_text( + value: object, max_length: int = _MAX_HEADER_LENGTH +) -> Optional[str]: + if not isinstance(value, str) or not value: + return None + return _CONTROL_CHARS_RE.sub("", value).strip()[:max_length] or None + + +def _decode_header(value: object) -> Optional[str]: + if not isinstance(value, bytes): + return None + return _sanitize_text(value.decode("latin-1")) + + +def _headers_from_scope(scope: Mapping[str, Any]) -> dict[bytes, bytes]: + result: dict[bytes, bytes] = {} + headers = scope.get("headers", ()) + if not isinstance(headers, (list, tuple)): + return result + + for item in headers: + if ( + isinstance(item, (list, tuple)) + and len(item) == 2 + 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]) + return result + + +def _server_host(scope: Mapping[str, Any]) -> Optional[str]: + server = scope.get("server") + if not isinstance(server, (list, tuple)) or len(server) != 2: + return None + + hostname, port = server + if not isinstance(hostname, str) or not isinstance(port, int): + return None + + hostname = _sanitize_text(hostname) + if not hostname: + return None + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + + scheme = scope.get("scheme") + default_port = (scheme == "http" and port == 80) or ( + scheme == "https" and port == 443 + ) + return hostname if default_port else f"{hostname}:{port}" + + +def _extract_tags( + scope: Mapping[str, Any], headers: Mapping[bytes, bytes] +) -> dict[str, Any]: + tags: dict[str, Any] = {} + + method = _sanitize_text(scope.get("method"), max_length=32) + if method: + tags["$request_method"] = method + + path = _sanitize_text(scope.get("path"), max_length=_MAX_PATH_LENGTH) + if path: + tags["$request_path"] = path + + user_agent = _decode_header(headers.get(b"user-agent")) + if user_agent: + tags["$user_agent"] = user_agent + tags["$raw_user_agent"] = user_agent + + forwarded_for = _decode_header(headers.get(b"x-forwarded-for")) + if forwarded_for: + ip_address = _sanitize_text(forwarded_for.split(",", 1)[0]) + else: + client = scope.get("client") + ip_address = ( + _sanitize_text(client[0]) + if isinstance(client, (list, tuple)) + and client + and isinstance(client[0], str) + else None + ) + if ip_address: + tags["$ip"] = ip_address + + scheme = _sanitize_text(scope.get("scheme"), max_length=16) + host = _decode_header(headers.get(b"host")) or _server_host(scope) + if scheme and host and path: + # Deliberately omit query strings: they commonly contain secrets and + # high-cardinality values. The path remains available separately. + tags["$current_url"] = f"{scheme}://{host}{path}" + + return tags + + +async def _resolve_callback_result(value): + if inspect.isawaitable(value): + return await value + return value + + +class PosthogASGIMiddleware: + """Add PostHog context and automatic exception capture to an ASGI app. + + Args: + app: The downstream ASGI application. + client: Optional PostHog client. The global client is used by default. + capture_exceptions: Capture exceptions escaping the downstream app. + request_filter: Optional sync or async callback receiving the ASGI scope. + Returning ``False`` bypasses all instrumentation for that scope. + extra_tags: Optional sync or async callback receiving the ASGI scope and + returning additional context tags. + """ + + def __init__( + self, + app: _ASGIApp, + client: Optional[Client] = None, + capture_exceptions: bool = True, + request_filter: Optional[_RequestFilter] = None, + extra_tags: Optional[_ExtraTags] = None, + ) -> None: + self.app = app + self.client = client + self.capture_exceptions = capture_exceptions + self.request_filter = request_filter + self.extra_tags = extra_tags + + async def __call__(self, scope, receive, send) -> None: + if scope.get("type") not in {"http", "websocket"}: + await self.app(scope, receive, send) + return + + if self.request_filter and not await _resolve_callback_result( + self.request_filter(scope) + ): + await self.app(scope, receive, send) + return + + # Exception capture is explicit below so integration-specific mechanism + # 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) + 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) + + tags = _extract_tags(scope, headers) + if self.extra_tags: + extra_tags = await _resolve_callback_result(self.extra_tags(scope)) + if extra_tags: + tags.update(extra_tags) + for key, value in tags.items(): + contexts.tag(key, value) + + try: + await self.app(scope, receive, send) + except Exception as exception: + if self.capture_exceptions: + if self.client: + _capture_exception_with_metadata( + self.client, exception, _CAPTURE_METADATA + ) + else: + from .. import capture_exception + + cast(Any, capture_exception)( + exception, _capture_metadata=_CAPTURE_METADATA + ) + raise diff --git a/posthog/test/integrations/test_asgi_integration.py b/posthog/test/integrations/test_asgi_integration.py new file mode 100644 index 00000000..5964d8fb --- /dev/null +++ b/posthog/test/integrations/test_asgi_integration.py @@ -0,0 +1,314 @@ +from unittest.mock import Mock, patch + +import pytest + +from posthog import contexts +from posthog.client import Client +from posthog.integrations.asgi import PosthogASGIMiddleware + + +def http_scope(**overrides): + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "scheme": "https", + "method": "GET", + "path": "/api/items", + "raw_path": b"/api/items", + "query_string": b"token=secret", + "headers": [ + (b"host", b"api.example.com"), + (b"user-agent", b"test-agent/1.0"), + (b"x-forwarded-for", b"203.0.113.5, 10.0.0.1"), + (b"x-posthog-session-id", b"session-123"), + (b"x-posthog-distinct-id", b"user-456"), + ], + "client": ("198.51.100.8", 1234), + "server": ("api.example.com", 443), + } + scope.update(overrides) + return scope + + +async def noop_receive(): + return {"type": "http.disconnect"} + + +async def noop_send(message): + return None + + +@pytest.mark.asyncio +async def test_adds_request_tags_and_tracing_context_then_restores_parent(): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["distinct_id"] = contexts.get_context_distinct_id() + observed["tags"] = contexts.get_tags() + await send({"type": "http.response.start", "status": 204, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + sent = [] + + async def send(message): + sent.append(message) + + with contexts.new_context(fresh=True): + contexts.identify_context("parent-user") + contexts.tag("parent-tag", "kept") + middleware = PosthogASGIMiddleware(app) + await middleware(http_scope(method="POST"), noop_receive, send) + + assert contexts.get_context_distinct_id() == "parent-user" + assert contexts.get_context_session_id() is None + assert contexts.get_tags() == {"parent-tag": "kept"} + + assert observed["session_id"] == "session-123" + assert observed["distinct_id"] == "user-456" + assert observed["tags"] == { + "parent-tag": "kept", + "$request_method": "POST", + "$request_path": "/api/items", + "$user_agent": "test-agent/1.0", + "$raw_user_agent": "test-agent/1.0", + "$ip": "203.0.113.5", + "$current_url": "https://api.example.com/api/items", + } + assert sent == [ + {"type": "http.response.start", "status": 204, "headers": []}, + {"type": "http.response.body", "body": b""}, + ] + assert "secret" not in observed["tags"]["$current_url"] + + +@pytest.mark.asyncio +async def test_sanitizes_tracing_headers_and_uses_socket_ip_fallback(): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["distinct_id"] = contexts.get_context_distinct_id() + observed["tags"] = contexts.get_tags() + + scope = http_scope( + headers=[ + (b"host", b"example.com"), + (b"x-posthog-session-id", b" session\n-123 "), + (b"x-posthog-distinct-id", b" user\t-456 "), + ] + ) + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + + assert observed["session_id"] == "session-123" + assert observed["distinct_id"] == "user-456" + assert observed["tags"]["$ip"] == "198.51.100.8" + + +@pytest.mark.asyncio +async def test_malformed_and_duplicate_headers_are_handled_safely(): + observed = {} + + async def app(scope, receive, send): + observed["distinct_id"] = contexts.get_context_distinct_id() + + scope = http_scope( + headers=[ + (b"x-posthog-distinct-id", b"first"), + (b"X-POSTHOG-DISTINCT-ID", b"second"), + ("not-bytes", "ignored"), + (b"incomplete",), + ] + ) + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + + assert observed["distinct_id"] == "first" + + +@pytest.mark.asyncio +async def test_captures_exception_with_client_and_preserves_propagation(): + client = Mock() + error = RuntimeError("application failed") + + async def app(scope, receive, send): + assert contexts.get_tags()["$request_path"] == "/api/items" + raise error + + middleware = PosthogASGIMiddleware(app, client=client) + + with pytest.raises(RuntimeError, match="application failed") as raised: + await middleware(http_scope(), noop_receive, noop_send) + + assert raised.value is error + client.capture_exception.assert_called_once_with( + error, + _capture_metadata={ + "level": "error", + "source": "asgi.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) + + +@pytest.mark.asyncio +async def test_captured_event_uses_canonical_framework_boundary_metadata(): + error = RuntimeError("application failed") + + async def app(scope, receive, send): + raise error + + client = Client("test-api-key", sync_mode=True) + try: + with patch.object(client, "capture", return_value="event-id") as capture: + with pytest.raises(RuntimeError, match="application failed"): + await PosthogASGIMiddleware(app, client=client)( + http_scope(), noop_receive, noop_send + ) + + properties = capture.call_args.kwargs["properties"] + outermost = properties["$exception_list"][0] + assert properties["$exception_level"] == "error" + assert properties["$exception_source"] == "asgi.middleware" + assert outermost["mechanism"] == { + "type": "middleware", + "handled": False, + "exception_id": 0, + "synthetic": False, + } + finally: + client.shutdown() + + +@pytest.mark.asyncio +async def test_captures_exception_with_global_client(): + error = ValueError("bad request handler") + + async def app(scope, receive, send): + raise error + + with patch("posthog.capture_exception") as capture_exception: + with pytest.raises(ValueError, match="bad request handler"): + await PosthogASGIMiddleware(app)(http_scope(), noop_receive, noop_send) + + capture_exception.assert_called_once_with( + error, + _capture_metadata={ + "level": "error", + "source": "asgi.middleware", + "mechanism": {"type": "middleware", "handled": False}, + }, + ) + + +@pytest.mark.asyncio +async def test_can_disable_exception_capture(): + client = Mock() + + async def app(scope, receive, send): + raise LookupError("not captured") + + with pytest.raises(LookupError, match="not captured"): + await PosthogASGIMiddleware(app, client=client, capture_exceptions=False)( + http_scope(), noop_receive, noop_send + ) + + client.capture_exception.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_filter", [False, True]) +async def test_request_filter_bypasses_all_instrumentation(async_filter): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["tags"] = contexts.get_tags() + + if async_filter: + + async def request_filter(scope): + return False + + else: + + def request_filter(scope): + return False + + with contexts.new_context(fresh=True): + contexts.tag("existing", True) + await PosthogASGIMiddleware(app, request_filter=request_filter)( + http_scope(), noop_receive, noop_send + ) + + assert observed == {"session_id": None, "tags": {"existing": True}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("async_tags", [False, True]) +async def test_extra_tags_supports_sync_and_async_callbacks(async_tags): + observed = {} + + async def app(scope, receive, send): + observed.update(contexts.get_tags()) + + if async_tags: + + async def extra_tags(scope): + return {"framework": "fastapi"} + + else: + + def extra_tags(scope): + return {"framework": "starlette"} + + await PosthogASGIMiddleware(app, extra_tags=extra_tags)( + http_scope(), noop_receive, noop_send + ) + + assert observed["framework"] == ("fastapi" if async_tags else "starlette") + + +@pytest.mark.asyncio +async def test_websocket_scope_is_instrumented(): + observed = {} + + async def app(scope, receive, send): + observed["session_id"] = contexts.get_context_session_id() + observed["path"] = contexts.get_tags()["$request_path"] + + scope = http_scope(type="websocket", scheme="wss", method=None, path="/socket") + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + + assert observed == {"session_id": "session-123", "path": "/socket"} + + +@pytest.mark.asyncio +async def test_lifespan_scope_passes_through_without_context(): + scope = {"type": "lifespan"} + observed = {} + + async def app(received_scope, receive, send): + observed["scope"] = received_scope + observed["tags"] = contexts.get_tags() + + with contexts.new_context(fresh=True): + contexts.tag("existing", "value") + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + + assert observed == {"scope": scope, "tags": {"existing": "value"}} + + +@pytest.mark.asyncio +async def test_builds_url_from_server_when_host_header_is_absent(): + observed = {} + + async def app(scope, receive, send): + observed.update(contexts.get_tags()) + + scope = http_scope( + headers=[], scheme="http", server=("2001:db8::1", 8080), path="/health" + ) + await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + + assert observed["$current_url"] == "http://[2001:db8::1]:8080/health" diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 51ee00d9..fb7da7ba 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -334,6 +334,8 @@ alias posthog.inner_set_code_variables_mask_url_credentials_context -> posthog.c alias posthog.inner_set_context_device_id -> posthog.contexts.set_context_device_id alias posthog.inner_set_context_session -> posthog.contexts.set_context_session alias posthog.inner_tag -> posthog.contexts.tag +alias posthog.integrations.asgi.Client -> posthog.client.Client +alias posthog.integrations.asgi.contexts -> posthog.contexts alias posthog.integrations.django.Client -> posthog.client.Client alias posthog.integrations.django.contexts -> posthog.contexts alias posthog.mcp.CaptureEventData -> posthog.mcp.types.CaptureEventData @@ -861,6 +863,11 @@ attribute posthog.flag_definition_cache.FlagDefinitionCacheData.property_matchin attribute posthog.flag_definition_cache_provider = None 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.client = client +attribute posthog.integrations.asgi.PosthogASGIMiddleware.extra_tags = extra_tags +attribute posthog.integrations.asgi.PosthogASGIMiddleware.request_filter = request_filter 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 @@ -1197,6 +1204,7 @@ 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: bool = True, request_filter: Optional[_RequestFilter] = None, extra_tags: Optional[_ExtraTags] = 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.mcp.McpAnalytics(key: Any) @@ -1774,6 +1782,7 @@ module posthog.feature_flag_evaluations module posthog.feature_flags module posthog.flag_definition_cache module posthog.integrations +module posthog.integrations.asgi module posthog.integrations.celery module posthog.integrations.django module posthog.mcp From 134aa3581879d2ffe46a1ac8aab775280affe2a3 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 12:22:36 +0200 Subject: [PATCH 2/4] chore(asgi): type exception capture metadata --- posthog/integrations/asgi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index 74aa598c..d587c996 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -25,7 +25,10 @@ from .. import contexts from ..client import Client -from ..exception_utils import _capture_exception_with_metadata +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, +) _ASGIApp = Callable[ [ @@ -45,7 +48,7 @@ _MAX_HEADER_LENGTH = 1000 _MAX_PATH_LENGTH = 2048 _CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") -_CAPTURE_METADATA = { +_CAPTURE_METADATA: _ExceptionCaptureMetadata = { "level": "error", "source": "asgi.middleware", "mechanism": {"type": "middleware", "handled": False}, From e43ca8e25ec9f0acd603fec8717a645d0bae72bf Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 12:32:20 +0200 Subject: [PATCH 3/4] fix(asgi): make tracing header trust explicit --- .sampo/changesets/asgi-error-tracking.md | 2 +- posthog/integrations/asgi.py | 27 ++++++++++++++----- .../integrations/test_asgi_integration.py | 27 ++++++++++++++++--- references/public_api_snapshot.txt | 3 ++- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/.sampo/changesets/asgi-error-tracking.md b/.sampo/changesets/asgi-error-tracking.md index 5f5a1cc4..4b596e45 100644 --- a/.sampo/changesets/asgi-error-tracking.md +++ b/.sampo/changesets/asgi-error-tracking.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Add framework-independent ASGI middleware for automatic request context and unhandled exception capture, supporting FastAPI, Starlette, Litestar, WebSockets, sync or async filters, and additional request tags without requiring a framework dependency. +Add framework-independent ASGI middleware for automatic request context and unhandled exception capture, supporting FastAPI, Starlette, Litestar, WebSockets, sync or async filters, additional request tags, and opt-in PostHog tracing headers without requiring a framework dependency. diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index d587c996..e05ec86e 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -16,6 +16,11 @@ Only HTTP and WebSocket connections are instrumented. Lifespan and custom ASGI scope types pass through unchanged. + +``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. """ import inspect @@ -170,6 +175,11 @@ class PosthogASGIMiddleware: Returning ``False`` bypasses all instrumentation for that scope. extra_tags: Optional sync or async callback receiving the ASGI scope and returning additional context tags. + 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. """ def __init__( @@ -179,12 +189,14 @@ def __init__( capture_exceptions: bool = True, request_filter: Optional[_RequestFilter] = None, extra_tags: Optional[_ExtraTags] = None, + trust_tracing_headers: bool = False, ) -> None: self.app = app self.client = client self.capture_exceptions = capture_exceptions self.request_filter = request_filter self.extra_tags = extra_tags + self.trust_tracing_headers = trust_tracing_headers async def __call__(self, scope, receive, send) -> None: if scope.get("type") not in {"http", "websocket"}: @@ -201,13 +213,14 @@ 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) - 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) + 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) tags = _extract_tags(scope, headers) if self.extra_tags: diff --git a/posthog/test/integrations/test_asgi_integration.py b/posthog/test/integrations/test_asgi_integration.py index 5964d8fb..e2c850f9 100644 --- a/posthog/test/integrations/test_asgi_integration.py +++ b/posthog/test/integrations/test_asgi_integration.py @@ -58,7 +58,7 @@ async def send(message): with contexts.new_context(fresh=True): contexts.identify_context("parent-user") contexts.tag("parent-tag", "kept") - middleware = PosthogASGIMiddleware(app) + middleware = PosthogASGIMiddleware(app, trust_tracing_headers=True) await middleware(http_scope(method="POST"), noop_receive, send) assert contexts.get_context_distinct_id() == "parent-user" @@ -83,6 +83,19 @@ async def send(message): assert "secret" not in observed["tags"]["$current_url"] +@pytest.mark.asyncio +async def test_ignores_client_controlled_tracing_headers_by_default(): + 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) + + assert observed == {"session_id": None, "distinct_id": None} + + @pytest.mark.asyncio async def test_sanitizes_tracing_headers_and_uses_socket_ip_fallback(): observed = {} @@ -99,7 +112,9 @@ async def app(scope, receive, send): (b"x-posthog-distinct-id", b" user\t-456 "), ] ) - await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + scope, noop_receive, noop_send + ) assert observed["session_id"] == "session-123" assert observed["distinct_id"] == "user-456" @@ -121,7 +136,9 @@ async def app(scope, receive, send): (b"incomplete",), ] ) - await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) + await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + scope, noop_receive, noop_send + ) assert observed["distinct_id"] == "first" @@ -278,7 +295,9 @@ 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)(scope, noop_receive, noop_send) + await PosthogASGIMiddleware(app, trust_tracing_headers=True)( + scope, noop_receive, noop_send + ) assert observed == {"session_id": "session-123", "path": "/socket"} diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index fb7da7ba..218fb124 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -868,6 +868,7 @@ attribute posthog.integrations.asgi.PosthogASGIMiddleware.capture_exceptions = c attribute posthog.integrations.asgi.PosthogASGIMiddleware.client = client attribute posthog.integrations.asgi.PosthogASGIMiddleware.extra_tags = extra_tags attribute posthog.integrations.asgi.PosthogASGIMiddleware.request_filter = request_filter +attribute posthog.integrations.asgi.PosthogASGIMiddleware.trust_tracing_headers = trust_tracing_headers 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 @@ -1204,7 +1205,7 @@ 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: bool = True, request_filter: Optional[_RequestFilter] = None, extra_tags: Optional[_ExtraTags] = None) +class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_exceptions: bool = True, request_filter: Optional[_RequestFilter] = None, extra_tags: Optional[_ExtraTags] = None, trust_tracing_headers: bool = False) 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.mcp.McpAnalytics(key: Any) From c1512f87e2e1dc39aeaf4e7b74c7caf6bfd90387 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 12:37:40 +0200 Subject: [PATCH 4/4] refactor(asgi): expose request properties terminology --- .sampo/changesets/asgi-error-tracking.md | 2 +- posthog/integrations/asgi.py | 44 ++++++++++--------- .../integrations/test_asgi_integration.py | 40 ++++++++--------- references/public_api_snapshot.txt | 4 +- 4 files changed, 46 insertions(+), 44 deletions(-) diff --git a/.sampo/changesets/asgi-error-tracking.md b/.sampo/changesets/asgi-error-tracking.md index 4b596e45..b52661e7 100644 --- a/.sampo/changesets/asgi-error-tracking.md +++ b/.sampo/changesets/asgi-error-tracking.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Add framework-independent ASGI middleware for automatic request context and unhandled exception capture, supporting FastAPI, Starlette, Litestar, WebSockets, sync or async filters, additional request tags, and opt-in PostHog tracing headers without requiring a framework dependency. +Add framework-independent ASGI middleware for automatic request context and unhandled exception capture, supporting FastAPI, Starlette, Litestar, WebSockets, sync or async filters, additional request properties, and opt-in PostHog tracing headers without requiring a framework dependency. diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index e05ec86e..b00c18c8 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -45,10 +45,10 @@ ] _RequestFilterResult = Union[bool, Awaitable[bool]] _RequestFilter = Callable[[dict[str, Any]], _RequestFilterResult] -_ExtraTagsResult = Union[ +_ExtraPropertiesResult = Union[ Optional[Mapping[str, Any]], Awaitable[Optional[Mapping[str, Any]]] ] -_ExtraTags = Callable[[dict[str, Any]], _ExtraTagsResult] +_ExtraProperties = Callable[[dict[str, Any]], _ExtraPropertiesResult] _MAX_HEADER_LENGTH = 1000 _MAX_PATH_LENGTH = 2048 @@ -115,23 +115,23 @@ def _server_host(scope: Mapping[str, Any]) -> Optional[str]: return hostname if default_port else f"{hostname}:{port}" -def _extract_tags( +def _extract_properties( scope: Mapping[str, Any], headers: Mapping[bytes, bytes] ) -> dict[str, Any]: - tags: dict[str, Any] = {} + properties: dict[str, Any] = {} method = _sanitize_text(scope.get("method"), max_length=32) if method: - tags["$request_method"] = method + properties["$request_method"] = method path = _sanitize_text(scope.get("path"), max_length=_MAX_PATH_LENGTH) if path: - tags["$request_path"] = path + properties["$request_path"] = path user_agent = _decode_header(headers.get(b"user-agent")) if user_agent: - tags["$user_agent"] = user_agent - tags["$raw_user_agent"] = user_agent + properties["$user_agent"] = user_agent + properties["$raw_user_agent"] = user_agent forwarded_for = _decode_header(headers.get(b"x-forwarded-for")) if forwarded_for: @@ -146,16 +146,16 @@ def _extract_tags( else None ) if ip_address: - tags["$ip"] = ip_address + properties["$ip"] = ip_address scheme = _sanitize_text(scope.get("scheme"), max_length=16) host = _decode_header(headers.get(b"host")) or _server_host(scope) if scheme and host and path: # Deliberately omit query strings: they commonly contain secrets and # high-cardinality values. The path remains available separately. - tags["$current_url"] = f"{scheme}://{host}{path}" + properties["$current_url"] = f"{scheme}://{host}{path}" - return tags + return properties async def _resolve_callback_result(value): @@ -173,8 +173,8 @@ class PosthogASGIMiddleware: capture_exceptions: Capture exceptions escaping the downstream app. request_filter: Optional sync or async callback receiving the ASGI scope. Returning ``False`` bypasses all instrumentation for that scope. - extra_tags: Optional sync or async callback receiving the ASGI scope and - returning additional context tags. + 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 @@ -188,14 +188,14 @@ def __init__( client: Optional[Client] = None, capture_exceptions: bool = True, request_filter: Optional[_RequestFilter] = None, - extra_tags: Optional[_ExtraTags] = None, + extra_properties: Optional[_ExtraProperties] = None, trust_tracing_headers: bool = False, ) -> None: self.app = app self.client = client self.capture_exceptions = capture_exceptions self.request_filter = request_filter - self.extra_tags = extra_tags + self.extra_properties = extra_properties self.trust_tracing_headers = trust_tracing_headers async def __call__(self, scope, receive, send) -> None: @@ -222,12 +222,14 @@ async def __call__(self, scope, receive, send) -> None: if distinct_id: contexts.identify_context(distinct_id) - tags = _extract_tags(scope, headers) - if self.extra_tags: - extra_tags = await _resolve_callback_result(self.extra_tags(scope)) - if extra_tags: - tags.update(extra_tags) - for key, value in tags.items(): + properties = _extract_properties(scope, headers) + if self.extra_properties: + extra_properties = await _resolve_callback_result( + self.extra_properties(scope) + ) + if extra_properties: + properties.update(extra_properties) + for key, value in properties.items(): contexts.tag(key, value) try: diff --git a/posthog/test/integrations/test_asgi_integration.py b/posthog/test/integrations/test_asgi_integration.py index e2c850f9..5ddaecfa 100644 --- a/posthog/test/integrations/test_asgi_integration.py +++ b/posthog/test/integrations/test_asgi_integration.py @@ -40,13 +40,13 @@ async def noop_send(message): @pytest.mark.asyncio -async def test_adds_request_tags_and_tracing_context_then_restores_parent(): +async def test_adds_request_properties_and_tracing_context_then_restores_parent(): observed = {} async def app(scope, receive, send): observed["session_id"] = contexts.get_context_session_id() observed["distinct_id"] = contexts.get_context_distinct_id() - observed["tags"] = contexts.get_tags() + observed["properties"] = contexts.get_tags() await send({"type": "http.response.start", "status": 204, "headers": []}) await send({"type": "http.response.body", "body": b""}) @@ -57,18 +57,18 @@ async def send(message): with contexts.new_context(fresh=True): contexts.identify_context("parent-user") - contexts.tag("parent-tag", "kept") + contexts.tag("parent-property", "kept") middleware = PosthogASGIMiddleware(app, trust_tracing_headers=True) await middleware(http_scope(method="POST"), noop_receive, send) assert contexts.get_context_distinct_id() == "parent-user" assert contexts.get_context_session_id() is None - assert contexts.get_tags() == {"parent-tag": "kept"} + assert contexts.get_tags() == {"parent-property": "kept"} assert observed["session_id"] == "session-123" assert observed["distinct_id"] == "user-456" - assert observed["tags"] == { - "parent-tag": "kept", + assert observed["properties"] == { + "parent-property": "kept", "$request_method": "POST", "$request_path": "/api/items", "$user_agent": "test-agent/1.0", @@ -80,7 +80,7 @@ async def send(message): {"type": "http.response.start", "status": 204, "headers": []}, {"type": "http.response.body", "body": b""}, ] - assert "secret" not in observed["tags"]["$current_url"] + assert "secret" not in observed["properties"]["$current_url"] @pytest.mark.asyncio @@ -103,7 +103,7 @@ async def test_sanitizes_tracing_headers_and_uses_socket_ip_fallback(): async def app(scope, receive, send): observed["session_id"] = contexts.get_context_session_id() observed["distinct_id"] = contexts.get_context_distinct_id() - observed["tags"] = contexts.get_tags() + observed["properties"] = contexts.get_tags() scope = http_scope( headers=[ @@ -118,7 +118,7 @@ async def app(scope, receive, send): assert observed["session_id"] == "session-123" assert observed["distinct_id"] == "user-456" - assert observed["tags"]["$ip"] == "198.51.100.8" + assert observed["properties"]["$ip"] == "198.51.100.8" @pytest.mark.asyncio @@ -240,7 +240,7 @@ async def test_request_filter_bypasses_all_instrumentation(async_filter): async def app(scope, receive, send): observed["session_id"] = contexts.get_context_session_id() - observed["tags"] = contexts.get_tags() + observed["properties"] = contexts.get_tags() if async_filter: @@ -258,32 +258,32 @@ def request_filter(scope): http_scope(), noop_receive, noop_send ) - assert observed == {"session_id": None, "tags": {"existing": True}} + assert observed == {"session_id": None, "properties": {"existing": True}} @pytest.mark.asyncio -@pytest.mark.parametrize("async_tags", [False, True]) -async def test_extra_tags_supports_sync_and_async_callbacks(async_tags): +@pytest.mark.parametrize("async_properties", [False, True]) +async def test_extra_properties_supports_sync_and_async_callbacks(async_properties): observed = {} async def app(scope, receive, send): observed.update(contexts.get_tags()) - if async_tags: + if async_properties: - async def extra_tags(scope): + async def extra_properties(scope): return {"framework": "fastapi"} else: - def extra_tags(scope): + def extra_properties(scope): return {"framework": "starlette"} - await PosthogASGIMiddleware(app, extra_tags=extra_tags)( + await PosthogASGIMiddleware(app, extra_properties=extra_properties)( http_scope(), noop_receive, noop_send ) - assert observed["framework"] == ("fastapi" if async_tags else "starlette") + assert observed["framework"] == ("fastapi" if async_properties else "starlette") @pytest.mark.asyncio @@ -309,13 +309,13 @@ async def test_lifespan_scope_passes_through_without_context(): async def app(received_scope, receive, send): observed["scope"] = received_scope - observed["tags"] = contexts.get_tags() + observed["properties"] = contexts.get_tags() with contexts.new_context(fresh=True): contexts.tag("existing", "value") await PosthogASGIMiddleware(app)(scope, noop_receive, noop_send) - assert observed == {"scope": scope, "tags": {"existing": "value"}} + assert observed == {"scope": scope, "properties": {"existing": "value"}} @pytest.mark.asyncio diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 218fb124..8c3300ec 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -866,7 +866,7 @@ 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.client = client -attribute posthog.integrations.asgi.PosthogASGIMiddleware.extra_tags = extra_tags +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.celery.PosthogCeleryIntegration.capture_exceptions = capture_exceptions @@ -1205,7 +1205,7 @@ 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: bool = True, request_filter: Optional[_RequestFilter] = None, extra_tags: Optional[_ExtraTags] = None, trust_tracing_headers: bool = False) +class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_exceptions: bool = True, request_filter: Optional[_RequestFilter] = None, extra_properties: Optional[_ExtraProperties] = None, trust_tracing_headers: bool = False) 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.mcp.McpAnalytics(key: Any)