Skip to content

Commit 2f0d67d

Browse files
authored
feat(integrations): align request middleware configuration (#1040)
* Align ASGI and Flask integration defaults * feat(integrations): align request middleware configuration
1 parent 492d71b commit 2f0d67d

11 files changed

Lines changed: 705 additions & 90 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+
Align ASGI, Flask, and Django request integration configuration. ASGI and Flask now default to inheriting the effective client's exception-autocapture setting, with explicit capture overrides and context-only operation. Flask and Django require opt-in to accept PostHog tracing identity/session headers. Django adds property-named settings and extraction methods while preserving legacy aliases and its historical capture default; setting POSTHOG_MW_CAPTURE_EXCEPTIONS to None enables client inheritance. Document client routing, request-filter semantics, and event enrichment independently of error tracking.

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ SDK usage examples and code snippets live in the official documentation so they
2727
- [Python library docs](https://posthog.com/docs/libraries/python)
2828
- [Django framework docs](https://posthog.com/docs/libraries/django)
2929
- [Flask framework docs](https://posthog.com/docs/libraries/flask)
30+
- [Request integration configuration](references/middleware_configuration.md) — shared middleware options and compatibility notes
3031
- [OpenFeature provider docs](https://posthog.com/docs/feature-flags/installation/openfeature) — use PostHog flags through the [OpenFeature](https://openfeature.dev) Python SDK
3132

3233
## Contributing

‎posthog/integrations/asgi.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
Only HTTP and WebSocket connections are instrumented. Lifespan and custom ASGI
1818
scope types pass through unchanged.
1919
20+
The request context enriches events captured by any client while the request is
21+
running. Passing ``client`` only selects where automatically captured exceptions
22+
are sent; it does not redirect application calls to ``posthog.capture`` or
23+
``another_client.capture``.
24+
2025
``X-PostHog-Distinct-ID`` and ``X-PostHog-Session-ID`` are client-controlled
2126
analytics context, not authentication. They are ignored by default. Set
2227
``trust_tracing_headers=True`` only when deliberately accepting browser analytics
@@ -169,8 +174,12 @@ class PosthogASGIMiddleware:
169174
170175
Args:
171176
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.
177+
client: Optional destination for automatically captured exceptions. The
178+
global client is used by default. Request context still enriches events
179+
captured through any client and does not redirect capture calls.
180+
capture_exceptions: Capture exceptions escaping the downstream app. If
181+
omitted, inherit the effective client's ``enable_exception_autocapture``
182+
setting. Pass ``True`` or ``False`` to override it.
174183
request_filter: Optional sync or async callback receiving the ASGI scope.
175184
Returning ``False`` bypasses all instrumentation for that scope.
176185
extra_properties: Optional sync or async callback receiving the ASGI scope and
@@ -186,7 +195,7 @@ def __init__(
186195
self,
187196
app: _ASGIApp,
188197
client: Optional[Client] = None,
189-
capture_exceptions: bool = True,
198+
capture_exceptions: Optional[bool] = None,
190199
request_filter: Optional[_RequestFilter] = None,
191200
extra_properties: Optional[_ExtraProperties] = None,
192201
trust_tracing_headers: bool = False,
@@ -235,7 +244,12 @@ async def __call__(self, scope, receive, send) -> None:
235244
try:
236245
await self.app(scope, receive, send)
237246
except Exception as exception:
238-
if self.capture_exceptions:
247+
capture_exceptions = (
248+
self.capture_exceptions
249+
if self.capture_exceptions is not None
250+
else contexts._default_capture_exceptions(self.client)
251+
)
252+
if capture_exceptions:
239253
if self.client:
240254
_capture_exception_with_metadata(
241255
self.client, exception, _CAPTURE_METADATA

‎posthog/integrations/django.py‎

Lines changed: 103 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,26 @@ class PosthogContextMiddleware:
6969
- Forwarded IP address as `$ip`
7070
- User agent as `$user_agent`
7171
72-
The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
73-
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
74-
global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
75-
76-
The middleware behaviour is customisable through 3 additional functions:
77-
- `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
78-
- `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
79-
- `POSTHOG_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
80-
81-
You can use the `POSTHOG_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
82-
83-
Context tags are automatically included as properties on all events captured within a context, including exceptions.
72+
Configure the integration with these Django settings:
73+
- `POSTHOG_MW_CLIENT`: client used for automatic exception capture, or the global client.
74+
This does not redirect events explicitly captured through other clients.
75+
- `POSTHOG_MW_CAPTURE_EXCEPTIONS`: `False` enables context enrichment only;
76+
`True` enables automatic exception capture; `None` inherits the effective client's
77+
`enable_exception_autocapture`. When omitted, the legacy default is `True`.
78+
- `POSTHOG_MW_REQUEST_FILTER`: a callback receiving the request. Returning `False`
79+
disables both enrichment and automatic exception capture for that request.
80+
- `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.
85+
- `POSTHOG_MW_PROPERTIES_MAP`: an optional callback transforming request properties.
86+
87+
The old `POSTHOG_MW_EXTRA_TAGS` and `POSTHOG_MW_TAG_MAP` settings remain aliases;
88+
the property-named settings take precedence when both are configured.
89+
90+
Request properties are included on all events captured within the active context,
91+
including when automatic exception capture is disabled.
8492
See the context documentation for more information. The extracted distinct ID and session ID,
8593
if found, are used to associate all events captured in the middleware context with the same distinct ID
8694
and session as currently active on the frontend. See the documentation for `set_context_session`
@@ -113,15 +121,16 @@ def __init__(self, get_response):
113121

114122
from django.conf import settings
115123

116-
if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable(
117-
settings.POSTHOG_MW_EXTRA_TAGS
118-
):
119-
self.extra_tags = cast(
120-
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
121-
settings.POSTHOG_MW_EXTRA_TAGS,
122-
)
123-
else:
124-
self.extra_tags = None
124+
extra_properties = (
125+
settings.POSTHOG_MW_EXTRA_PROPERTIES
126+
if hasattr(settings, "POSTHOG_MW_EXTRA_PROPERTIES")
127+
else getattr(settings, "POSTHOG_MW_EXTRA_TAGS", None)
128+
)
129+
self.extra_properties = (
130+
cast("Callable[[HttpRequest], Dict[str, Any]]", extra_properties)
131+
if callable(extra_properties)
132+
else None
133+
)
125134

126135
if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable(
127136
settings.POSTHOG_MW_REQUEST_FILTER
@@ -133,22 +142,29 @@ def __init__(self, get_response):
133142
else:
134143
self.request_filter = None
135144

136-
if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable(
137-
settings.POSTHOG_MW_TAG_MAP
138-
):
139-
self.tag_map = cast(
140-
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
141-
settings.POSTHOG_MW_TAG_MAP,
142-
)
143-
else:
144-
self.tag_map = None
145+
properties_map = (
146+
settings.POSTHOG_MW_PROPERTIES_MAP
147+
if hasattr(settings, "POSTHOG_MW_PROPERTIES_MAP")
148+
else getattr(settings, "POSTHOG_MW_TAG_MAP", None)
149+
)
150+
self.properties_map = (
151+
cast("Callable[[Dict[str, Any]], Dict[str, Any]]", properties_map)
152+
if callable(properties_map)
153+
else None
154+
)
145155

146-
if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance(
147-
settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool
148-
):
149-
self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS
150-
else:
151-
self.capture_exceptions = True
156+
capture_exceptions = getattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS", True)
157+
self.capture_exceptions: Optional[bool] = (
158+
capture_exceptions
159+
if capture_exceptions is None or isinstance(capture_exceptions, bool)
160+
else True
161+
)
162+
trust_tracing_headers = getattr(
163+
settings, "POSTHOG_MW_TRUST_TRACING_HEADERS", False
164+
)
165+
self.trust_tracing_headers = (
166+
trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False
167+
)
152168

153169
if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
154170
settings.POSTHOG_MW_CLIENT, Client
@@ -157,9 +173,32 @@ def __init__(self, get_response):
157173
else:
158174
self.client = None
159175

176+
@property
177+
def extra_tags(self):
178+
"""Compatibility alias for ``extra_properties``."""
179+
return self.extra_properties
180+
181+
@extra_tags.setter
182+
def extra_tags(self, callback):
183+
self.extra_properties = callback
184+
185+
@property
186+
def tag_map(self):
187+
"""Compatibility alias for ``properties_map``."""
188+
return self.properties_map
189+
190+
@tag_map.setter
191+
def tag_map(self, callback):
192+
self.properties_map = callback
193+
160194
def extract_tags(self, request):
161195
# type: (HttpRequest) -> Dict[str, Any]
162-
"""Extract tags from request in sync context."""
196+
"""Compatibility alias for ``extract_properties``."""
197+
return self.extract_properties(request)
198+
199+
def extract_properties(self, request):
200+
# type: (HttpRequest) -> Dict[str, Any]
201+
"""Extract event properties and identity from a synchronous request."""
163202
user_id, user_email = self.extract_request_user(request)
164203
return self._build_tags(request, user_id, user_email)
165204

@@ -172,15 +211,17 @@ def _build_tags(self, request, user_id, user_email):
172211
"""
173212
tags = {}
174213

175-
# Extract session ID from X-POSTHOG-SESSION-ID header
176-
session_id = _get_sanitized_tracing_header(request, "X-POSTHOG-SESSION-ID")
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+
)
177223
if session_id:
178224
contexts.set_context_session(session_id)
179-
180-
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
181-
distinct_id = (
182-
_get_sanitized_tracing_header(request, "X-POSTHOG-DISTINCT-ID") or user_id
183-
)
184225
if distinct_id:
185226
contexts.identify_context(distinct_id)
186227

@@ -213,15 +254,14 @@ def _build_tags(self, request, user_id, user_email):
213254
tags["$user_agent"] = user_agent
214255
tags["$raw_user_agent"] = user_agent
215256

216-
# Apply extra tags if configured
217-
if self.extra_tags:
218-
extra = self.extra_tags(request)
257+
# Apply application-specific properties, then the optional transform.
258+
if self.extra_properties:
259+
extra = self.extra_properties(request)
219260
if extra:
220261
tags.update(extra)
221262

222-
# Apply tag mapping if configured
223-
if self.tag_map:
224-
tags = self.tag_map(tags)
263+
if self.properties_map:
264+
tags = self.properties_map(tags)
225265

226266
return tags
227267

@@ -232,9 +272,14 @@ def extract_request_user(self, request):
232272
return self._resolve_user_details(user)
233273

234274
async def aextract_tags(self, request):
275+
# type: (HttpRequest) -> Dict[str, Any]
276+
"""Compatibility alias for ``aextract_properties``."""
277+
return await self.aextract_properties(request)
278+
279+
async def aextract_properties(self, request):
235280
# type: (HttpRequest) -> Dict[str, Any]
236281
"""
237-
Async version of extract_tags for use in async request handling.
282+
Async version of extract_properties for use in async request handling.
238283
239284
Uses await request.auser() instead of request.user to avoid
240285
SynchronousOnlyOperation in async context.
@@ -361,7 +406,12 @@ def process_exception(self, request, exception):
361406
if self.request_filter and not self.request_filter(request):
362407
return
363408

364-
if not self.capture_exceptions:
409+
capture_exceptions = (
410+
self.capture_exceptions
411+
if self.capture_exceptions is not None
412+
else contexts._default_capture_exceptions(self.client)
413+
)
414+
if not capture_exceptions:
365415
return
366416

367417
# Context and tags already set by __call__ or __acall__
@@ -374,6 +424,6 @@ def process_exception(self, request, exception):
374424
if self.client:
375425
_capture_exception_with_metadata(self.client, exception, capture_metadata)
376426
else:
377-
from posthog import capture_exception
427+
from .. import capture_exception
378428

379429
cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata)

‎posthog/integrations/flask.py‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
1111
app = Flask(__name__)
1212
PosthogFlaskIntegration(app)
13+
14+
The request context enriches events captured by any client while the request is
15+
running. Passing ``client`` only selects where automatically captured exceptions
16+
are sent; it does not redirect application calls to ``posthog.capture`` or
17+
``another_client.capture``.
1318
"""
1419

1520
from __future__ import annotations
@@ -100,16 +105,24 @@ class PosthogFlaskIntegration:
100105
Args:
101106
app: An optional Flask application. If omitted, call :meth:`init_app`
102107
later (the Flask application-factory pattern).
103-
client: Optional PostHog client used to capture exceptions. The global
104-
client is used by default.
108+
client: Optional destination for automatically captured exceptions. The
109+
global client is used by default. Request context still enriches events
110+
captured through any client and does not redirect capture calls.
105111
capture_exceptions: Capture exceptions that reach Flask's unhandled
106-
exception machinery. Defaults to ``True``.
112+
exception machinery. If omitted, inherit the effective client's
113+
``enable_exception_autocapture`` setting. Pass ``True`` or ``False``
114+
to override it.
107115
request_filter: Optional callback receiving Flask's request object. A
108116
false return value disables both context and exception capture for
109117
that request.
110118
extra_properties: Optional callback returning additional event properties.
111119
This is useful for application-specific metadata such as an authenticated
112120
user's role. Values should not contain secrets or request bodies.
121+
trust_tracing_headers: Use client-provided PostHog distinct and session ID
122+
headers as analytics context. Disabled by default because these headers
123+
are not authenticated. Enable only when deliberately accepting browser
124+
attribution or when a trusted upstream replaces incoming values. Never
125+
use these identifiers for authorization.
113126
114127
The integration intentionally does not capture exceptions handled by an
115128
application error handler, expected HTTP exceptions, request or response
@@ -123,14 +136,16 @@ def __init__(
123136
app: Optional[Flask] = None,
124137
*,
125138
client: Optional[Client] = None,
126-
capture_exceptions: bool = True,
139+
capture_exceptions: Optional[bool] = None,
127140
request_filter: Optional[Callable[[Request], bool]] = None,
128141
extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None,
142+
trust_tracing_headers: bool = False,
129143
) -> None:
130144
self.client = client
131145
self.capture_exceptions = capture_exceptions
132146
self.request_filter = request_filter
133147
self.extra_properties = extra_properties
148+
self.trust_tracing_headers = trust_tracing_headers
134149

135150
if app is not None:
136151
self.init_app(app)
@@ -175,17 +190,18 @@ def _before_request(self) -> None:
175190
privacy_settings.apply()
176191
setattr(g, _REQUEST_STATE_KEY, _RequestState(scope=scope))
177192

178-
session_id = _sanitize_tracing_header_value(
179-
request.headers.get("X-POSTHOG-SESSION-ID")
180-
)
181-
if session_id:
182-
contexts.set_context_session(session_id)
193+
if self.trust_tracing_headers:
194+
session_id = _sanitize_tracing_header_value(
195+
request.headers.get("X-POSTHOG-SESSION-ID")
196+
)
197+
if session_id:
198+
contexts.set_context_session(session_id)
183199

184-
distinct_id = _sanitize_tracing_header_value(
185-
request.headers.get("X-POSTHOG-DISTINCT-ID")
186-
)
187-
if distinct_id:
188-
contexts.identify_context(distinct_id)
200+
distinct_id = _sanitize_tracing_header_value(
201+
request.headers.get("X-POSTHOG-DISTINCT-ID")
202+
)
203+
if distinct_id:
204+
contexts.identify_context(distinct_id)
189205

190206
for key, value in self._request_properties(request).items():
191207
contexts.tag(key, value)
@@ -223,7 +239,14 @@ def _request_properties(request: Request) -> dict[str, Any]:
223239
def _handle_unhandled_exception(
224240
self, sender: Flask, exception: BaseException, **kwargs: Any
225241
) -> None:
226-
if not self.capture_exceptions or not self._request_is_tracked():
242+
if not self._request_is_tracked():
243+
return
244+
capture_exceptions = (
245+
self.capture_exceptions
246+
if self.capture_exceptions is not None
247+
else contexts._default_capture_exceptions(self.client)
248+
)
249+
if not capture_exceptions:
227250
return
228251

229252
capture_metadata: _ExceptionCaptureMetadata = {

0 commit comments

Comments
 (0)