Skip to content

Commit 780cba9

Browse files
authored
fix(integrations): align tracing-header capture across frameworks (#1061)
1 parent 12e88a2 commit 780cba9

12 files changed

Lines changed: 479 additions & 144 deletions
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+
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.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Shared request-scoped analytics attribution for framework integrations."""
2+
3+
import re
4+
import warnings
5+
from collections.abc import Callable
6+
from typing import Optional
7+
8+
from .. import contexts
9+
10+
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
11+
_MAX_HEADER_LENGTH = 1000
12+
13+
14+
def _sanitize_tracing_header_value(value: object) -> Optional[str]:
15+
if not isinstance(value, str):
16+
return None
17+
return _CONTROL_CHARS_RE.sub("", value).strip()[:_MAX_HEADER_LENGTH] or None
18+
19+
20+
def _resolve_capture_tracing_headers(
21+
capture: Optional[bool],
22+
trust: Optional[bool],
23+
*,
24+
canonical_name: str = "capture_tracing_headers",
25+
deprecated_name: str = "trust_tracing_headers",
26+
) -> bool:
27+
if trust is not None:
28+
warnings.warn(
29+
f"{deprecated_name} is deprecated; use {canonical_name} instead.",
30+
DeprecationWarning,
31+
stacklevel=3,
32+
)
33+
if capture is not None:
34+
return capture if isinstance(capture, bool) else False
35+
if trust is not None:
36+
return trust if isinstance(trust, bool) else False
37+
return True
38+
39+
40+
def _read_tracing_header(
41+
get_header: Callable[[str], object], name: str
42+
) -> Optional[str]:
43+
try:
44+
return _sanitize_tracing_header_value(get_header(name))
45+
except Exception:
46+
return None
47+
48+
49+
def _apply_tracing_headers(
50+
get_header: Callable[[str], object],
51+
capture_tracing_headers: bool,
52+
fallback_distinct_id: Optional[str] = None,
53+
) -> None:
54+
distinct_id = fallback_distinct_id
55+
if capture_tracing_headers:
56+
header_id = _read_tracing_header(get_header, "X-POSTHOG-DISTINCT-ID")
57+
session_id = _read_tracing_header(get_header, "X-POSTHOG-SESSION-ID")
58+
distinct_id = header_id or distinct_id
59+
if session_id:
60+
contexts.set_context_session(session_id)
61+
if distinct_id:
62+
contexts.identify_context(distinct_id)

‎posthog/integrations/asgi.py‎

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@
2323
``another_client.capture``.
2424
2525
``X-PostHog-Distinct-ID`` and ``X-PostHog-Session-ID`` are client-controlled
26-
analytics context, not authentication. They are ignored by default. Set
27-
``trust_tracing_headers=True`` only when deliberately accepting browser analytics
28-
attribution or when a trusted upstream strips and replaces incoming values.
26+
analytics context, not authentication or authorization. They are read by default
27+
once middleware is installed. Set ``capture_tracing_headers=False`` to ignore them.
2928
"""
3029

3130
import inspect
@@ -39,6 +38,11 @@
3938
_ExceptionCaptureMetadata,
4039
_capture_exception_with_metadata,
4140
)
41+
from ._tracing_headers import (
42+
_apply_tracing_headers,
43+
_resolve_capture_tracing_headers,
44+
_sanitize_tracing_header_value,
45+
)
4246

4347
_ASGIApp = Callable[
4448
[
@@ -92,9 +96,14 @@ def _headers_from_scope(scope: Mapping[str, Any]) -> dict[bytes, bytes]:
9296
and isinstance(item[0], bytes)
9397
and isinstance(item[1], bytes)
9498
):
95-
# Keep the first value. Tracing headers are singular, and joining arbitrary
96-
# duplicate user input can create misleading identity values.
97-
result.setdefault(item[0].lower(), item[1])
99+
name, value = item[0].lower(), item[1]
100+
# Tracing headers use the first usable value, never a joined identity.
101+
if name in {
102+
b"x-posthog-distinct-id",
103+
b"x-posthog-session-id",
104+
} and not _sanitize_tracing_header_value(value.decode("latin-1")):
105+
continue
106+
result.setdefault(name, value)
98107
return result
99108

100109

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

194203
def __init__(
@@ -198,14 +207,27 @@ def __init__(
198207
capture_exceptions: Optional[bool] = None,
199208
request_filter: Optional[_RequestFilter] = None,
200209
extra_properties: Optional[_ExtraProperties] = None,
201-
trust_tracing_headers: bool = False,
210+
trust_tracing_headers: Optional[bool] = None,
211+
*,
212+
capture_tracing_headers: Optional[bool] = None,
202213
) -> None:
203214
self.app = app
204215
self.client = client
205216
self.capture_exceptions = capture_exceptions
206217
self.request_filter = request_filter
207218
self.extra_properties = extra_properties
208-
self.trust_tracing_headers = trust_tracing_headers
219+
self.capture_tracing_headers = _resolve_capture_tracing_headers(
220+
capture_tracing_headers, trust_tracing_headers
221+
)
222+
223+
@property
224+
def trust_tracing_headers(self) -> bool:
225+
"""Deprecated alias for ``capture_tracing_headers``."""
226+
return self.capture_tracing_headers
227+
228+
@trust_tracing_headers.setter
229+
def trust_tracing_headers(self, enabled: bool) -> None:
230+
self.capture_tracing_headers = enabled
209231

210232
async def __call__(self, scope, receive, send) -> None:
211233
if scope.get("type") not in {"http", "websocket"}:
@@ -222,14 +244,10 @@ async def __call__(self, scope, receive, send) -> None:
222244
# metadata is preserved. The context itself must not capture a second time.
223245
with contexts.new_context(capture_exceptions=False, client=self.client):
224246
headers = _headers_from_scope(scope)
225-
if self.trust_tracing_headers:
226-
session_id = _decode_header(headers.get(b"x-posthog-session-id"))
227-
if session_id:
228-
contexts.set_context_session(session_id)
229-
230-
distinct_id = _decode_header(headers.get(b"x-posthog-distinct-id"))
231-
if distinct_id:
232-
contexts.identify_context(distinct_id)
247+
_apply_tracing_headers(
248+
lambda name: headers.get(name.lower().encode(), b"").decode("latin-1"),
249+
self.capture_tracing_headers,
250+
)
233251

234252
properties = _extract_properties(scope, headers)
235253
if self.extra_properties:

‎posthog/integrations/django.py‎

Lines changed: 31 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import re
21
from typing import TYPE_CHECKING, Any, Optional, cast
32

43
from .. import contexts
@@ -7,6 +6,7 @@
76
_ExceptionCaptureMetadata,
87
_capture_exception_with_metadata,
98
)
9+
from ._tracing_headers import _apply_tracing_headers, _resolve_capture_tracing_headers
1010

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

2929

30-
_MAX_TRACING_HEADER_LENGTH = 1000
31-
_TRACING_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
32-
33-
34-
def _sanitize_tracing_header_value(value) -> Optional[str]:
35-
"""Return a safe tracing header value, or None if the value is invalid.
36-
37-
Tracing headers come from user-controlled HTTP requests and are copied into event properties.
38-
Match the PostHog app's header sanitization: accept strings only, remove C0/C1 control
39-
characters, trim surrounding whitespace, cap length, and drop empty results.
40-
"""
41-
if not isinstance(value, str) or not value:
42-
return None
43-
44-
return (
45-
_TRACING_HEADER_CONTROL_CHARS_RE.sub("", value).strip()[
46-
:_MAX_TRACING_HEADER_LENGTH
47-
]
48-
or None
49-
)
50-
51-
52-
def _get_sanitized_tracing_header(request, header_name) -> Optional[str]:
53-
try:
54-
return _sanitize_tracing_header_value(request.headers.get(header_name))
55-
except Exception:
56-
return None
57-
58-
5930
class PosthogContextMiddleware:
6031
"""Middleware to automatically track Django requests.
6132
@@ -78,10 +49,13 @@ class PosthogContextMiddleware:
7849
- `POSTHOG_MW_REQUEST_FILTER`: a callback receiving the request. Returning `False`
7950
disables both enrichment and automatic exception capture for that request.
8051
- `POSTHOG_MW_EXTRA_PROPERTIES`: a callback returning additional event properties.
81-
- `POSTHOG_MW_TRUST_TRACING_HEADERS`: accept the client-controlled PostHog identity
82-
and session headers only when `True` (default: `False`). These values are analytics
83-
context, never authentication or authorization. Authenticated Django user identity
84-
is still available when headers are disabled.
52+
- `POSTHOG_MW_CAPTURE_TRACING_HEADERS`: read PostHog identity and session headers
53+
as request-scoped analytics context (default: `True`). Explicit capture IDs and
54+
event properties take precedence. These values are never authentication or
55+
authorization. Authenticated Django user identity remains the fallback when no
56+
usable header ID is available or header capture is disabled.
57+
- `POSTHOG_MW_TRUST_TRACING_HEADERS`: deprecated alias for
58+
`POSTHOG_MW_CAPTURE_TRACING_HEADERS`; the capture-named setting takes precedence.
8559
- `POSTHOG_MW_PROPERTIES_MAP`: an optional callback transforming request properties.
8660
8761
The old `POSTHOG_MW_EXTRA_TAGS` and `POSTHOG_MW_TAG_MAP` settings remain aliases;
@@ -159,11 +133,17 @@ def __init__(self, get_response):
159133
if capture_exceptions is None or isinstance(capture_exceptions, bool)
160134
else True
161135
)
136+
capture_tracing_headers = getattr(
137+
settings, "POSTHOG_MW_CAPTURE_TRACING_HEADERS", None
138+
)
162139
trust_tracing_headers = getattr(
163-
settings, "POSTHOG_MW_TRUST_TRACING_HEADERS", False
140+
settings, "POSTHOG_MW_TRUST_TRACING_HEADERS", None
164141
)
165-
self.trust_tracing_headers = (
166-
trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False
142+
self.capture_tracing_headers = _resolve_capture_tracing_headers(
143+
capture_tracing_headers,
144+
trust_tracing_headers,
145+
canonical_name="POSTHOG_MW_CAPTURE_TRACING_HEADERS",
146+
deprecated_name="POSTHOG_MW_TRUST_TRACING_HEADERS",
167147
)
168148

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

156+
@property
157+
def trust_tracing_headers(self) -> bool:
158+
"""Deprecated alias for ``capture_tracing_headers``."""
159+
return self.capture_tracing_headers
160+
161+
@trust_tracing_headers.setter
162+
def trust_tracing_headers(self, enabled: bool) -> None:
163+
self.capture_tracing_headers = enabled
164+
176165
@property
177166
def extra_tags(self):
178167
"""Compatibility alias for ``extra_properties``."""
@@ -211,19 +200,11 @@ def _build_tags(self, request, user_id, user_email):
211200
"""
212201
tags = {}
213202

214-
# Client-controlled headers are analytics context, not authenticated identity.
215-
session_id = None
216-
distinct_id = user_id
217-
if self.trust_tracing_headers:
218-
session_id = _get_sanitized_tracing_header(request, "X-POSTHOG-SESSION-ID")
219-
distinct_id = (
220-
_get_sanitized_tracing_header(request, "X-POSTHOG-DISTINCT-ID")
221-
or user_id
222-
)
223-
if session_id:
224-
contexts.set_context_session(session_id)
225-
if distinct_id:
226-
contexts.identify_context(distinct_id)
203+
_apply_tracing_headers(
204+
request.headers.get,
205+
self.capture_tracing_headers,
206+
fallback_distinct_id=user_id,
207+
)
227208

228209
# Extract user email
229210
if user_email:

0 commit comments

Comments
 (0)