Skip to content

Commit

Permalink
Configure HTTP methods to capture in ASGI middleware and frameworks (#…
Browse files Browse the repository at this point in the history
…3533)

- Do not capture transactions for `OPTIONS` and `HEAD` HTTP methods by default. 
- Make it possible with an `http_methods_to_capture` config option for Starlette and FastAPI, to specify what HTTP methods to capture.

Fixes #3529
  • Loading branch information
antonpirker authored Oct 1, 2024
1 parent 454b9ba commit 8944047
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 44 deletions.
13 changes: 13 additions & 0 deletions sentry_sdk/integrations/_asgi_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
"""
Expand Down
106 changes: 63 additions & 43 deletions sentry_sdk/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import asyncio
from contextlib import contextmanager
import inspect
from copy import deepcopy
from functools import partial
Expand All @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand 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]
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions sentry_sdk/integrations/starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down
96 changes: 95 additions & 1 deletion tests/integrations/fastapi/test_fastapi.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 8944047

Please sign in to comment.