From 1408ca12b7c7fe0526242d428fae85ba6035b8cf Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 12 Sep 2024 16:42:28 +0200 Subject: [PATCH] Configure http methods to capture in wsgi middleware --- sentry_sdk/integrations/_wsgi_common.py | 12 +++++ sentry_sdk/integrations/flask.py | 21 ++++++-- sentry_sdk/integrations/wsgi.py | 62 ++++++++++++++++------ tests/integrations/flask/test_flask.py | 69 +++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 18 deletions(-) diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 14a4c4aea4..0b9ddd01f7 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -37,6 +37,18 @@ x[len("HTTP_") :] for x in SENSITIVE_ENV_KEYS if x.startswith("HTTP_") ) +DEFAULT_HTTP_METHODS_TO_CAPTURE = ( + "CONNECT", + "DELETE", + "GET", + # "HEAD", # do not capture HEAD requests + # "OPTIONS", # do not capture OPTIONS requests + "PATCH", + "POST", + "PUT", + "TRACE", +) + def request_body_within_bounds(client, content_length): # type: (Optional[sentry_sdk.client.BaseClient], int) -> bool diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 7b0fcf3187..323b028d0a 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -1,6 +1,9 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.integrations._wsgi_common import RequestExtractor +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + RequestExtractor, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import SOURCE_FOR_STYLE @@ -52,14 +55,19 @@ class FlaskIntegration(Integration): transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__( + self, + transaction_style="endpoint", # type: str + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...] + ): + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) @staticmethod def setup_once(): @@ -83,9 +91,16 @@ def sentry_patched_wsgi_app(self, environ, start_response): if sentry_sdk.get_client().get_integration(FlaskIntegration) is None: return old_app(self, environ, start_response) + integration = sentry_sdk.get_client().get_integration(FlaskIntegration) + middleware = SentryWsgiMiddleware( lambda *a, **kw: old_app(self, *a, **kw), span_origin=FlaskIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), ) return middleware(environ, start_response) diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 00aad30854..5f72e9f5c0 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -1,4 +1,5 @@ import sys +from contextlib import contextmanager from functools import partial import sentry_sdk @@ -6,7 +7,10 @@ from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.integrations._wsgi_common import _filter_headers +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + _filter_headers, +) from sentry_sdk.sessions import track_session from sentry_sdk.scope import use_isolation_scope from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_ROUTE @@ -45,6 +49,13 @@ def __call__(self, status, response_headers, exc_info=None): # type: ignore _wsgi_middleware_applied = ContextVar("sentry_wsgi_middleware_applied") +# This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support +@contextmanager +def nullcontext(): + # type: () -> Iterator[None] + yield + + def wsgi_decoding_dance(s, charset="utf-8", errors="replace"): # type: (str, str, str) -> str return s.encode("latin1").decode(charset, errors) @@ -66,13 +77,25 @@ def get_request_url(environ, use_x_forwarded_for=False): class SentryWsgiMiddleware: - __slots__ = ("app", "use_x_forwarded_for", "span_origin") + __slots__ = ( + "app", + "use_x_forwarded_for", + "span_origin", + "http_methods_to_capture", + ) - def __init__(self, app, use_x_forwarded_for=False, span_origin="manual"): - # type: (Callable[[Dict[str, str], Callable[..., Any]], Any], bool, str) -> None + def __init__( + self, + app, # type: Callable[[Dict[str, str], Callable[..., Any]], Any] + use_x_forwarded_for=False, # type: bool + span_origin="manual", # type: str + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: Tuple[str, ...] + ): + # type: (...) -> None self.app = app self.use_x_forwarded_for = use_x_forwarded_for self.span_origin = span_origin + self.http_methods_to_capture = http_methods_to_capture def __call__(self, environ, start_response): # type: (Dict[str, str], Callable[..., Any]) -> _ScopedResponse @@ -92,16 +115,24 @@ def __call__(self, environ, start_response): ) ) - transaction = continue_trace( - environ, - op=OP.HTTP_SERVER, - name="generic WSGI request", - source=TRANSACTION_SOURCE_ROUTE, - origin=self.span_origin, - ) + method = environ.get("REQUEST_METHOD", "").upper() + transaction = None + if method in self.http_methods_to_capture: + transaction = continue_trace( + environ, + op=OP.HTTP_SERVER, + name="generic WSGI request", + source=TRANSACTION_SOURCE_ROUTE, + origin=self.span_origin, + ) - with sentry_sdk.start_transaction( - transaction, custom_sampling_context={"wsgi_environ": environ} + with ( + sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"wsgi_environ": environ}, + ) + if transaction is not None + else nullcontext() ): try: response = self.app( @@ -120,7 +151,7 @@ def __call__(self, environ, start_response): def _sentry_start_response( # type: ignore old_start_response, # type: StartResponse - transaction, # type: Transaction + transaction, # type: Optional[Transaction] status, # type: str response_headers, # type: WsgiResponseHeaders exc_info=None, # type: Optional[WsgiExcInfo] @@ -128,7 +159,8 @@ def _sentry_start_response( # type: ignore # type: (...) -> WsgiResponseIter with capture_internal_exceptions(): status_int = int(status.split(" ", 1)[0]) - transaction.set_http_status(status_int) + if transaction is not None: + transaction.set_http_status(status_int) if exc_info is None: # The Django Rest Framework WSGI test client, and likely other diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 03a3b0b9d0..20eb12623c 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -47,6 +47,10 @@ def hi(): capture_message("hi") return "ok" + @app.route("/nomessage") + def nohi(): + return "ok" + @app.route("/message/") def hi_with_id(message_id): capture_message("hi again") @@ -962,3 +966,68 @@ def test_span_origin(sentry_init, app, capture_events): (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + + +def test_transaction_http_method_default( + sentry_init, + app, + capture_events, +): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + sentry_init( + traces_sample_rate=1.0, + integrations=[flask_sentry.FlaskIntegration()], + ) + events = capture_events() + + client = app.test_client() + response = client.get("/nomessage") + assert response.status_code == 200 + + response = client.options("/nomessage") + assert response.status_code == 200 + + response = client.head("/nomessage") + assert response.status_code == 200 + + (event,) = events + + assert len(events) == 1 + assert event["request"]["method"] == "GET" + + +def test_transaction_http_method_custom( + sentry_init, + app, + capture_events, +): + """ + Configure FlaskIntegration to ONLY capture OPTIONS and HEAD requests. + """ + sentry_init( + traces_sample_rate=1.0, + integrations=[ + flask_sentry.FlaskIntegration( + http_methods_to_capture=("OPTIONS", "HEAD") + ) # case does not matter + ], + ) + events = capture_events() + + client = app.test_client() + response = client.get("/nomessage") + assert response.status_code == 200 + + response = client.options("/nomessage") + assert response.status_code == 200 + + response = client.head("/nomessage") + assert response.status_code == 200 + + assert len(events) == 2 + + (event1, event2) = events + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD"