diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index c16bbbcfe8..8608ce12e8 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -15,6 +15,19 @@ from sentry_sdk.utils import AnnotatedValue +DEFAULT_HTTP_METHODS_TO_CAPTURE = ( + "CONNECT", + "DELETE", + "GET", + # "HEAD", # do not capture HEAD requests by default + # "OPTIONS", # do not capture OPTIONS requests by default + "PATCH", + "POST", + "PUT", + "TRACE", +) + + def _get_headers(asgi_scope): # type: (Any) -> Dict[str, str] """ diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index 33fe18bd82..4459f8e4fc 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -5,6 +5,7 @@ """ import asyncio +from contextlib import contextmanager import inspect from copy import deepcopy from functools import partial @@ -14,6 +15,7 @@ from sentry_sdk.consts import OP from sentry_sdk.integrations._asgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, _get_headers, _get_request_data, _get_url, @@ -42,6 +44,7 @@ from typing import Any from typing import Callable from typing import Dict + from typing import Iterator from typing import Optional from typing import Tuple @@ -55,6 +58,13 @@ TRANSACTION_STYLE_VALUES = ("endpoint", "url") +# 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 _capture_exception(exc, mechanism_type="asgi"): # type: (Any, str) -> None @@ -89,17 +99,19 @@ class SentryAsgiMiddleware: "transaction_style", "mechanism_type", "span_origin", + "http_methods_to_capture", ) def __init__( self, - app, - unsafe_context_data=False, - transaction_style="endpoint", - mechanism_type="asgi", - span_origin="manual", + app, # type: Any + unsafe_context_data=False, # type: bool + transaction_style="endpoint", # type: str + mechanism_type="asgi", # type: str + span_origin="manual", # type: str + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: Tuple[str, ...] ): - # type: (Any, bool, str, str, str) -> None + # type: (...) -> None """ Instrument an ASGI application with Sentry. Provides HTTP/websocket data to sent events and basic handling for exceptions bubbling up @@ -134,6 +146,7 @@ def __init__( self.mechanism_type = mechanism_type self.span_origin = span_origin self.app = app + self.http_methods_to_capture = http_methods_to_capture if _looks_like_asgi3(app): self.__call__ = self._run_asgi3 # type: Callable[..., Any] @@ -185,52 +198,59 @@ async def _run_app(self, scope, receive, send, asgi_version): scope, ) - if ty in ("http", "websocket"): - transaction = continue_trace( - _get_headers(scope), - op="{}.server".format(ty), - name=transaction_name, - source=transaction_source, - origin=self.span_origin, - ) - logger.debug( - "[ASGI] Created transaction (continuing trace): %s", - transaction, - ) - else: - transaction = Transaction( - op=OP.HTTP_SERVER, - name=transaction_name, - source=transaction_source, - origin=self.span_origin, - ) + method = scope.get("method", "").upper() + transaction = None + if method in self.http_methods_to_capture: + if ty in ("http", "websocket"): + transaction = continue_trace( + _get_headers(scope), + op="{}.server".format(ty), + name=transaction_name, + source=transaction_source, + origin=self.span_origin, + ) + logger.debug( + "[ASGI] Created transaction (continuing trace): %s", + transaction, + ) + else: + transaction = Transaction( + op=OP.HTTP_SERVER, + name=transaction_name, + source=transaction_source, + origin=self.span_origin, + ) + logger.debug( + "[ASGI] Created transaction (new): %s", transaction + ) + + transaction.set_tag("asgi.type", ty) logger.debug( - "[ASGI] Created transaction (new): %s", transaction + "[ASGI] Set transaction name and source on transaction: '%s' / '%s'", + transaction.name, + transaction.source, ) - transaction.set_tag("asgi.type", ty) - logger.debug( - "[ASGI] Set transaction name and source on transaction: '%s' / '%s'", - transaction.name, - transaction.source, - ) - - with sentry_sdk.start_transaction( - transaction, - custom_sampling_context={"asgi_scope": scope}, + with ( + sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"asgi_scope": scope}, + ) + if transaction is not None + else nullcontext() ): logger.debug("[ASGI] Started transaction: %s", transaction) try: async def _sentry_wrapped_send(event): # type: (Dict[str, Any]) -> Any - is_http_response = ( - event.get("type") == "http.response.start" - and transaction is not None - and "status" in event - ) - if is_http_response: - transaction.set_http_status(event["status"]) + if transaction is not None: + is_http_response = ( + event.get("type") == "http.response.start" + and "status" in event + ) + if is_http_response: + transaction.set_http_status(event["status"]) return await send(event) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 61c5f3e4ff..9d002ce890 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -11,6 +11,7 @@ Integration, _DEFAULT_FAILED_REQUEST_STATUS_CODES, ) +from sentry_sdk.integrations._asgi_common import DEFAULT_HTTP_METHODS_TO_CAPTURE from sentry_sdk.integrations._wsgi_common import ( HttpCodeRangeContainer, _is_json_content_type, @@ -85,6 +86,7 @@ def __init__( transaction_style="url", # type: str failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Union[Set[int], list[HttpStatusCodeRange], None] middleware_spans=True, # type: bool + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...] ): # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: @@ -94,6 +96,7 @@ def __init__( ) self.transaction_style = transaction_style self.middleware_spans = middleware_spans + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) if isinstance(failed_request_status_codes, Set): self.failed_request_status_codes = ( @@ -390,6 +393,11 @@ async def _sentry_patched_asgi_app(self, scope, receive, send): mechanism_type=StarletteIntegration.identifier, transaction_style=integration.transaction_style, span_origin=StarletteIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), ) middleware.__call__ = middleware._run_asgi3 diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 0603455186..93d048c029 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -1,10 +1,11 @@ import json import logging +import pytest import threading import warnings from unittest import mock -import pytest +import fastapi from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from fastapi.middleware.trustedhost import TrustedHostMiddleware @@ -13,6 +14,10 @@ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.starlette import StarletteIntegration +from sentry_sdk.utils import parse_version + + +FASTAPI_VERSION = parse_version(fastapi.__version__) from tests.integrations.starlette import test_starlette @@ -31,6 +36,17 @@ async def _message(): capture_message("Hi") return {"message": "Hi"} + @app.delete("/nomessage") + @app.get("/nomessage") + @app.head("/nomessage") + @app.options("/nomessage") + @app.patch("/nomessage") + @app.post("/nomessage") + @app.put("/nomessage") + @app.trace("/nomessage") + async def _nomessage(): + return {"message": "nothing here..."} + @app.get("/message/{message_id}") async def _message_with_id(message_id): capture_message("Hi") @@ -548,6 +564,84 @@ async def _error(): assert not events +@pytest.mark.skipif( + FASTAPI_VERSION < (0, 80), + reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_default(sentry_init, capture_events): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + # FastAPI is heavily based on Starlette so we also need + # to enable StarletteIntegration. + # In the future this will be auto enabled. + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration(), + FastApiIntegration(), + ], + ) + + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 1 + + (event,) = events + + assert event["request"]["method"] == "GET" + + +@pytest.mark.skipif( + FASTAPI_VERSION < (0, 80), + reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_custom(sentry_init, capture_events): + # FastAPI is heavily based on Starlette so we also need + # to enable StarletteIntegration. + # In the future this will be auto enabled. + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + FastApiIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + ], + ) + + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 2 + + (event1, event2) = events + + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" + + @test_starlette.parametrize_test_configurable_status_codes def test_configurable_status_codes( sentry_init, diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 097ecbdcf7..1ba9eb7589 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -113,6 +113,9 @@ async def _message(request): capture_message("hi") return starlette.responses.JSONResponse({"status": "ok"}) + async def _nomessage(request): + return starlette.responses.JSONResponse({"status": "ok"}) + async def _message_with_id(request): capture_message("hi") return starlette.responses.JSONResponse({"status": "ok"}) @@ -142,12 +145,25 @@ async def _render_template(request): } return templates.TemplateResponse("trace_meta.html", template_context) + all_methods = [ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", + ] + app = starlette.applications.Starlette( debug=debug, routes=[ starlette.routing.Route("/some_url", _homepage), starlette.routing.Route("/custom_error", _custom_error), starlette.routing.Route("/message", _message), + starlette.routing.Route("/nomessage", _nomessage, methods=all_methods), starlette.routing.Route("/message/{message_id}", _message_with_id), starlette.routing.Route("/sync/thread_ids", _thread_ids_sync), starlette.routing.Route("/async/thread_ids", _thread_ids_async), @@ -1210,6 +1226,70 @@ async def _error(request): assert not events +@pytest.mark.skipif( + STARLETTE_VERSION < (0, 21), + reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_default(sentry_init, capture_events): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration(), + ], + ) + events = capture_events() + + starlette_app = starlette_app_factory() + + client = TestClient(starlette_app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 1 + + (event,) = events + + assert event["request"]["method"] == "GET" + + +@pytest.mark.skipif( + STARLETTE_VERSION < (0, 21), + reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_custom(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + ], + debug=True, + ) + events = capture_events() + + starlette_app = starlette_app_factory() + + client = TestClient(starlette_app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 2 + + (event1, event2) = events + + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" + + parametrize_test_configurable_status_codes = pytest.mark.parametrize( ("failed_request_status_codes", "status_code", "expected_error"), (