From c1c709f37a8a2002523004e502e58b769900bdc3 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 11:42:22 +0200 Subject: [PATCH 1/5] feat(flask): add error tracking integration --- .sampo/changesets/flask-error-tracking.md | 5 + posthog/integrations/flask.py | 217 ++++++++++++++++++ .../integrations/test_flask_integration.py | 198 ++++++++++++++++ pyproject.toml | 1 + references/public_api_snapshot.txt | 7 + uv.lock | 71 +++++- 6 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 .sampo/changesets/flask-error-tracking.md create mode 100644 posthog/integrations/flask.py create mode 100644 posthog/test/integrations/test_flask_integration.py diff --git a/.sampo/changesets/flask-error-tracking.md b/.sampo/changesets/flask-error-tracking.md new file mode 100644 index 00000000..4e4bcf30 --- /dev/null +++ b/.sampo/changesets/flask-error-tracking.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add a Flask integration that creates isolated request contexts, attaches safe request metadata and tracing identity, and automatically captures unhandled application exceptions without changing Flask's error behavior. diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py new file mode 100644 index 00000000..b1ef05ac --- /dev/null +++ b/posthog/integrations/flask.py @@ -0,0 +1,217 @@ +"""Flask request context and exception tracking integration. + +Flask is imported lazily when :meth:`PosthogFlaskIntegration.init_app` is called, +so importing the PostHog SDK does not require Flask to be installed. + +Example:: + + from flask import Flask + from posthog.integrations.flask import PosthogFlaskIntegration + + app = Flask(__name__) + PosthogFlaskIntegration(app) +""" + +from __future__ import annotations + +import re +from contextlib import AbstractContextManager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, cast + +from .. import contexts +from ..client import Client +from ..exception_utils import _capture_exception_with_metadata + +if TYPE_CHECKING: + from flask import Flask, Request + + +__all__ = ["PosthogFlaskIntegration"] + +_MAX_TRACING_HEADER_LENGTH = 1000 +_TRACING_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]") +_EXTENSION_KEY = "posthog" +_REQUEST_STATE_KEY = "_posthog_integration_state" + + +def _sanitize_tracing_header_value(value: object) -> Optional[str]: + """Return a bounded tracing header value safe for event properties.""" + if not isinstance(value, str) or not value: + return None + + return ( + _TRACING_HEADER_CONTROL_CHARS_RE.sub("", value).strip()[ + :_MAX_TRACING_HEADER_LENGTH + ] + or None + ) + + +@dataclass +class _RequestState: + scope: AbstractContextManager[None] + tracked: bool = True + + +class PosthogFlaskIntegration: + """Add PostHog request context and exception tracking to a Flask app. + + 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. + capture_exceptions: Capture exceptions that reach Flask's unhandled + exception machinery. Defaults to ``True``. + request_filter: Optional callback receiving Flask's request object. A + false return value disables both context and exception capture for + that request. + extra_tags: Optional callback returning additional context tags. This + is useful for application-specific metadata such as an authenticated + user's role. Values should not contain secrets or request bodies. + + The integration intentionally does not capture exceptions handled by an + application error handler, expected HTTP exceptions, request or response + bodies, query strings, cookies, authorization headers, or arbitrary headers. + Call ``capture_exception`` explicitly from a custom error handler if a + handled exception should be reported. + """ + + def __init__( + self, + app: Optional[Flask] = None, + *, + client: Optional[Client] = None, + capture_exceptions: bool = True, + request_filter: Optional[Callable[[Request], bool]] = None, + extra_tags: Optional[Callable[[Request], Mapping[str, Any]]] = None, + ) -> None: + self.client = client + self.capture_exceptions = capture_exceptions + self.request_filter = request_filter + self.extra_tags = extra_tags + + if app is not None: + self.init_app(app) + + def init_app(self, app: Flask) -> None: + """Register the integration with a Flask application once.""" + try: + from flask import got_request_exception + except ImportError as error: # pragma: no cover - exercised without Flask + raise RuntimeError( + "PosthogFlaskIntegration requires Flask to be installed" + ) from error + + if _EXTENSION_KEY in app.extensions: + raise RuntimeError("PostHog is already initialized for this Flask app") + + app.extensions[_EXTENSION_KEY] = self + app.before_request(self._before_request) + app.teardown_request(self._teardown_request) + got_request_exception.connect(self._handle_unhandled_exception, app, weak=False) + + def _before_request(self) -> None: + from flask import g, request + + if self.request_filter is not None and not self.request_filter(request): + setattr(g, _REQUEST_STATE_KEY, None) + return + + # Flask handles application exceptions before returning control through + # the request stack, so capture through got_request_exception rather than + # through new_context. This also avoids duplicate capture. + scope = contexts.new_context( + fresh=True, + capture_exceptions=False, + client=self.client, + ) + scope.__enter__() + 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) + + 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_tags(request).items(): + contexts.tag(key, value) + + if self.extra_tags is not None: + extra_tags = self.extra_tags(request) + if extra_tags: + for key, value in extra_tags.items(): + contexts.tag(key, value) + + @staticmethod + def _request_tags(request: Request) -> dict[str, Any]: + tags: dict[str, Any] = { + # base_url deliberately excludes query strings, which commonly + # contain credentials, tokens, and other sensitive values. + "$current_url": request.base_url, + "$request_method": request.method, + "$request_path": request.path, + } + + if request.remote_addr: + tags["$ip"] = request.remote_addr + + user_agent = request.headers.get("User-Agent") + if user_agent: + tags["$user_agent"] = user_agent + tags["$raw_user_agent"] = user_agent + + url_rule = getattr(request, "url_rule", None) + if url_rule is not None: + tags["$request_route"] = str(url_rule) + + return tags + + def _handle_unhandled_exception( + self, sender: Flask, exception: BaseException, **kwargs: Any + ) -> None: + if not self.capture_exceptions or not self._request_is_tracked(): + return + + capture_metadata = { + "level": "error", + "source": "flask.integration", + "mechanism": {"type": "flask", "handled": False}, + } + if self.client is not None: + _capture_exception_with_metadata(self.client, exception, capture_metadata) + else: + # Keep this import relative so the generated posthoganalytics mirror + # resolves its own global client rather than the posthog package. + from .. import capture_exception + + cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata) + + @staticmethod + def _request_is_tracked() -> bool: + from flask import g, has_request_context + + if not has_request_context(): + return False + state = getattr(g, _REQUEST_STATE_KEY, None) + return isinstance(state, _RequestState) and state.tracked + + def _teardown_request(self, exception: Optional[BaseException]) -> None: + from flask import g + + state = getattr(g, _REQUEST_STATE_KEY, None) + if not isinstance(state, _RequestState): + return + + # The Flask signal already captured any unhandled exception. Close the + # context normally so new_context cannot capture it a second time. + setattr(g, _REQUEST_STATE_KEY, None) + state.scope.__exit__(None, None, None) diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py new file mode 100644 index 00000000..2cc1518e --- /dev/null +++ b/posthog/test/integrations/test_flask_integration.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from flask import Flask, abort, jsonify + +from posthog import contexts +from posthog.integrations.flask import ( + PosthogFlaskIntegration, + _sanitize_tracing_header_value, +) + + +def _app() -> Flask: + app = Flask(__name__) + app.config.update(TESTING=True) + return app + + +def test_adds_request_context_and_restores_parent_context() -> None: + app = _app() + PosthogFlaskIntegration(app, extra_tags=lambda request: {"tenant": "acme"}) + + @app.get("/users/") + def view(user_id: str): + scope = contexts._get_current_context() + assert scope is not None + return jsonify( + distinct_id=contexts.get_context_distinct_id(), + session_id=contexts.get_context_session_id(), + tags=scope.collect_tags(), + ) + + with contexts.new_context(): + contexts.tag("outer", "must-not-leak-into-request") + response = app.test_client().get( + "/users/123?access_token=secret", + headers={ + "X-PostHog-Distinct-Id": " person-1 ", + "X-PostHog-Session-Id": " session-1 ", + "User-Agent": "integration-test/1.0", + }, + environ_base={"REMOTE_ADDR": "203.0.113.10"}, + ) + + payload = response.get_json() + assert payload["distinct_id"] == "person-1" + assert payload["session_id"] == "session-1" + assert payload["tags"] == { + "$current_url": "http://localhost/users/123", + "$ip": "203.0.113.10", + "$raw_user_agent": "integration-test/1.0", + "$request_method": "GET", + "$request_path": "/users/123", + "$request_route": "/users/", + "$user_agent": "integration-test/1.0", + "tenant": "acme", + } + assert "secret" not in payload["tags"]["$current_url"] + + parent = contexts._get_current_context() + assert parent is not None + assert parent.collect_tags() == {"outer": "must-not-leak-into-request"} + + +def test_captures_unhandled_exception_once_with_request_tags() -> None: + app = _app() + client = Mock() + captures = [] + + def capture(exception, **kwargs): + scope = contexts._get_current_context() + assert scope is not None + captures.append((exception, kwargs, scope.collect_tags())) + return "event-id" + + client.capture_exception.side_effect = capture + PosthogFlaskIntegration(app, client=client) + error = ValueError("view failed") + + @app.get("/failure") + def failure(): + raise error + + with pytest.raises(ValueError, match="view failed"): + app.test_client().get("/failure") + + assert len(captures) == 1 + exception, kwargs, tags = captures[0] + assert exception is error + assert kwargs == { + "_capture_metadata": { + "level": "error", + "source": "flask.integration", + "mechanism": {"type": "flask", "handled": False}, + } + } + assert tags["$request_path"] == "/failure" + assert contexts._get_current_context() is None + + +def test_uses_global_client_when_custom_client_is_not_provided() -> None: + app = _app() + PosthogFlaskIntegration(app) + error = RuntimeError("boom") + + @app.get("/failure") + def failure(): + raise error + + with patch("posthog.capture_exception", return_value="event-id") as capture: + with pytest.raises(RuntimeError, match="boom"): + app.test_client().get("/failure") + + capture.assert_called_once_with( + error, + _capture_metadata={ + "level": "error", + "source": "flask.integration", + "mechanism": {"type": "flask", "handled": False}, + }, + ) + + +def test_does_not_capture_handled_exception_or_expected_http_error() -> None: + app = _app() + client = Mock() + PosthogFlaskIntegration(app, client=client) + + @app.errorhandler(ValueError) + def handle_value_error(error): + return {"error": str(error)}, 422 + + @app.get("/handled") + def handled(): + raise ValueError("expected") + + @app.get("/missing") + def missing(): + abort(404) + + assert app.test_client().get("/handled").status_code == 422 + assert app.test_client().get("/missing").status_code == 404 + client.capture_exception.assert_not_called() + + +def test_capture_exceptions_can_be_disabled_without_changing_propagation() -> None: + app = _app() + client = Mock() + PosthogFlaskIntegration(app, client=client, capture_exceptions=False) + + @app.get("/failure") + def failure(): + raise LookupError("disabled") + + with pytest.raises(LookupError, match="disabled"): + app.test_client().get("/failure") + + client.capture_exception.assert_not_called() + + +def test_request_filter_skips_context_and_exception_capture() -> None: + app = _app() + client = Mock() + PosthogFlaskIntegration( + app, + client=client, + request_filter=lambda request: request.path != "/ignored", + ) + + @app.get("/ignored") + def ignored(): + assert contexts._get_current_context() is None + raise RuntimeError("ignored") + + with pytest.raises(RuntimeError, match="ignored"): + app.test_client().get("/ignored") + + client.capture_exception.assert_not_called() + + +def test_supports_application_factory_pattern_and_rejects_duplicate_setup() -> None: + app = _app() + integration = PosthogFlaskIntegration() + integration.init_app(app) + + assert app.extensions["posthog"] is integration + + with pytest.raises(RuntimeError, match="already initialized"): + PosthogFlaskIntegration(app) + + +def test_sanitizes_and_bounds_tracing_headers() -> None: + assert _sanitize_tracing_header_value(" person\n-\t1\x85 ") == "person-1" + assert _sanitize_tracing_header_value("\r\n") is None + assert _sanitize_tracing_header_value(123) is None + assert _sanitize_tracing_header_value("a" * 1001) == "a" * 1000 diff --git a/pyproject.toml b/pyproject.toml index 17052d31..8150af84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dev = [ ] test = [ "freezegun==1.5.1", + "flask>=2.2", "python-dateutil>=2.9.0.post0", "tzdata", "coverage", diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 51ee00d9..af8733f8 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -874,6 +874,10 @@ attribute posthog.integrations.django.PosthogContextMiddleware.get_response = ge 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.flask.PosthogFlaskIntegration.capture_exceptions = capture_exceptions +attribute posthog.integrations.flask.PosthogFlaskIntegration.client = client +attribute posthog.integrations.flask.PosthogFlaskIntegration.extra_tags = extra_tags +attribute posthog.integrations.flask.PosthogFlaskIntegration.request_filter = request_filter attribute posthog.is_server = True attribute posthog.log_captured_exceptions = False attribute posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware.app = app @@ -1199,6 +1203,7 @@ class posthog.flag_definition_cache.FlagDefinitionCacheData class posthog.flag_definition_cache.FlagDefinitionCacheProvider 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_tags: Optional[Callable[[Request], Mapping[str, Any]]] = None) class posthog.mcp.McpAnalytics(key: Any) class posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware(app: Any) class posthog.mcp.constants.PostHogMCPAnalyticsEvent @@ -1667,6 +1672,7 @@ method posthog.integrations.django.PosthogContextMiddleware.aextract_tags(reques 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) +method posthog.integrations.flask.PosthogFlaskIntegration.init_app(app: Flask) -> None method posthog.mcp.McpAnalytics.capture(event: str, properties: Optional[dict] = None) -> None method posthog.mcp.McpAnalytics.flush() -> None method posthog.mcp.posthog_mcp.PostHogMCP.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] @@ -1776,6 +1782,7 @@ module posthog.flag_definition_cache module posthog.integrations module posthog.integrations.celery module posthog.integrations.django +module posthog.integrations.flask module posthog.mcp module posthog.mcp.asgi module posthog.mcp.constants diff --git a/uv.lock b/uv.lock index 60046d85..5a0563d2 100644 --- a/uv.lock +++ b/uv.lock @@ -29,7 +29,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "caio" }, + { name = "caio", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" } wheels = [ @@ -49,7 +49,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "caio" }, + { name = "caio", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } wheels = [ @@ -265,7 +265,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "typing-extensions", marker = "python_full_version != '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/68/fb4fb78c9eac59d5e819108a57664737f855c5a8e9b76aec1738bb137f9e/asgiref-3.9.0.tar.gz", hash = "sha256:3dd2556d0f08c4fab8a010d9ab05ef8c34565f6bf32381d17505f7ca5b273767", size = 36772, upload-time = "2025-07-03T13:25:01.491Z" } wheels = [ @@ -355,6 +355,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "cachetools" version = "7.1.6" @@ -914,6 +923,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "freezegun" version = "1.5.1" @@ -1361,6 +1387,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jaraco-classes" version = "3.4.0" @@ -1406,6 +1441,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.10.0" @@ -2814,6 +2861,7 @@ test = [ { name = "coverage" }, { name = "django" }, { name = "fastmcp" }, + { name = "flask" }, { name = "freezegun" }, { name = "gevent", marker = "implementation_name == 'cpython'" }, { name = "google-genai" }, @@ -2859,6 +2907,7 @@ requires-dist = [ { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, { name = "django-stubs", marker = "extra == 'dev'" }, { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, + { name = "flask", marker = "extra == 'test'", specifier = ">=2.2" }, { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, { name = "gevent", marker = "implementation_name == 'cpython' and extra == 'test'", specifier = ">=25.4.1" }, { name = "google-genai", marker = "extra == 'test'" }, @@ -3766,8 +3815,8 @@ name = "secretstorage" version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "jeepney", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } wheels = [ @@ -4353,6 +4402,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +] + [[package]] name = "wheel" version = "0.45.1" From 51d7fb529ef88ddf526bae704f0cccfc67403ecd Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 11:47:46 +0200 Subject: [PATCH 2/5] fix(flask): use canonical exception metadata --- posthog/integrations/flask.py | 4 ++-- posthog/test/integrations/test_flask_integration.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index b1ef05ac..4be0a151 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -183,8 +183,8 @@ def _handle_unhandled_exception( capture_metadata = { "level": "error", - "source": "flask.integration", - "mechanism": {"type": "flask", "handled": False}, + "source": "flask.got_request_exception", + "mechanism": {"type": "middleware", "handled": False}, } if self.client is not None: _capture_exception_with_metadata(self.client, exception, capture_metadata) diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py index 2cc1518e..3aad3f68 100644 --- a/posthog/test/integrations/test_flask_integration.py +++ b/posthog/test/integrations/test_flask_integration.py @@ -92,8 +92,8 @@ def failure(): assert kwargs == { "_capture_metadata": { "level": "error", - "source": "flask.integration", - "mechanism": {"type": "flask", "handled": False}, + "source": "flask.got_request_exception", + "mechanism": {"type": "middleware", "handled": False}, } } assert tags["$request_path"] == "/failure" @@ -117,8 +117,8 @@ def failure(): error, _capture_metadata={ "level": "error", - "source": "flask.integration", - "mechanism": {"type": "flask", "handled": False}, + "source": "flask.got_request_exception", + "mechanism": {"type": "middleware", "handled": False}, }, ) From 1c4a8c93993bdd80f1de09e7d772da2629ab15be Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 12:22:52 +0200 Subject: [PATCH 3/5] chore(flask): type exception capture metadata --- posthog/integrations/flask.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index 4be0a151..5906b317 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -21,7 +21,10 @@ from .. import contexts from ..client import Client -from ..exception_utils import _capture_exception_with_metadata +from ..exception_utils import ( + _ExceptionCaptureMetadata, + _capture_exception_with_metadata, +) if TYPE_CHECKING: from flask import Flask, Request @@ -181,7 +184,7 @@ def _handle_unhandled_exception( if not self.capture_exceptions or not self._request_is_tracked(): return - capture_metadata = { + capture_metadata: _ExceptionCaptureMetadata = { "level": "error", "source": "flask.got_request_exception", "mechanism": {"type": "middleware", "handled": False}, From 82e40ea02985a35673863b4915940c01f337ef8a Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 12:37:50 +0200 Subject: [PATCH 4/5] refactor(flask): expose request properties terminology --- posthog/integrations/flask.py | 32 +++++++++---------- .../integrations/test_flask_integration.py | 14 ++++---- references/public_api_snapshot.txt | 4 +-- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index 5906b317..dafb1e8c 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -70,8 +70,8 @@ class PosthogFlaskIntegration: request_filter: Optional callback receiving Flask's request object. A false return value disables both context and exception capture for that request. - extra_tags: Optional callback returning additional context tags. This - is useful for application-specific metadata such as an authenticated + 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. The integration intentionally does not capture exceptions handled by an @@ -88,12 +88,12 @@ def __init__( client: Optional[Client] = None, capture_exceptions: bool = True, request_filter: Optional[Callable[[Request], bool]] = None, - extra_tags: Optional[Callable[[Request], Mapping[str, Any]]] = None, + extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None, ) -> None: self.client = client self.capture_exceptions = capture_exceptions self.request_filter = request_filter - self.extra_tags = extra_tags + self.extra_properties = extra_properties if app is not None: self.init_app(app) @@ -145,18 +145,18 @@ def _before_request(self) -> None: if distinct_id: contexts.identify_context(distinct_id) - for key, value in self._request_tags(request).items(): + for key, value in self._request_properties(request).items(): contexts.tag(key, value) - if self.extra_tags is not None: - extra_tags = self.extra_tags(request) - if extra_tags: - for key, value in extra_tags.items(): + if self.extra_properties is not None: + extra_properties = self.extra_properties(request) + if extra_properties: + for key, value in extra_properties.items(): contexts.tag(key, value) @staticmethod - def _request_tags(request: Request) -> dict[str, Any]: - tags: dict[str, Any] = { + def _request_properties(request: Request) -> dict[str, Any]: + properties: dict[str, Any] = { # base_url deliberately excludes query strings, which commonly # contain credentials, tokens, and other sensitive values. "$current_url": request.base_url, @@ -165,18 +165,18 @@ def _request_tags(request: Request) -> dict[str, Any]: } if request.remote_addr: - tags["$ip"] = request.remote_addr + properties["$ip"] = request.remote_addr user_agent = request.headers.get("User-Agent") if user_agent: - tags["$user_agent"] = user_agent - tags["$raw_user_agent"] = user_agent + properties["$user_agent"] = user_agent + properties["$raw_user_agent"] = user_agent url_rule = getattr(request, "url_rule", None) if url_rule is not None: - tags["$request_route"] = str(url_rule) + properties["$request_route"] = str(url_rule) - return tags + return properties def _handle_unhandled_exception( self, sender: Flask, exception: BaseException, **kwargs: Any diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py index 3aad3f68..3514a924 100644 --- a/posthog/test/integrations/test_flask_integration.py +++ b/posthog/test/integrations/test_flask_integration.py @@ -20,7 +20,7 @@ def _app() -> Flask: def test_adds_request_context_and_restores_parent_context() -> None: app = _app() - PosthogFlaskIntegration(app, extra_tags=lambda request: {"tenant": "acme"}) + PosthogFlaskIntegration(app, extra_properties=lambda request: {"tenant": "acme"}) @app.get("/users/") def view(user_id: str): @@ -29,7 +29,7 @@ def view(user_id: str): return jsonify( distinct_id=contexts.get_context_distinct_id(), session_id=contexts.get_context_session_id(), - tags=scope.collect_tags(), + properties=scope.collect_tags(), ) with contexts.new_context(): @@ -47,7 +47,7 @@ def view(user_id: str): payload = response.get_json() assert payload["distinct_id"] == "person-1" assert payload["session_id"] == "session-1" - assert payload["tags"] == { + assert payload["properties"] == { "$current_url": "http://localhost/users/123", "$ip": "203.0.113.10", "$raw_user_agent": "integration-test/1.0", @@ -57,14 +57,14 @@ def view(user_id: str): "$user_agent": "integration-test/1.0", "tenant": "acme", } - assert "secret" not in payload["tags"]["$current_url"] + assert "secret" not in payload["properties"]["$current_url"] parent = contexts._get_current_context() assert parent is not None assert parent.collect_tags() == {"outer": "must-not-leak-into-request"} -def test_captures_unhandled_exception_once_with_request_tags() -> None: +def test_captures_unhandled_exception_once_with_request_properties() -> None: app = _app() client = Mock() captures = [] @@ -87,7 +87,7 @@ def failure(): app.test_client().get("/failure") assert len(captures) == 1 - exception, kwargs, tags = captures[0] + exception, kwargs, properties = captures[0] assert exception is error assert kwargs == { "_capture_metadata": { @@ -96,7 +96,7 @@ def failure(): "mechanism": {"type": "middleware", "handled": False}, } } - assert tags["$request_path"] == "/failure" + assert properties["$request_path"] == "/failure" assert contexts._get_current_context() is None diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index af8733f8..a1d6a4f9 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -876,7 +876,7 @@ attribute posthog.integrations.django.PosthogContextMiddleware.sync_capable = Tr attribute posthog.integrations.django.PosthogContextMiddleware.tag_map = cast('Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]', settings.POSTHOG_MW_TAG_MAP) attribute posthog.integrations.flask.PosthogFlaskIntegration.capture_exceptions = capture_exceptions attribute posthog.integrations.flask.PosthogFlaskIntegration.client = client -attribute posthog.integrations.flask.PosthogFlaskIntegration.extra_tags = extra_tags +attribute posthog.integrations.flask.PosthogFlaskIntegration.extra_properties = extra_properties attribute posthog.integrations.flask.PosthogFlaskIntegration.request_filter = request_filter attribute posthog.is_server = True attribute posthog.log_captured_exceptions = False @@ -1203,7 +1203,7 @@ class posthog.flag_definition_cache.FlagDefinitionCacheData class posthog.flag_definition_cache.FlagDefinitionCacheProvider 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_tags: Optional[Callable[[Request], Mapping[str, Any]]] = None) +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.mcp.McpAnalytics(key: Any) class posthog.mcp.asgi.PostHogMcpStatelessSessionMiddleware(app: Any) class posthog.mcp.constants.PostHogMCPAnalyticsEvent From 807f85b841abd31d16fb0da287af50717e850c78 Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 8 Oct 2026 16:19:54 +0200 Subject: [PATCH 5/5] fix(flask): preserve exception privacy settings --- posthog/integrations/flask.py | 42 +++++++++++++ .../integrations/test_flask_integration.py | 62 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index dafb1e8c..9a4bf430 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -57,6 +57,43 @@ class _RequestState: tracked: bool = True +@dataclass(frozen=True) +class _ExceptionPrivacySettings: + capture_code_variables: Optional[bool] + mask_patterns: Optional[list] + ignore_patterns: Optional[list] + mask_url_credentials: Optional[bool] + detect_secrets: Optional[bool] + + @classmethod + def from_current_context(cls) -> _ExceptionPrivacySettings: + """Snapshot effective privacy controls before entering a fresh request scope.""" + return cls( + capture_code_variables=contexts.get_capture_exception_code_variables_context(), + mask_patterns=contexts.get_code_variables_mask_patterns_context(), + ignore_patterns=contexts.get_code_variables_ignore_patterns_context(), + mask_url_credentials=contexts.get_code_variables_mask_url_credentials_context(), + detect_secrets=contexts.get_code_variables_detect_secrets_context(), + ) + + def apply(self) -> None: + """Apply inherited privacy controls without restoring identity or properties.""" + if self.capture_code_variables is not None: + contexts.set_capture_exception_code_variables_context( + self.capture_code_variables + ) + if self.mask_patterns is not None: + contexts.set_code_variables_mask_patterns_context(self.mask_patterns) + if self.ignore_patterns is not None: + contexts.set_code_variables_ignore_patterns_context(self.ignore_patterns) + if self.mask_url_credentials is not None: + contexts.set_code_variables_mask_url_credentials_context( + self.mask_url_credentials + ) + if self.detect_secrets is not None: + contexts.set_code_variables_detect_secrets_context(self.detect_secrets) + + class PosthogFlaskIntegration: """Add PostHog request context and exception tracking to a Flask app. @@ -122,6 +159,10 @@ def _before_request(self) -> None: setattr(g, _REQUEST_STATE_KEY, None) return + # A request gets fresh identity and event properties, but privacy controls + # must remain at least as strict as the effective enclosing context. + privacy_settings = _ExceptionPrivacySettings.from_current_context() + # Flask handles application exceptions before returning control through # the request stack, so capture through got_request_exception rather than # through new_context. This also avoids duplicate capture. @@ -131,6 +172,7 @@ def _before_request(self) -> None: client=self.client, ) scope.__enter__() + privacy_settings.apply() setattr(g, _REQUEST_STATE_KEY, _RequestState(scope=scope)) session_id = _sanitize_tracing_header_value( diff --git a/posthog/test/integrations/test_flask_integration.py b/posthog/test/integrations/test_flask_integration.py index 3514a924..4df637ff 100644 --- a/posthog/test/integrations/test_flask_integration.py +++ b/posthog/test/integrations/test_flask_integration.py @@ -64,6 +64,68 @@ def view(user_id: str): assert parent.collect_tags() == {"outer": "must-not-leak-into-request"} +def test_fresh_request_preserves_enclosing_exception_privacy_settings() -> None: + app = _app() + client = Mock() + observed = {} + + def capture(exception, **kwargs): + scope = contexts._get_current_context() + assert scope is not None + observed.update( + capture_code_variables=contexts.get_capture_exception_code_variables_context(), + mask_patterns=contexts.get_code_variables_mask_patterns_context(), + ignore_patterns=contexts.get_code_variables_ignore_patterns_context(), + mask_url_credentials=contexts.get_code_variables_mask_url_credentials_context(), + detect_secrets=contexts.get_code_variables_detect_secrets_context(), + distinct_id=contexts.get_context_distinct_id(), + session_id=contexts.get_context_session_id(), + properties=scope.collect_tags(), + ) + return "event-id" + + client.capture_exception.side_effect = capture + PosthogFlaskIntegration(app, client=client) + + @app.get("/privacy") + def privacy_failure(): + raise ValueError("privacy settings") + + with contexts.new_context(): + contexts.set_capture_exception_code_variables_context(False) + contexts.set_code_variables_mask_patterns_context(["secret"]) + contexts.set_code_variables_ignore_patterns_context(["ignored"]) + contexts.set_code_variables_mask_url_credentials_context(True) + contexts.set_code_variables_detect_secrets_context(True) + contexts.identify_context("outer-person") + contexts.set_context_session("outer-session") + contexts.tag("outer-property", "must-not-leak") + + with pytest.raises(ValueError, match="privacy settings"): + app.test_client().get("/privacy") + + # Teardown restores the enclosing context unchanged. + assert contexts.get_capture_exception_code_variables_context() is False + assert contexts.get_code_variables_mask_patterns_context() == ["secret"] + assert contexts.get_code_variables_ignore_patterns_context() == ["ignored"] + assert contexts.get_code_variables_mask_url_credentials_context() is True + assert contexts.get_code_variables_detect_secrets_context() is True + + properties = observed.pop("properties") + assert properties["$request_path"] == "/privacy" + assert "outer-property" not in properties + assert observed == { + "capture_code_variables": False, + "mask_patterns": ["secret"], + "ignore_patterns": ["ignored"], + "mask_url_credentials": True, + "detect_secrets": True, + # Identity remains isolated by the fresh scope. + "distinct_id": None, + "session_id": None, + } + + def test_captures_unhandled_exception_once_with_request_properties() -> None: app = _app() client = Mock()