Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .sampo/changesets/consistent-request-integration-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ SDK usage examples and code snippets live in the official documentation so they
- [Python library docs](https://posthog.com/docs/libraries/python)
- [Django framework docs](https://posthog.com/docs/libraries/django)
- [Flask framework docs](https://posthog.com/docs/libraries/flask)
- [Request integration configuration](references/middleware_configuration.md) — shared middleware options and compatibility notes
- [OpenFeature provider docs](https://posthog.com/docs/feature-flags/installation/openfeature) — use PostHog flags through the [OpenFeature](https://openfeature.dev) Python SDK

## Contributing
Expand Down
22 changes: 18 additions & 4 deletions posthog/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
Only HTTP and WebSocket connections are instrumented. Lifespan and custom ASGI
scope types pass through unchanged.

The request context enriches events captured by any client while the request is
running. Passing ``client`` only selects where automatically captured exceptions
are sent; it does not redirect application calls to ``posthog.capture`` or
``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
Expand Down Expand Up @@ -169,8 +174,12 @@ class PosthogASGIMiddleware:

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.
client: Optional destination for automatically captured exceptions. The
global client is used by default. Request context still enriches events
captured through any client and does not redirect capture calls.
capture_exceptions: Capture exceptions escaping the downstream app. If
omitted, inherit the effective client's ``enable_exception_autocapture``
setting. Pass ``True`` or ``False`` to override it.
request_filter: Optional sync or async callback receiving the ASGI scope.
Returning ``False`` bypasses all instrumentation for that scope.
extra_properties: Optional sync or async callback receiving the ASGI scope and
Expand All @@ -186,7 +195,7 @@ def __init__(
self,
app: _ASGIApp,
client: Optional[Client] = None,
capture_exceptions: bool = True,
capture_exceptions: Optional[bool] = None,
request_filter: Optional[_RequestFilter] = None,
extra_properties: Optional[_ExtraProperties] = None,
trust_tracing_headers: bool = False,
Expand Down Expand Up @@ -235,7 +244,12 @@ async def __call__(self, scope, receive, send) -> None:
try:
await self.app(scope, receive, send)
except Exception as exception:
if self.capture_exceptions:
capture_exceptions = (
self.capture_exceptions
if self.capture_exceptions is not None
else contexts._default_capture_exceptions(self.client)
)
if capture_exceptions:
if self.client:
_capture_exception_with_metadata(
self.client, exception, _CAPTURE_METADATA
Expand Down
156 changes: 103 additions & 53 deletions posthog/integrations/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,26 @@ class PosthogContextMiddleware:
- Forwarded IP address as `$ip`
- User agent as `$user_agent`

The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance

The middleware behaviour is customisable through 3 additional functions:
- `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.
- `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
- `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.

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.

Context tags are automatically included as properties on all events captured within a context, including exceptions.
Configure the integration with these Django settings:
- `POSTHOG_MW_CLIENT`: client used for automatic exception capture, or the global client.
This does not redirect events explicitly captured through other clients.
- `POSTHOG_MW_CAPTURE_EXCEPTIONS`: `False` enables context enrichment only;
`True` enables automatic exception capture; `None` inherits the effective client's
`enable_exception_autocapture`. When omitted, the legacy default is `True`.
- `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_PROPERTIES_MAP`: an optional callback transforming request properties.

The old `POSTHOG_MW_EXTRA_TAGS` and `POSTHOG_MW_TAG_MAP` settings remain aliases;
the property-named settings take precedence when both are configured.

Request properties are included on all events captured within the active context,
including when automatic exception capture is disabled.
See the context documentation for more information. The extracted distinct ID and session ID,
if found, are used to associate all events captured in the middleware context with the same distinct ID
and session as currently active on the frontend. See the documentation for `set_context_session`
Expand Down Expand Up @@ -113,15 +121,16 @@ def __init__(self, get_response):

from django.conf import settings

if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable(
settings.POSTHOG_MW_EXTRA_TAGS
):
self.extra_tags = cast(
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
settings.POSTHOG_MW_EXTRA_TAGS,
)
else:
self.extra_tags = None
extra_properties = (
settings.POSTHOG_MW_EXTRA_PROPERTIES
if hasattr(settings, "POSTHOG_MW_EXTRA_PROPERTIES")
else getattr(settings, "POSTHOG_MW_EXTRA_TAGS", None)
)
self.extra_properties = (
cast("Callable[[HttpRequest], Dict[str, Any]]", extra_properties)
if callable(extra_properties)
else None
)

if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable(
settings.POSTHOG_MW_REQUEST_FILTER
Expand All @@ -133,22 +142,29 @@ def __init__(self, get_response):
else:
self.request_filter = None

if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable(
settings.POSTHOG_MW_TAG_MAP
):
self.tag_map = cast(
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
settings.POSTHOG_MW_TAG_MAP,
)
else:
self.tag_map = None
properties_map = (
settings.POSTHOG_MW_PROPERTIES_MAP
if hasattr(settings, "POSTHOG_MW_PROPERTIES_MAP")
else getattr(settings, "POSTHOG_MW_TAG_MAP", None)
)
self.properties_map = (
cast("Callable[[Dict[str, Any]], Dict[str, Any]]", properties_map)
if callable(properties_map)
else None
)

if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance(
settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool
):
self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS
else:
self.capture_exceptions = True
capture_exceptions = getattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS", True)
self.capture_exceptions: Optional[bool] = (
capture_exceptions
if capture_exceptions is None or isinstance(capture_exceptions, bool)
else True
)
trust_tracing_headers = getattr(
settings, "POSTHOG_MW_TRUST_TRACING_HEADERS", False
)
self.trust_tracing_headers = (
trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False
)

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

@property
def extra_tags(self):
"""Compatibility alias for ``extra_properties``."""
return self.extra_properties

@extra_tags.setter
def extra_tags(self, callback):
self.extra_properties = callback

@property
def tag_map(self):
"""Compatibility alias for ``properties_map``."""
return self.properties_map

@tag_map.setter
def tag_map(self, callback):
self.properties_map = callback

def extract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
"""Extract tags from request in sync context."""
"""Compatibility alias for ``extract_properties``."""
return self.extract_properties(request)

def extract_properties(self, request):
# type: (HttpRequest) -> Dict[str, Any]
"""Extract event properties and identity from a synchronous request."""
user_id, user_email = self.extract_request_user(request)
return self._build_tags(request, user_id, user_email)

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

# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = _get_sanitized_tracing_header(request, "X-POSTHOG-SESSION-ID")
# 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)

# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
distinct_id = (
_get_sanitized_tracing_header(request, "X-POSTHOG-DISTINCT-ID") or user_id
)
if distinct_id:
contexts.identify_context(distinct_id)

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

# Apply extra tags if configured
if self.extra_tags:
extra = self.extra_tags(request)
# Apply application-specific properties, then the optional transform.
if self.extra_properties:
extra = self.extra_properties(request)
if extra:
tags.update(extra)

# Apply tag mapping if configured
if self.tag_map:
tags = self.tag_map(tags)
if self.properties_map:
tags = self.properties_map(tags)

return tags

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

async def aextract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
"""Compatibility alias for ``aextract_properties``."""
return await self.aextract_properties(request)

async def aextract_properties(self, request):
# type: (HttpRequest) -> Dict[str, Any]
"""
Async version of extract_tags for use in async request handling.
Async version of extract_properties for use in async request handling.

Uses await request.auser() instead of request.user to avoid
SynchronousOnlyOperation in async context.
Expand Down Expand Up @@ -361,7 +406,12 @@ def process_exception(self, request, exception):
if self.request_filter and not self.request_filter(request):
return

if not self.capture_exceptions:
capture_exceptions = (
self.capture_exceptions
if self.capture_exceptions is not None
else contexts._default_capture_exceptions(self.client)
)
if not capture_exceptions:
return

# Context and tags already set by __call__ or __acall__
Expand All @@ -374,6 +424,6 @@ def process_exception(self, request, exception):
if self.client:
_capture_exception_with_metadata(self.client, exception, capture_metadata)
else:
from posthog import capture_exception
from .. import capture_exception

cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata)
53 changes: 38 additions & 15 deletions posthog/integrations/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

app = Flask(__name__)
PosthogFlaskIntegration(app)

The request context enriches events captured by any client while the request is
running. Passing ``client`` only selects where automatically captured exceptions
are sent; it does not redirect application calls to ``posthog.capture`` or
``another_client.capture``.
"""

from __future__ import annotations
Expand Down Expand Up @@ -100,16 +105,24 @@ class PosthogFlaskIntegration:
Args:
app: An optional Flask application. If omitted, call :meth:`init_app`
later (the Flask application-factory pattern).
client: Optional PostHog client used to capture exceptions. The global
client is used by default.
client: Optional destination for automatically captured exceptions. The
global client is used by default. Request context still enriches events
captured through any client and does not redirect capture calls.
capture_exceptions: Capture exceptions that reach Flask's unhandled
exception machinery. Defaults to ``True``.
exception machinery. If omitted, inherit the effective client's
``enable_exception_autocapture`` setting. Pass ``True`` or ``False``
to override it.
request_filter: Optional callback receiving Flask's request object. A
false return value disables both context and exception capture for
that request.
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.

The integration intentionally does not capture exceptions handled by an
application error handler, expected HTTP exceptions, request or response
Expand All @@ -123,14 +136,16 @@ def __init__(
app: Optional[Flask] = None,
*,
client: Optional[Client] = None,
capture_exceptions: bool = True,
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,
) -> 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

if app is not None:
self.init_app(app)
Expand Down Expand Up @@ -175,17 +190,18 @@ def _before_request(self) -> None:
privacy_settings.apply()
setattr(g, _REQUEST_STATE_KEY, _RequestState(scope=scope))

session_id = _sanitize_tracing_header_value(
request.headers.get("X-POSTHOG-SESSION-ID")
)
if session_id:
contexts.set_context_session(session_id)
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)
distinct_id = _sanitize_tracing_header_value(
request.headers.get("X-POSTHOG-DISTINCT-ID")
)
if distinct_id:
contexts.identify_context(distinct_id)

for key, value in self._request_properties(request).items():
contexts.tag(key, value)
Expand Down Expand Up @@ -223,7 +239,14 @@ def _request_properties(request: Request) -> dict[str, Any]:
def _handle_unhandled_exception(
self, sender: Flask, exception: BaseException, **kwargs: Any
) -> None:
if not self.capture_exceptions or not self._request_is_tracked():
if not self._request_is_tracked():
return
capture_exceptions = (
self.capture_exceptions
if self.capture_exceptions is not None
else contexts._default_capture_exceptions(self.client)
)
if not capture_exceptions:
return

capture_metadata: _ExceptionCaptureMetadata = {
Expand Down
Loading
Loading