Skip to content

Commit 1bf2c2c

Browse files
hpouilloteli-r-ph
authored andcommitted
feat(flask): add error tracking integration (#1038)
* feat(flask): add error tracking integration * fix(flask): use canonical exception metadata * chore(flask): type exception capture metadata * refactor(flask): expose request properties terminology * fix(flask): preserve exception privacy settings (cherry picked from commit 292855d)
1 parent 236a73e commit 1bf2c2c

6 files changed

Lines changed: 601 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
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.

‎posthog/integrations/flask.py‎

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Flask request context and exception tracking integration.
2+
3+
Flask is imported lazily when :meth:`PosthogFlaskIntegration.init_app` is called,
4+
so importing the PostHog SDK does not require Flask to be installed.
5+
6+
Example::
7+
8+
from flask import Flask
9+
from posthog.integrations.flask import PosthogFlaskIntegration
10+
11+
app = Flask(__name__)
12+
PosthogFlaskIntegration(app)
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import re
18+
from contextlib import AbstractContextManager
19+
from dataclasses import dataclass
20+
from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, cast
21+
22+
from .. import contexts
23+
from ..client import Client
24+
from ..exception_utils import (
25+
_ExceptionCaptureMetadata,
26+
_capture_exception_with_metadata,
27+
)
28+
29+
if TYPE_CHECKING:
30+
from flask import Flask, Request
31+
32+
33+
__all__ = ["PosthogFlaskIntegration"]
34+
35+
_MAX_TRACING_HEADER_LENGTH = 1000
36+
_TRACING_HEADER_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
37+
_EXTENSION_KEY = "posthog"
38+
_REQUEST_STATE_KEY = "_posthog_integration_state"
39+
40+
41+
def _sanitize_tracing_header_value(value: object) -> Optional[str]:
42+
"""Return a bounded tracing header value safe for event properties."""
43+
if not isinstance(value, str) or not value:
44+
return None
45+
46+
return (
47+
_TRACING_HEADER_CONTROL_CHARS_RE.sub("", value).strip()[
48+
:_MAX_TRACING_HEADER_LENGTH
49+
]
50+
or None
51+
)
52+
53+
54+
@dataclass
55+
class _RequestState:
56+
scope: AbstractContextManager[None]
57+
tracked: bool = True
58+
59+
60+
@dataclass(frozen=True)
61+
class _ExceptionPrivacySettings:
62+
capture_code_variables: Optional[bool]
63+
mask_patterns: Optional[list]
64+
ignore_patterns: Optional[list]
65+
mask_url_credentials: Optional[bool]
66+
detect_secrets: Optional[bool]
67+
68+
@classmethod
69+
def from_current_context(cls) -> _ExceptionPrivacySettings:
70+
"""Snapshot effective privacy controls before entering a fresh request scope."""
71+
return cls(
72+
capture_code_variables=contexts.get_capture_exception_code_variables_context(),
73+
mask_patterns=contexts.get_code_variables_mask_patterns_context(),
74+
ignore_patterns=contexts.get_code_variables_ignore_patterns_context(),
75+
mask_url_credentials=contexts.get_code_variables_mask_url_credentials_context(),
76+
detect_secrets=contexts.get_code_variables_detect_secrets_context(),
77+
)
78+
79+
def apply(self) -> None:
80+
"""Apply inherited privacy controls without restoring identity or properties."""
81+
if self.capture_code_variables is not None:
82+
contexts.set_capture_exception_code_variables_context(
83+
self.capture_code_variables
84+
)
85+
if self.mask_patterns is not None:
86+
contexts.set_code_variables_mask_patterns_context(self.mask_patterns)
87+
if self.ignore_patterns is not None:
88+
contexts.set_code_variables_ignore_patterns_context(self.ignore_patterns)
89+
if self.mask_url_credentials is not None:
90+
contexts.set_code_variables_mask_url_credentials_context(
91+
self.mask_url_credentials
92+
)
93+
if self.detect_secrets is not None:
94+
contexts.set_code_variables_detect_secrets_context(self.detect_secrets)
95+
96+
97+
class PosthogFlaskIntegration:
98+
"""Add PostHog request context and exception tracking to a Flask app.
99+
100+
Args:
101+
app: An optional Flask application. If omitted, call :meth:`init_app`
102+
later (the Flask application-factory pattern).
103+
client: Optional PostHog client used to capture exceptions. The global
104+
client is used by default.
105+
capture_exceptions: Capture exceptions that reach Flask's unhandled
106+
exception machinery. Defaults to ``True``.
107+
request_filter: Optional callback receiving Flask's request object. A
108+
false return value disables both context and exception capture for
109+
that request.
110+
extra_properties: Optional callback returning additional event properties.
111+
This is useful for application-specific metadata such as an authenticated
112+
user's role. Values should not contain secrets or request bodies.
113+
114+
The integration intentionally does not capture exceptions handled by an
115+
application error handler, expected HTTP exceptions, request or response
116+
bodies, query strings, cookies, authorization headers, or arbitrary headers.
117+
Call ``capture_exception`` explicitly from a custom error handler if a
118+
handled exception should be reported.
119+
"""
120+
121+
def __init__(
122+
self,
123+
app: Optional[Flask] = None,
124+
*,
125+
client: Optional[Client] = None,
126+
capture_exceptions: bool = True,
127+
request_filter: Optional[Callable[[Request], bool]] = None,
128+
extra_properties: Optional[Callable[[Request], Mapping[str, Any]]] = None,
129+
) -> None:
130+
self.client = client
131+
self.capture_exceptions = capture_exceptions
132+
self.request_filter = request_filter
133+
self.extra_properties = extra_properties
134+
135+
if app is not None:
136+
self.init_app(app)
137+
138+
def init_app(self, app: Flask) -> None:
139+
"""Register the integration with a Flask application once."""
140+
try:
141+
from flask import got_request_exception
142+
except ImportError as error: # pragma: no cover - exercised without Flask
143+
raise RuntimeError(
144+
"PosthogFlaskIntegration requires Flask to be installed"
145+
) from error
146+
147+
if _EXTENSION_KEY in app.extensions:
148+
raise RuntimeError("PostHog is already initialized for this Flask app")
149+
150+
app.extensions[_EXTENSION_KEY] = self
151+
app.before_request(self._before_request)
152+
app.teardown_request(self._teardown_request)
153+
got_request_exception.connect(self._handle_unhandled_exception, app, weak=False)
154+
155+
def _before_request(self) -> None:
156+
from flask import g, request
157+
158+
if self.request_filter is not None and not self.request_filter(request):
159+
setattr(g, _REQUEST_STATE_KEY, None)
160+
return
161+
162+
# A request gets fresh identity and event properties, but privacy controls
163+
# must remain at least as strict as the effective enclosing context.
164+
privacy_settings = _ExceptionPrivacySettings.from_current_context()
165+
166+
# Flask handles application exceptions before returning control through
167+
# the request stack, so capture through got_request_exception rather than
168+
# through new_context. This also avoids duplicate capture.
169+
scope = contexts.new_context(
170+
fresh=True,
171+
capture_exceptions=False,
172+
client=self.client,
173+
)
174+
scope.__enter__()
175+
privacy_settings.apply()
176+
setattr(g, _REQUEST_STATE_KEY, _RequestState(scope=scope))
177+
178+
session_id = _sanitize_tracing_header_value(
179+
request.headers.get("X-POSTHOG-SESSION-ID")
180+
)
181+
if session_id:
182+
contexts.set_context_session(session_id)
183+
184+
distinct_id = _sanitize_tracing_header_value(
185+
request.headers.get("X-POSTHOG-DISTINCT-ID")
186+
)
187+
if distinct_id:
188+
contexts.identify_context(distinct_id)
189+
190+
for key, value in self._request_properties(request).items():
191+
contexts.tag(key, value)
192+
193+
if self.extra_properties is not None:
194+
extra_properties = self.extra_properties(request)
195+
if extra_properties:
196+
for key, value in extra_properties.items():
197+
contexts.tag(key, value)
198+
199+
@staticmethod
200+
def _request_properties(request: Request) -> dict[str, Any]:
201+
properties: dict[str, Any] = {
202+
# base_url deliberately excludes query strings, which commonly
203+
# contain credentials, tokens, and other sensitive values.
204+
"$current_url": request.base_url,
205+
"$request_method": request.method,
206+
"$request_path": request.path,
207+
}
208+
209+
if request.remote_addr:
210+
properties["$ip"] = request.remote_addr
211+
212+
user_agent = request.headers.get("User-Agent")
213+
if user_agent:
214+
properties["$user_agent"] = user_agent
215+
properties["$raw_user_agent"] = user_agent
216+
217+
url_rule = getattr(request, "url_rule", None)
218+
if url_rule is not None:
219+
properties["$request_route"] = str(url_rule)
220+
221+
return properties
222+
223+
def _handle_unhandled_exception(
224+
self, sender: Flask, exception: BaseException, **kwargs: Any
225+
) -> None:
226+
if not self.capture_exceptions or not self._request_is_tracked():
227+
return
228+
229+
capture_metadata: _ExceptionCaptureMetadata = {
230+
"level": "error",
231+
"source": "flask.got_request_exception",
232+
"mechanism": {"type": "middleware", "handled": False},
233+
}
234+
if self.client is not None:
235+
_capture_exception_with_metadata(self.client, exception, capture_metadata)
236+
else:
237+
# Keep this import relative so the generated posthoganalytics mirror
238+
# resolves its own global client rather than the posthog package.
239+
from .. import capture_exception
240+
241+
cast(Any, capture_exception)(exception, _capture_metadata=capture_metadata)
242+
243+
@staticmethod
244+
def _request_is_tracked() -> bool:
245+
from flask import g, has_request_context
246+
247+
if not has_request_context():
248+
return False
249+
state = getattr(g, _REQUEST_STATE_KEY, None)
250+
return isinstance(state, _RequestState) and state.tracked
251+
252+
def _teardown_request(self, exception: Optional[BaseException]) -> None:
253+
from flask import g
254+
255+
state = getattr(g, _REQUEST_STATE_KEY, None)
256+
if not isinstance(state, _RequestState):
257+
return
258+
259+
# The Flask signal already captured any unhandled exception. Close the
260+
# context normally so new_context cannot capture it a second time.
261+
setattr(g, _REQUEST_STATE_KEY, None)
262+
state.scope.__exit__(None, None, None)

0 commit comments

Comments
 (0)