From 24b171099a462732b57366397fd8318410589471 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 17:02:21 +0200 Subject: [PATCH 1/2] Align ASGI and Flask integration defaults --- posthog/integrations/asgi.py | 21 ++- posthog/integrations/flask.py | 50 +++++-- .../integrations/test_asgi_integration.py | 103 ++++++++++++-- .../integrations/test_flask_integration.py | 130 +++++++++++++++++- 4 files changed, 272 insertions(+), 32 deletions(-) diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index b00c18c8..1c7bb90b 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -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 @@ -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 @@ -186,14 +195,18 @@ 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, ) -> None: self.app = app self.client = client - self.capture_exceptions = capture_exceptions + self.capture_exceptions = ( + contexts._default_capture_exceptions(client) + if capture_exceptions is None + else capture_exceptions + ) self.request_filter = request_filter self.extra_properties = extra_properties self.trust_tracing_headers = trust_tracing_headers diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index 9a4bf430..2aa5c39b 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -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 @@ -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 @@ -123,14 +136,20 @@ 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.capture_exceptions = ( + contexts._default_capture_exceptions(client) + if capture_exceptions is None + else 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) @@ -175,17 +194,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) diff --git a/posthog/test/integrations/test_asgi_integration.py b/posthog/test/integrations/test_asgi_integration.py index 5ddaecfa..d209540e 100644 --- a/posthog/test/integrations/test_asgi_integration.py +++ b/posthog/test/integrations/test_asgi_integration.py @@ -2,6 +2,7 @@ import pytest +import posthog from posthog import contexts from posthog.client import Client from posthog.integrations.asgi import PosthogASGIMiddleware @@ -152,7 +153,7 @@ async def app(scope, receive, send): assert contexts.get_tags()["$request_path"] == "/api/items" raise error - middleware = PosthogASGIMiddleware(app, client=client) + middleware = PosthogASGIMiddleware(app, client=client, capture_exceptions=True) with pytest.raises(RuntimeError, match="application failed") as raised: await middleware(http_scope(), noop_receive, noop_send) @@ -179,9 +180,9 @@ async def app(scope, receive, send): 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 - ) + await PosthogASGIMiddleware( + app, client=client, capture_exceptions=True + )(http_scope(), noop_receive, noop_send) properties = capture.call_args.kwargs["properties"] outermost = properties["$exception_list"][0] @@ -206,7 +207,9 @@ async def app(scope, receive, send): 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) + await PosthogASGIMiddleware(app, capture_exceptions=True)( + http_scope(), noop_receive, noop_send + ) capture_exception.assert_called_once_with( error, @@ -233,14 +236,93 @@ async def app(scope, receive, send): client.capture_exception.assert_not_called() +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [False, True]) +async def test_exception_capture_defaults_to_custom_client_setting(enabled): + client = Mock(enable_exception_autocapture=enabled) + + async def app(scope, receive, send): + raise RuntimeError("custom default") + + with pytest.raises(RuntimeError, match="custom default"): + await PosthogASGIMiddleware(app, client=client)( + http_scope(), noop_receive, noop_send + ) + + if enabled: + client.capture_exception.assert_called_once() + else: + client.capture_exception.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("enabled", [False, True]) +async def test_exception_capture_defaults_to_global_client_setting(enabled): + default_client = Mock(enable_exception_autocapture=enabled) + + async def app(scope, receive, send): + raise RuntimeError("global default") + + with ( + patch.object(posthog, "default_client", default_client), + patch.object(posthog, "enable_exception_autocapture", not enabled), + patch("posthog.capture_exception") as capture_exception, + pytest.raises(RuntimeError, match="global default"), + ): + await PosthogASGIMiddleware(app)(http_scope(), noop_receive, noop_send) + + if enabled: + capture_exception.assert_called_once() + else: + capture_exception.assert_not_called() + + +@pytest.mark.asyncio +async def test_context_enriches_events_without_redirecting_capture_calls(): + events = [] + + def record_event(event): + events.append(event) + return event + + event_client = Client( + "event-api-key", send=False, sync_mode=True, before_send=record_event + ) + exception_client = Mock(enable_exception_autocapture=False) + + async def app(scope, receive, send): + event_client.capture( + "request event", distinct_id="event-user", properties={"source": "app"} + ) + + try: + await PosthogASGIMiddleware( + app, + client=exception_client, + extra_properties=lambda scope: {"tenant": "acme"}, + )(http_scope(), noop_receive, noop_send) + finally: + event_client.shutdown() + + assert len(events) == 1 + assert events[0]["distinct_id"] == "event-user" + assert events[0]["properties"]["source"] == "app" + assert events[0]["properties"]["tenant"] == "acme" + assert events[0]["properties"]["$request_path"] == "/api/items" + assert {"tenant", "$request_path"} <= set(events[0]["properties"]["$context_tags"]) + exception_client.capture.assert_not_called() + + @pytest.mark.asyncio @pytest.mark.parametrize("async_filter", [False, True]) async def test_request_filter_bypasses_all_instrumentation(async_filter): observed = {} + client = Mock() async def app(scope, receive, send): observed["session_id"] = contexts.get_context_session_id() observed["properties"] = contexts.get_tags() + raise RuntimeError("filtered") if async_filter: @@ -254,11 +336,16 @@ def request_filter(scope): with contexts.new_context(fresh=True): contexts.tag("existing", True) - await PosthogASGIMiddleware(app, request_filter=request_filter)( - http_scope(), noop_receive, noop_send - ) + with pytest.raises(RuntimeError, match="filtered"): + await PosthogASGIMiddleware( + app, + client=client, + capture_exceptions=True, + request_filter=request_filter, + )(http_scope(), noop_receive, noop_send) assert observed == {"session_id": None, "properties": {"existing": True}} + client.capture_exception.assert_not_called() @pytest.mark.asyncio diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py index 4df637ff..f7a2aaf8 100644 --- a/posthog/test/integrations/test_flask_integration.py +++ b/posthog/test/integrations/test_flask_integration.py @@ -5,7 +5,9 @@ import pytest from flask import Flask, abort, jsonify +import posthog from posthog import contexts +from posthog.client import Client from posthog.integrations.flask import ( PosthogFlaskIntegration, _sanitize_tracing_header_value, @@ -20,7 +22,11 @@ def _app() -> Flask: def test_adds_request_context_and_restores_parent_context() -> None: app = _app() - PosthogFlaskIntegration(app, extra_properties=lambda request: {"tenant": "acme"}) + PosthogFlaskIntegration( + app, + extra_properties=lambda request: {"tenant": "acme"}, + trust_tracing_headers=True, + ) @app.get("/users/") def view(user_id: str): @@ -85,7 +91,7 @@ def capture(exception, **kwargs): return "event-id" client.capture_exception.side_effect = capture - PosthogFlaskIntegration(app, client=client) + PosthogFlaskIntegration(app, client=client, capture_exceptions=True) @app.get("/privacy") def privacy_failure(): @@ -138,7 +144,7 @@ def capture(exception, **kwargs): return "event-id" client.capture_exception.side_effect = capture - PosthogFlaskIntegration(app, client=client) + PosthogFlaskIntegration(app, client=client, capture_exceptions=True) error = ValueError("view failed") @app.get("/failure") @@ -164,7 +170,7 @@ def failure(): def test_uses_global_client_when_custom_client_is_not_provided() -> None: app = _app() - PosthogFlaskIntegration(app) + PosthogFlaskIntegration(app, capture_exceptions=True) error = RuntimeError("boom") @app.get("/failure") @@ -188,7 +194,7 @@ def failure(): def test_does_not_capture_handled_exception_or_expected_http_error() -> None: app = _app() client = Mock() - PosthogFlaskIntegration(app, client=client) + PosthogFlaskIntegration(app, client=client, capture_exceptions=True) @app.errorhandler(ValueError) def handle_value_error(error): @@ -222,12 +228,126 @@ def failure(): client.capture_exception.assert_not_called() +@pytest.mark.parametrize("enabled", [False, True]) +def test_exception_capture_defaults_to_custom_client_setting(enabled: bool) -> None: + app = _app() + client = Mock(enable_exception_autocapture=enabled) + PosthogFlaskIntegration(app, client=client) + + @app.get("/failure") + def failure(): + raise RuntimeError("custom default") + + with pytest.raises(RuntimeError, match="custom default"): + app.test_client().get("/failure") + + if enabled: + client.capture_exception.assert_called_once() + else: + client.capture_exception.assert_not_called() + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_exception_capture_defaults_to_global_client_setting(enabled: bool) -> None: + app = _app() + default_client = Mock(enable_exception_autocapture=enabled) + + with ( + patch.object(posthog, "default_client", default_client), + patch.object(posthog, "enable_exception_autocapture", not enabled), + ): + PosthogFlaskIntegration(app) + + @app.get("/failure") + def failure(): + raise RuntimeError("global default") + + with ( + patch("posthog.capture_exception") as capture_exception, + pytest.raises(RuntimeError, match="global default"), + ): + app.test_client().get("/failure") + + if enabled: + capture_exception.assert_called_once() + else: + capture_exception.assert_not_called() + + +def test_context_enriches_events_without_redirecting_capture_calls() -> None: + app = _app() + events = [] + + def record_event(event): + events.append(event) + return event + + event_client = Client( + "event-api-key", send=False, sync_mode=True, before_send=record_event + ) + exception_client = Mock(enable_exception_autocapture=False) + PosthogFlaskIntegration( + app, + client=exception_client, + extra_properties=lambda request: {"tenant": "acme"}, + ) + + @app.get("/capture") + def capture_event(): + event_client.capture( + "request event", distinct_id="event-user", properties={"source": "app"} + ) + return "ok" + + try: + assert app.test_client().get("/capture").status_code == 200 + finally: + event_client.shutdown() + + assert len(events) == 1 + assert events[0]["distinct_id"] == "event-user" + assert events[0]["properties"]["source"] == "app" + assert events[0]["properties"]["tenant"] == "acme" + assert events[0]["properties"]["$request_path"] == "/capture" + assert {"tenant", "$request_path"} <= set(events[0]["properties"]["$context_tags"]) + exception_client.capture.assert_not_called() + + +def test_tracing_headers_are_ignored_by_default_and_can_be_trusted() -> None: + def request_identity(trust_tracing_headers: bool) -> dict: + app = _app() + PosthogFlaskIntegration(app, trust_tracing_headers=trust_tracing_headers) + + @app.get("/identity") + def identity(): + return jsonify( + distinct_id=contexts.get_context_distinct_id(), + session_id=contexts.get_context_session_id(), + ) + + response = app.test_client().get( + "/identity", + headers={ + "X-PostHog-Distinct-Id": "person-1", + "X-PostHog-Session-Id": "session-1", + }, + ) + return response.get_json() + + assert request_identity(False) == {"distinct_id": None, "session_id": None} + assert request_identity(True) == { + "distinct_id": "person-1", + "session_id": "session-1", + } + + def test_request_filter_skips_context_and_exception_capture() -> None: app = _app() client = Mock() PosthogFlaskIntegration( app, client=client, + capture_exceptions=True, request_filter=lambda request: request.path != "/ignored", ) From 7953c0876140c6310b80ec0bf7e193e942163024 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 17:07:19 +0200 Subject: [PATCH 2/2] feat(integrations): align request middleware configuration --- .../consistent-request-integration-api.md | 5 + README.md | 1 + posthog/integrations/asgi.py | 13 +- posthog/integrations/django.py | 156 ++++++++----- posthog/integrations/flask.py | 15 +- .../integrations/test_asgi_integration.py | 17 +- .../integrations/test_flask_integration.py | 20 +- posthog/test/integrations/test_middleware.py | 9 + .../test/integrations/test_middleware_api.py | 216 ++++++++++++++++++ references/middleware_configuration.md | 69 ++++++ references/public_api_snapshot.txt | 16 +- 11 files changed, 456 insertions(+), 81 deletions(-) create mode 100644 .sampo/changesets/consistent-request-integration-api.md create mode 100644 posthog/test/integrations/test_middleware_api.py create mode 100644 references/middleware_configuration.md diff --git a/.sampo/changesets/consistent-request-integration-api.md b/.sampo/changesets/consistent-request-integration-api.md new file mode 100644 index 00000000..71ed755b --- /dev/null +++ b/.sampo/changesets/consistent-request-integration-api.md @@ -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. diff --git a/README.md b/README.md index 364101f0..cd92d38b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index 1c7bb90b..670be43e 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -202,11 +202,7 @@ def __init__( ) -> None: self.app = app self.client = client - self.capture_exceptions = ( - contexts._default_capture_exceptions(client) - if capture_exceptions is None - else capture_exceptions - ) + self.capture_exceptions = capture_exceptions self.request_filter = request_filter self.extra_properties = extra_properties self.trust_tracing_headers = trust_tracing_headers @@ -248,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 diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index 0347c6b8..7b973c0b 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -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` @@ -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 @@ -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 @@ -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) @@ -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) @@ -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 @@ -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. @@ -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__ @@ -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) diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index 2aa5c39b..b23b6209 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -142,11 +142,7 @@ def __init__( trust_tracing_headers: bool = False, ) -> None: self.client = client - self.capture_exceptions = ( - contexts._default_capture_exceptions(client) - if capture_exceptions is None - else capture_exceptions - ) + self.capture_exceptions = capture_exceptions self.request_filter = request_filter self.extra_properties = extra_properties self.trust_tracing_headers = trust_tracing_headers @@ -243,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 = { diff --git a/posthog/test/integrations/test_asgi_integration.py b/posthog/test/integrations/test_asgi_integration.py index d209540e..63ddf938 100644 --- a/posthog/test/integrations/test_asgi_integration.py +++ b/posthog/test/integrations/test_asgi_integration.py @@ -257,19 +257,28 @@ async def app(scope, receive, send): @pytest.mark.asyncio @pytest.mark.parametrize("enabled", [False, True]) -async def test_exception_capture_defaults_to_global_client_setting(enabled): - default_client = Mock(enable_exception_autocapture=enabled) +@pytest.mark.parametrize("initialized", [False, True]) +async def test_exception_capture_defaults_to_global_client_setting( + enabled, initialized +): + default_client = Mock(enable_exception_autocapture=enabled) if initialized else None async def app(scope, receive, send): raise RuntimeError("global default") + # Middleware may be registered before the global client is configured. + middleware = PosthogASGIMiddleware(app) with ( patch.object(posthog, "default_client", default_client), - patch.object(posthog, "enable_exception_autocapture", not enabled), + patch.object( + posthog, + "enable_exception_autocapture", + not enabled if initialized else enabled, + ), patch("posthog.capture_exception") as capture_exception, pytest.raises(RuntimeError, match="global default"), ): - await PosthogASGIMiddleware(app)(http_scope(), noop_receive, noop_send) + await middleware(http_scope(), noop_receive, noop_send) if enabled: capture_exception.assert_called_once() diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py index f7a2aaf8..9ce95dd3 100644 --- a/posthog/test/integrations/test_flask_integration.py +++ b/posthog/test/integrations/test_flask_integration.py @@ -248,21 +248,27 @@ def failure(): @pytest.mark.parametrize("enabled", [False, True]) -def test_exception_capture_defaults_to_global_client_setting(enabled: bool) -> None: +@pytest.mark.parametrize("initialized", [False, True]) +def test_exception_capture_defaults_to_global_client_setting( + enabled: bool, initialized: bool +) -> None: app = _app() - default_client = Mock(enable_exception_autocapture=enabled) + default_client = Mock(enable_exception_autocapture=enabled) if initialized else None - with ( - patch.object(posthog, "default_client", default_client), - patch.object(posthog, "enable_exception_autocapture", not enabled), - ): - PosthogFlaskIntegration(app) + # App-factory setup may run before the global client is configured. + PosthogFlaskIntegration(app) @app.get("/failure") def failure(): raise RuntimeError("global default") with ( + patch.object(posthog, "default_client", default_client), + patch.object( + posthog, + "enable_exception_autocapture", + not enabled if initialized else enabled, + ), patch("posthog.capture_exception") as capture_exception, pytest.raises(RuntimeError, match="global default"), ): diff --git a/posthog/test/integrations/test_middleware.py b/posthog/test/integrations/test_middleware.py index c76a8779..d58065bf 100644 --- a/posthog/test/integrations/test_middleware.py +++ b/posthog/test/integrations/test_middleware.py @@ -12,6 +12,7 @@ # Configure Django settings before importing middleware import django from django.conf import settings +from django.test import override_settings if not settings.configured: settings.configure( @@ -55,6 +56,7 @@ def create_middleware( tag_map=None, capture_exceptions=True, get_response=None, + trust_tracing_headers=True, ): """Helper to create middleware instance with mock Django settings""" if get_response is None: @@ -67,6 +69,7 @@ def create_middleware( mock_settings.POSTHOG_MW_TAG_MAP = tag_map mock_settings.POSTHOG_MW_CAPTURE_EXCEPTIONS = capture_exceptions mock_settings.POSTHOG_MW_CLIENT = None + mock_settings.POSTHOG_MW_TRUST_TRACING_HEADERS = trust_tracing_headers # Make hasattr work correctly def mock_hasattr(obj, name): @@ -469,6 +472,12 @@ def get_response_simulating_django(request): class TestPosthogContextMiddlewareAsync(unittest.TestCase): """Test asynchronous middleware behavior""" + def setUp(self): + # Header propagation tests explicitly opt in to analytics attribution. + configuration = override_settings(POSTHOG_MW_TRUST_TRACING_HEADERS=True) + configuration.enable() + self.addCleanup(configuration.disable) + def test_async_middleware_detection(self): """Test that async get_response is correctly detected""" diff --git a/posthog/test/integrations/test_middleware_api.py b/posthog/test/integrations/test_middleware_api.py new file mode 100644 index 00000000..7d3ccc75 --- /dev/null +++ b/posthog/test/integrations/test_middleware_api.py @@ -0,0 +1,216 @@ +"""Public configuration contract for Django request enrichment.""" + +import asyncio +from unittest.mock import Mock, patch + +import pytest +from django.conf import settings +from django.test import override_settings + +from posthog import contexts +from posthog.client import Client +from posthog.integrations.django import PosthogContextMiddleware +from posthog.test.integrations.test_middleware import MockRequest + + +@pytest.fixture +def client(): + instance = Client("test-key", send=False, enable_exception_autocapture=False) + yield instance + instance.shutdown() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("enabled", [False, True]) +@pytest.mark.parametrize("configured", [None, False, True]) +def test_capture_configuration_applies_to_context_and_exception_hook( + client, asynchronous, enabled, configured +): + client.enable_exception_autocapture = enabled + error = ValueError("view failed") + observed = [] + + def view(request): + scope = contexts._get_current_context() + observed.append(scope.capture_exceptions) + middleware.process_exception(request, error) + return "response" + + async def async_view(request): + return view(request) + + with ( + override_settings( + POSTHOG_MW_CLIENT=client, + POSTHOG_MW_CAPTURE_EXCEPTIONS=configured, + ), + patch.object(client, "capture_exception") as capture, + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + result = middleware(MockRequest()) + assert (asyncio.run(result) if asynchronous else result) == "response" + + expected = enabled if configured is None else configured + assert observed == [expected] + assert capture.call_count == int(expected) + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_capture_none_inherits_global_configuration(enabled): + with ( + override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None), + patch("posthog.default_client", None), + patch("posthog.enable_exception_autocapture", enabled), + patch("posthog.capture_exception") as capture, + ): + middleware = PosthogContextMiddleware(Mock()) + middleware.process_exception(MockRequest(), ValueError("view failed")) + assert capture.call_count == int(enabled) + + +def test_capture_none_inherits_initialized_global_client(client): + with ( + override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=None), + patch("posthog.default_client", client), + patch("posthog.enable_exception_autocapture", True), + patch("posthog.capture_exception") as capture, + ): + # An initialized global client's setting takes precedence over the module flag. + middleware = PosthogContextMiddleware(Mock()) + middleware.process_exception(MockRequest(), ValueError("view failed")) + capture.assert_not_called() + + +def test_omitted_capture_setting_preserves_legacy_default(): + with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=True): + del settings.POSTHOG_MW_CAPTURE_EXCEPTIONS + middleware = PosthogContextMiddleware(Mock()) + assert middleware.capture_exceptions is True + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("trust", [False, True]) +def test_tracing_headers_require_opt_in_and_user_fallback(asynchronous, trust): + observed = {} + + def view(request): + observed["distinct_id"] = contexts.get_context_distinct_id() + observed["session_id"] = contexts.get_context_session_id() + return "response" + + async def async_view(request): + return view(request) + + request = MockRequest( + headers={ + "X-POSTHOG-DISTINCT-ID": "browser-user", + "X-POSTHOG-SESSION-ID": "browser-session", + } + ) + request.user = Mock(is_authenticated=True, pk=42, email="user@example.com") + + async def auser(): + return request.user + + request.auser = auser + with override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, + POSTHOG_MW_TRUST_TRACING_HEADERS=trust, + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + with contexts.new_context(fresh=True, capture_exceptions=False): + result = middleware(request) + assert (asyncio.run(result) if asynchronous else result) == "response" + + assert observed == { + "distinct_id": "browser-user" if trust else "42", + "session_id": "browser-session" if trust else None, + } + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_context_only_enriches_events_without_redirecting_the_client( + client, asynchronous +): + integration_client = Mock(spec=Client) + captured = [] + + def view(request): + client.capture("custom-event", properties={"tenant": "explicit"}) + middleware.process_exception(request, ValueError("view failed")) + return "response" + + async def async_view(request): + return view(request) + + with ( + override_settings( + POSTHOG_MW_CAPTURE_EXCEPTIONS=False, + POSTHOG_MW_EXTRA_PROPERTIES=lambda request: { + "tenant": "context", + "role": "admin", + }, + ), + patch.object( + client, + "_enqueue", + side_effect=lambda message, *args, **kwargs: captured.append(message), + ), + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + middleware.client = integration_client + result = middleware(MockRequest(path="/checkout")) + assert (asyncio.run(result) if asynchronous else result) == "response" + + assert captured[0]["event"] == "custom-event" + assert captured[0]["properties"]["$request_path"] == "/checkout" + assert captured[0]["properties"]["tenant"] == "explicit" + assert captured[0]["properties"]["role"] == "admin" + integration_client.capture.assert_not_called() + integration_client.capture_exception.assert_not_called() + assert contexts.get_tags() == {} + + +def test_property_settings_take_precedence_over_legacy_aliases(): + with override_settings( + POSTHOG_MW_EXTRA_PROPERTIES=lambda request: {"new": True}, + POSTHOG_MW_EXTRA_TAGS=lambda request: {"old": True}, + POSTHOG_MW_PROPERTIES_MAP=lambda properties: {**properties, "mapped": "new"}, + POSTHOG_MW_TAG_MAP=lambda properties: {**properties, "mapped": "old"}, + ): + middleware = PosthogContextMiddleware(Mock()) + properties = middleware.extract_properties(MockRequest()) + assert properties["new"] is True + assert "old" not in properties + assert properties["mapped"] == "new" + assert middleware.extra_tags is middleware.extra_properties + assert middleware.tag_map is middleware.properties_map + + +@pytest.mark.parametrize("asynchronous", [False, True]) +def test_request_filter_disables_context_and_capture(client, asynchronous): + properties = Mock() + error = ValueError("view failed") + + def view(request): + assert contexts.get_tags() == {} + middleware.process_exception(request, error) + return "response" + + async def async_view(request): + return view(request) + + with ( + override_settings( + POSTHOG_MW_CLIENT=client, + POSTHOG_MW_CAPTURE_EXCEPTIONS=True, + POSTHOG_MW_REQUEST_FILTER=lambda request: False, + POSTHOG_MW_EXTRA_PROPERTIES=properties, + ), + patch.object(client, "capture_exception") as capture, + ): + middleware = PosthogContextMiddleware(async_view if asynchronous else view) + result = middleware(MockRequest()) + assert (asyncio.run(result) if asynchronous else result) == "response" + capture.assert_not_called() + properties.assert_not_called() diff --git a/references/middleware_configuration.md b/references/middleware_configuration.md new file mode 100644 index 00000000..1612117d --- /dev/null +++ b/references/middleware_configuration.md @@ -0,0 +1,69 @@ +# Request integration configuration + +ASGI, Flask, and Django enrich events captured during an active request with request +properties and available identity/session context. Enrichment does not create a +request event or redirect manually captured events to a different client. + +## Shared options + +| ASGI / Flask option | Django setting | Meaning | +| --- | --- | --- | +| `client` | `POSTHOG_MW_CLIENT` | Client for automatic exceptions; defaults to the global client. | +| `capture_exceptions` | `POSTHOG_MW_CAPTURE_EXCEPTIONS` | `True`: capture automatically; `False`: context only; `None`: inherit the effective client's `enable_exception_autocapture`. | +| `request_filter` | `POSTHOG_MW_REQUEST_FILTER` | Callback returning `False` to skip both enrichment and automatic capture for a request. | +| `extra_properties` | `POSTHOG_MW_EXTRA_PROPERTIES` | Callback returning additional event properties. Explicit properties passed to event capture take precedence. | +| `trust_tracing_headers` | `POSTHOG_MW_TRUST_TRACING_HEADERS` | Accept `X-PostHog-Distinct-ID` and `X-PostHog-Session-ID` only when `True`; defaults to `False`. | + +ASGI callbacks receive the ASGI scope and can be synchronous or asynchronous. +Flask callbacks receive the Flask request. Django callbacks receive the Django +`HttpRequest`. + +ASGI and Flask default `capture_exceptions` to `None`, so installing an integration +alone does not enable error tracking on a client that has it disabled. Client +inheritance is resolved when an exception occurs, so client configuration performed +after middleware registration is respected. The flag +controls integration-level automatic capture, not manual `capture_exception()` +calls or other independently installed error-capture hooks. + +## Django compatibility + +Django retains its historical automatic exception capture when +`POSTHOG_MW_CAPTURE_EXCEPTIONS` is omitted. Set it to `None` to inherit the client's +configuration, or `False` for context-only behavior. + +`POSTHOG_MW_EXTRA_TAGS` remains an alias for `POSTHOG_MW_EXTRA_PROPERTIES`, and +`POSTHOG_MW_TAG_MAP` remains an alias for `POSTHOG_MW_PROPERTIES_MAP`. The latter is +a Django-specific callback that transforms the extracted properties. When both +old and new settings are present, the new setting takes precedence (including +when set to `None` to disable the callback). Existing tag-named middleware methods +and attributes remain compatibility aliases. + +Tracing headers now require opt-in in Django and Flask, matching ASGI's secure +default. Applications intentionally relying on browser-to-backend analytics +attribution must enable the trust option. Sanitization limits malformed input; +it does not establish authenticity. Never use these headers for authentication or +authorization. Django can still use its authenticated request user as identity +when tracing headers are ignored. + +## Django REST Framework + +DRF handles many exceptions before Django middleware can see them, so its +PostHog exception handler is configured separately. It does not establish request +context: use the Django middleware for enrichment, request properties, and header +policy. + +An explicit `create_exception_handler(capture_exceptions=True/False)` overrides the +Django setting. Otherwise the handler follows `POSTHOG_MW_CAPTURE_EXCEPTIONS`, +including `None` for client inheritance and the omitted legacy `True` default. +Its client precedence is explicit handler client, `POSTHOG_DRF_CLIENT` (legacy +handler-specific setting), `POSTHOG_MW_CLIENT`, then the global client. +The middleware request filter also applies to handled DRF errors. + +## Lifetime and isolation + +Context is read when an event is captured, before it is queued for transport. +It does not depend on a later flush occurring inside the request. Outside the +request scope it is restored. Background threads do not automatically inherit +request context; async tasks follow Python's context-variable propagation rules. +Flask isolates identity and request properties while retaining effective enclosing +exception code-variable privacy controls. diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 32f085b6..6e670c34 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -875,17 +875,21 @@ attribute posthog.integrations.celery.PosthogCeleryIntegration.client = client attribute posthog.integrations.celery.PosthogCeleryIntegration.propagate_context = propagate_context attribute posthog.integrations.celery.PosthogCeleryIntegration.task_filter = task_filter attribute posthog.integrations.django.PosthogContextMiddleware.async_capable = True -attribute posthog.integrations.django.PosthogContextMiddleware.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS +attribute posthog.integrations.django.PosthogContextMiddleware.capture_exceptions: Optional[bool] = capture_exceptions if (capture_exceptions is None or isinstance(capture_exceptions, bool)) else True attribute posthog.integrations.django.PosthogContextMiddleware.client = cast('Optional[Client]', settings.POSTHOG_MW_CLIENT) -attribute posthog.integrations.django.PosthogContextMiddleware.extra_tags = cast('Optional[Callable[[HttpRequest], Dict[str, Any]]]', settings.POSTHOG_MW_EXTRA_TAGS) +attribute posthog.integrations.django.PosthogContextMiddleware.extra_properties = cast('Callable[[HttpRequest], Dict[str, Any]]', extra_properties) if callable(extra_properties) else None +attribute posthog.integrations.django.PosthogContextMiddleware.extra_tags attribute posthog.integrations.django.PosthogContextMiddleware.get_response = get_response +attribute posthog.integrations.django.PosthogContextMiddleware.properties_map = cast('Callable[[Dict[str, Any]], Dict[str, Any]]', properties_map) if callable(properties_map) else None attribute posthog.integrations.django.PosthogContextMiddleware.request_filter = cast('Optional[Callable[[HttpRequest], bool]]', settings.POSTHOG_MW_REQUEST_FILTER) attribute posthog.integrations.django.PosthogContextMiddleware.sync_capable = True -attribute posthog.integrations.django.PosthogContextMiddleware.tag_map = cast('Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]', settings.POSTHOG_MW_TAG_MAP) +attribute posthog.integrations.django.PosthogContextMiddleware.tag_map +attribute posthog.integrations.django.PosthogContextMiddleware.trust_tracing_headers = trust_tracing_headers if isinstance(trust_tracing_headers, bool) else False attribute posthog.integrations.flask.PosthogFlaskIntegration.capture_exceptions = capture_exceptions attribute posthog.integrations.flask.PosthogFlaskIntegration.client = client attribute posthog.integrations.flask.PosthogFlaskIntegration.extra_properties = extra_properties attribute posthog.integrations.flask.PosthogFlaskIntegration.request_filter = request_filter +attribute posthog.integrations.flask.PosthogFlaskIntegration.trust_tracing_headers = trust_tracing_headers attribute posthog.is_server = True attribute posthog.log_captured_exceptions = False attribute posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware.app = app @@ -1210,10 +1214,10 @@ 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_properties: Optional[_ExtraProperties] = None, trust_tracing_headers: bool = False) +class posthog.integrations.asgi.PosthogASGIMiddleware(app: _ASGIApp, client: Optional[Client] = None, capture_exceptions: Optional[bool] = None, 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.integrations.flask.PosthogFlaskIntegration(app: Optional[Flask] = None, *, client: Optional[Client] = None, capture_exceptions: bool = True, request_filter: Optional[Callable[[Request], bool]] = None, extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None) +class posthog.integrations.flask.PosthogFlaskIntegration(app: Optional[Flask] = None, *, client: Optional[Client] = None, 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) class posthog.mcp.McpAnalytics(key: Any) class posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware(app: Any) class posthog.mcp.constants.PostHogMCPAnalyticsEvent @@ -1677,8 +1681,10 @@ method posthog.flag_definition_cache.FlagDefinitionCacheProvider.shutdown() -> O method posthog.integrations.celery.PosthogCeleryIntegration.instrument() -> None method posthog.integrations.celery.PosthogCeleryIntegration.shutdown() -> None method posthog.integrations.celery.PosthogCeleryIntegration.uninstrument() -> None +method posthog.integrations.django.PosthogContextMiddleware.aextract_properties(request) method posthog.integrations.django.PosthogContextMiddleware.aextract_request_user(request) method posthog.integrations.django.PosthogContextMiddleware.aextract_tags(request) +method posthog.integrations.django.PosthogContextMiddleware.extract_properties(request) method posthog.integrations.django.PosthogContextMiddleware.extract_request_user(request) method posthog.integrations.django.PosthogContextMiddleware.extract_tags(request) method posthog.integrations.django.PosthogContextMiddleware.process_exception(request, exception)