Skip to content

Commit 1c6a47a

Browse files
authored
feat(asgi): add error tracking middleware (#1037)
* feat(asgi): add error tracking middleware * chore(asgi): type exception capture metadata * fix(asgi): make tracing header trust explicit * refactor(asgi): expose request properties terminology
1 parent 50efa65 commit 1c6a47a

4 files changed

Lines changed: 597 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
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.

‎posthog/integrations/asgi.py‎

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
"""Framework-independent ASGI request context and exception capture.
2+
3+
The middleware speaks the ASGI protocol directly and has no dependency on an ASGI
4+
framework. It can therefore be used with FastAPI, Starlette, Litestar, or a raw
5+
ASGI application::
6+
7+
from fastapi import FastAPI
8+
from posthog.integrations.asgi import PosthogASGIMiddleware
9+
10+
app = FastAPI()
11+
app.add_middleware(PosthogASGIMiddleware)
12+
13+
It can also wrap an application directly::
14+
15+
app = PosthogASGIMiddleware(app)
16+
17+
Only HTTP and WebSocket connections are instrumented. Lifespan and custom ASGI
18+
scope types pass through unchanged.
19+
20+
``X-PostHog-Distinct-ID`` and ``X-PostHog-Session-ID`` are client-controlled
21+
analytics context, not authentication. They are ignored by default. Set
22+
``trust_tracing_headers=True`` only when deliberately accepting browser analytics
23+
attribution or when a trusted upstream strips and replaces incoming values.
24+
"""
25+
26+
import inspect
27+
import re
28+
from collections.abc import Awaitable, Callable, Mapping
29+
from typing import Any, Optional, Union, cast
30+
31+
from .. import contexts
32+
from ..client import Client
33+
from ..exception_utils import (
34+
_ExceptionCaptureMetadata,
35+
_capture_exception_with_metadata,
36+
)
37+
38+
_ASGIApp = Callable[
39+
[
40+
dict[str, Any],
41+
Callable[[], Awaitable[dict[str, Any]]],
42+
Callable[[dict[str, Any]], Awaitable[None]],
43+
],
44+
Awaitable[None],
45+
]
46+
_RequestFilterResult = Union[bool, Awaitable[bool]]
47+
_RequestFilter = Callable[[dict[str, Any]], _RequestFilterResult]
48+
_ExtraPropertiesResult = Union[
49+
Optional[Mapping[str, Any]], Awaitable[Optional[Mapping[str, Any]]]
50+
]
51+
_ExtraProperties = Callable[[dict[str, Any]], _ExtraPropertiesResult]
52+
53+
_MAX_HEADER_LENGTH = 1000
54+
_MAX_PATH_LENGTH = 2048
55+
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
56+
_CAPTURE_METADATA: _ExceptionCaptureMetadata = {
57+
"level": "error",
58+
"source": "asgi.middleware",
59+
"mechanism": {"type": "middleware", "handled": False},
60+
}
61+
62+
63+
def _sanitize_text(
64+
value: object, max_length: int = _MAX_HEADER_LENGTH
65+
) -> Optional[str]:
66+
if not isinstance(value, str) or not value:
67+
return None
68+
return _CONTROL_CHARS_RE.sub("", value).strip()[:max_length] or None
69+
70+
71+
def _decode_header(value: object) -> Optional[str]:
72+
if not isinstance(value, bytes):
73+
return None
74+
return _sanitize_text(value.decode("latin-1"))
75+
76+
77+
def _headers_from_scope(scope: Mapping[str, Any]) -> dict[bytes, bytes]:
78+
result: dict[bytes, bytes] = {}
79+
headers = scope.get("headers", ())
80+
if not isinstance(headers, (list, tuple)):
81+
return result
82+
83+
for item in headers:
84+
if (
85+
isinstance(item, (list, tuple))
86+
and len(item) == 2
87+
and isinstance(item[0], bytes)
88+
and isinstance(item[1], bytes)
89+
):
90+
# Keep the first value. Tracing headers are singular, and joining arbitrary
91+
# duplicate user input can create misleading identity values.
92+
result.setdefault(item[0].lower(), item[1])
93+
return result
94+
95+
96+
def _server_host(scope: Mapping[str, Any]) -> Optional[str]:
97+
server = scope.get("server")
98+
if not isinstance(server, (list, tuple)) or len(server) != 2:
99+
return None
100+
101+
hostname, port = server
102+
if not isinstance(hostname, str) or not isinstance(port, int):
103+
return None
104+
105+
hostname = _sanitize_text(hostname)
106+
if not hostname:
107+
return None
108+
if ":" in hostname and not hostname.startswith("["):
109+
hostname = f"[{hostname}]"
110+
111+
scheme = scope.get("scheme")
112+
default_port = (scheme == "http" and port == 80) or (
113+
scheme == "https" and port == 443
114+
)
115+
return hostname if default_port else f"{hostname}:{port}"
116+
117+
118+
def _extract_properties(
119+
scope: Mapping[str, Any], headers: Mapping[bytes, bytes]
120+
) -> dict[str, Any]:
121+
properties: dict[str, Any] = {}
122+
123+
method = _sanitize_text(scope.get("method"), max_length=32)
124+
if method:
125+
properties["$request_method"] = method
126+
127+
path = _sanitize_text(scope.get("path"), max_length=_MAX_PATH_LENGTH)
128+
if path:
129+
properties["$request_path"] = path
130+
131+
user_agent = _decode_header(headers.get(b"user-agent"))
132+
if user_agent:
133+
properties["$user_agent"] = user_agent
134+
properties["$raw_user_agent"] = user_agent
135+
136+
forwarded_for = _decode_header(headers.get(b"x-forwarded-for"))
137+
if forwarded_for:
138+
ip_address = _sanitize_text(forwarded_for.split(",", 1)[0])
139+
else:
140+
client = scope.get("client")
141+
ip_address = (
142+
_sanitize_text(client[0])
143+
if isinstance(client, (list, tuple))
144+
and client
145+
and isinstance(client[0], str)
146+
else None
147+
)
148+
if ip_address:
149+
properties["$ip"] = ip_address
150+
151+
scheme = _sanitize_text(scope.get("scheme"), max_length=16)
152+
host = _decode_header(headers.get(b"host")) or _server_host(scope)
153+
if scheme and host and path:
154+
# Deliberately omit query strings: they commonly contain secrets and
155+
# high-cardinality values. The path remains available separately.
156+
properties["$current_url"] = f"{scheme}://{host}{path}"
157+
158+
return properties
159+
160+
161+
async def _resolve_callback_result(value):
162+
if inspect.isawaitable(value):
163+
return await value
164+
return value
165+
166+
167+
class PosthogASGIMiddleware:
168+
"""Add PostHog context and automatic exception capture to an ASGI app.
169+
170+
Args:
171+
app: The downstream ASGI application.
172+
client: Optional PostHog client. The global client is used by default.
173+
capture_exceptions: Capture exceptions escaping the downstream app.
174+
request_filter: Optional sync or async callback receiving the ASGI scope.
175+
Returning ``False`` bypasses all instrumentation for that scope.
176+
extra_properties: Optional sync or async callback receiving the ASGI scope and
177+
returning additional event properties.
178+
trust_tracing_headers: Use client-provided PostHog distinct and session ID
179+
headers as analytics context. Disabled by default because these headers
180+
are not authenticated. Enable only when deliberately accepting browser
181+
attribution or when a trusted upstream replaces incoming values. Never
182+
use these identifiers for authorization.
183+
"""
184+
185+
def __init__(
186+
self,
187+
app: _ASGIApp,
188+
client: Optional[Client] = None,
189+
capture_exceptions: bool = True,
190+
request_filter: Optional[_RequestFilter] = None,
191+
extra_properties: Optional[_ExtraProperties] = None,
192+
trust_tracing_headers: bool = False,
193+
) -> None:
194+
self.app = app
195+
self.client = client
196+
self.capture_exceptions = capture_exceptions
197+
self.request_filter = request_filter
198+
self.extra_properties = extra_properties
199+
self.trust_tracing_headers = trust_tracing_headers
200+
201+
async def __call__(self, scope, receive, send) -> None:
202+
if scope.get("type") not in {"http", "websocket"}:
203+
await self.app(scope, receive, send)
204+
return
205+
206+
if self.request_filter and not await _resolve_callback_result(
207+
self.request_filter(scope)
208+
):
209+
await self.app(scope, receive, send)
210+
return
211+
212+
# Exception capture is explicit below so integration-specific mechanism
213+
# metadata is preserved. The context itself must not capture a second time.
214+
with contexts.new_context(capture_exceptions=False, client=self.client):
215+
headers = _headers_from_scope(scope)
216+
if self.trust_tracing_headers:
217+
session_id = _decode_header(headers.get(b"x-posthog-session-id"))
218+
if session_id:
219+
contexts.set_context_session(session_id)
220+
221+
distinct_id = _decode_header(headers.get(b"x-posthog-distinct-id"))
222+
if distinct_id:
223+
contexts.identify_context(distinct_id)
224+
225+
properties = _extract_properties(scope, headers)
226+
if self.extra_properties:
227+
extra_properties = await _resolve_callback_result(
228+
self.extra_properties(scope)
229+
)
230+
if extra_properties:
231+
properties.update(extra_properties)
232+
for key, value in properties.items():
233+
contexts.tag(key, value)
234+
235+
try:
236+
await self.app(scope, receive, send)
237+
except Exception as exception:
238+
if self.capture_exceptions:
239+
if self.client:
240+
_capture_exception_with_metadata(
241+
self.client, exception, _CAPTURE_METADATA
242+
)
243+
else:
244+
from .. import capture_exception
245+
246+
cast(Any, capture_exception)(
247+
exception, _capture_metadata=_CAPTURE_METADATA
248+
)
249+
raise

0 commit comments

Comments
 (0)