From 44b0244156e1f332a8f173f337713dab99462609 Mon Sep 17 00:00:00 2001 From: Ivana Kellyerova Date: Tue, 14 Nov 2023 13:52:59 +0100 Subject: [PATCH 1/9] feat(integrations): Support Django 5.0 (#2490) Fix the way we wrap signal receivers: Django 5.0 introduced async receivers and changed the signature of the `Signal._live_receivers` method to return both sync and async receivers. We'll need to change the Django version in tox.ini to 5.0 once it's been released. At the moment we're using the 5.0b1 release. --- sentry_sdk/integrations/django/asgi.py | 21 +++++------- .../integrations/django/signals_handlers.py | 33 +++++++++++++------ tests/integrations/django/asgi/test_asgi.py | 11 +++++++ tox.ini | 17 ++++++---- 4 files changed, 52 insertions(+), 30 deletions(-) diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index 48b27c50c8..bd785a23c2 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -8,6 +8,8 @@ import asyncio +from django.core.handlers.wsgi import WSGIRequest + from sentry_sdk import Hub, _functools from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP @@ -16,26 +18,21 @@ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.utils import capture_internal_exceptions -from django.core.handlers.wsgi import WSGIRequest - if TYPE_CHECKING: - from typing import Any - from typing import Dict - from typing import Union - from typing import Callable + from collections.abc import Callable + from typing import Any, Union from django.core.handlers.asgi import ASGIRequest from django.http.response import HttpResponse - from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk._types import EventProcessor -def _make_asgi_request_event_processor(request, integration): - # type: (ASGIRequest, DjangoIntegration) -> EventProcessor +def _make_asgi_request_event_processor(request): + # type: (ASGIRequest) -> EventProcessor def asgi_request_event_processor(event, hint): - # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + # type: (dict[str, Any], dict[str, Any]) -> dict[str, Any] # if the request is gone we are fine not logging the data from # it. This might happen if the processor is pushed away to # another thread. @@ -103,9 +100,7 @@ def sentry_patched_create_request(self, *args, **kwargs): # (otherwise Django closes the body stream and makes it impossible to read it again) _ = request.body - scope.add_event_processor( - _make_asgi_request_event_processor(request, integration) - ) + scope.add_event_processor(_make_asgi_request_event_processor(request)) return request, error_response diff --git a/sentry_sdk/integrations/django/signals_handlers.py b/sentry_sdk/integrations/django/signals_handlers.py index 87b6b22ff8..097a56c8aa 100644 --- a/sentry_sdk/integrations/django/signals_handlers.py +++ b/sentry_sdk/integrations/django/signals_handlers.py @@ -7,12 +7,12 @@ from sentry_sdk._functools import wraps from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP +from sentry_sdk.integrations.django import DJANGO_VERSION if TYPE_CHECKING: - from typing import Any - from typing import Callable - from typing import List + from collections.abc import Callable + from typing import Any, Union def _get_receiver_name(receiver): @@ -42,17 +42,27 @@ def _get_receiver_name(receiver): def patch_signals(): # type: () -> None - """Patch django signal receivers to create a span""" + """ + Patch django signal receivers to create a span. + + This only wraps sync receivers. Django>=5.0 introduced async receivers, but + since we don't create transactions for ASGI Django, we don't wrap them. + """ from sentry_sdk.integrations.django import DjangoIntegration old_live_receivers = Signal._live_receivers def _sentry_live_receivers(self, sender): - # type: (Signal, Any) -> List[Callable[..., Any]] + # type: (Signal, Any) -> Union[tuple[list[Callable[..., Any]], list[Callable[..., Any]]], list[Callable[..., Any]]] hub = Hub.current - receivers = old_live_receivers(self, sender) - def sentry_receiver_wrapper(receiver): + if DJANGO_VERSION >= (5, 0): + sync_receivers, async_receivers = old_live_receivers(self, sender) + else: + sync_receivers = old_live_receivers(self, sender) + async_receivers = [] + + def sentry_sync_receiver_wrapper(receiver): # type: (Callable[..., Any]) -> Callable[..., Any] @wraps(receiver) def wrapper(*args, **kwargs): @@ -69,9 +79,12 @@ def wrapper(*args, **kwargs): integration = hub.get_integration(DjangoIntegration) if integration and integration.signals_spans: - for idx, receiver in enumerate(receivers): - receivers[idx] = sentry_receiver_wrapper(receiver) + for idx, receiver in enumerate(sync_receivers): + sync_receivers[idx] = sentry_sync_receiver_wrapper(receiver) - return receivers + if DJANGO_VERSION >= (5, 0): + return sync_receivers, async_receivers + else: + return sync_receivers Signal._live_receivers = _sentry_live_receivers diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index 57145b698d..c7f5f1dfd9 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -26,6 +26,7 @@ @pytest.mark.parametrize("application", APPS) @pytest.mark.asyncio +@pytest.mark.forked async def test_basic(sentry_init, capture_events, application): sentry_init(integrations=[DjangoIntegration()], send_default_pii=True) @@ -58,6 +59,7 @@ async def test_basic(sentry_init, capture_events, application): @pytest.mark.parametrize("application", APPS) @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -85,6 +87,7 @@ async def test_async_views(sentry_init, capture_events, application): @pytest.mark.parametrize("application", APPS) @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -119,6 +122,7 @@ async def test_active_thread_id(sentry_init, capture_envelopes, endpoint, applic @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -152,6 +156,7 @@ async def test_async_views_concurrent_execution(sentry_init, settings): @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -189,6 +194,7 @@ async def test_async_middleware_that_is_function_concurrent_execution( @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -238,6 +244,7 @@ async def test_async_middleware_spans( @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -267,6 +274,7 @@ async def test_has_trace_if_performance_enabled(sentry_init, capture_events): @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -293,6 +301,7 @@ async def test_has_trace_if_performance_disabled(sentry_init, capture_events): @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -328,6 +337,7 @@ async def test_trace_from_headers_if_performance_enabled(sentry_init, capture_ev @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) @@ -373,6 +383,7 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e ], ) @pytest.mark.asyncio +@pytest.mark.forked @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) diff --git a/tox.ini b/tox.ini index d5e0d753a9..072b561b07 100644 --- a/tox.ini +++ b/tox.ini @@ -79,6 +79,8 @@ envlist = {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{3.2} # - Django 4.x {py3.8,py3.9,py3.10,py3.11,py3.12}-django-v{4.0,4.1,4.2} + # - Django 5.x + {py3.10,py3.11,py3.12}-django-v{5.0} # Falcon {py2.7,py3.5,py3.6,py3.7}-falcon-v{1.4} @@ -288,17 +290,16 @@ deps = django: Werkzeug<2.1.0 django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0 - {py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2,4.0,4.1,4.2}: pytest-asyncio - {py3.7,py3.8,py3.9,py3.10,py3.11}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2,4.0,4.1,4.2}: channels[daphne]>2 + {py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2,4.0,4.1,4.2,5.0}: pytest-asyncio + {py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2,4.0,4.1,4.2,5.0}: channels[daphne]>2 django-v{1.8,1.9,1.10,1.11,2.0,2.1}: pytest-django<4.0 django-v{2.2,3.0,3.1,3.2}: pytest-django>=4.0 django-v{2.2,3.0,3.1,3.2}: Werkzeug<2.0 - - django-v{4.0,4.1,4.2}: djangorestframework - django-v{4.0,4.1,4.2}: pytest-asyncio - django-v{4.0,4.1,4.2}: pytest-django - django-v{4.0,4.1,4.2}: Werkzeug + django-v{4.0,4.1,4.2,5.0}: djangorestframework + django-v{4.0,4.1,4.2,5.0}: pytest-asyncio + django-v{4.0,4.1,4.2,5.0}: pytest-django + django-v{4.0,4.1,4.2,5.0}: Werkzeug django-v1.8: Django>=1.8,<1.9 django-v1.9: Django>=1.9,<1.10 @@ -313,6 +314,8 @@ deps = django-v4.0: Django>=4.0,<4.1 django-v4.1: Django>=4.1,<4.2 django-v4.2: Django>=4.2,<4.3 + # TODO: change to final when available + django-v5.0: Django==5.0b1 # Falcon falcon-v1.4: falcon>=1.4,<1.5 From 5a6b5d4e4ad76f553d6d3e4362742dfbb85fe72c Mon Sep 17 00:00:00 2001 From: Ivana Kellyerova Date: Wed, 15 Nov 2023 10:48:10 +0100 Subject: [PATCH 2/9] Test with Flask 3.0 (#2506) - run test suite with Flask 3.0 - fix `request.get_json()` in the tests (Flask/Werkzeug 3.0 now throws an `UnsupportedMediaType` exception if the `Content-Type` isn't `application/json`) --- tests/integrations/flask/test_flask.py | 40 ++++++++++++++++++++------ tox.ini | 5 +++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 09b2c2fb30..3d3572e2d3 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -1,10 +1,9 @@ import json import re -import pytest import logging - from io import BytesIO +import pytest from flask import ( Flask, Response, @@ -14,9 +13,14 @@ render_template_string, ) from flask.views import View - from flask_login import LoginManager, login_user +try: + from werkzeug.wrappers.request import UnsupportedMediaType +except ImportError: + UnsupportedMediaType = None + +import sentry_sdk.integrations.flask as flask_sentry from sentry_sdk import ( set_tag, configure_scope, @@ -26,7 +30,6 @@ Hub, ) from sentry_sdk.integrations.logging import LoggingIntegration -import sentry_sdk.integrations.flask as flask_sentry from sentry_sdk.serializer import MAX_DATABAG_BREADTH @@ -340,7 +343,11 @@ def test_flask_medium_formdata_request(sentry_init, capture_events, app): def index(): assert request.form["foo"] == data["foo"] assert not request.get_data() - assert not request.get_json() + try: + assert not request.get_json() + except UnsupportedMediaType: + # flask/werkzeug 3 + pass capture_message("hi") return "ok" @@ -372,7 +379,11 @@ def index(): assert request.form["username"] == data["username"] assert request.form["age"] == data["age"] assert not request.get_data() - assert not request.get_json() + try: + assert not request.get_json() + except UnsupportedMediaType: + # flask/werkzeug 3 + pass set_tag("view", "yes") capture_message("hi") return "ok" @@ -405,7 +416,11 @@ def index(): assert request.get_data() == data else: assert request.get_data() == data.encode("ascii") - assert not request.get_json() + try: + assert not request.get_json() + except UnsupportedMediaType: + # flask/werkzeug 3 + pass capture_message("hi") return "ok" @@ -431,7 +446,11 @@ def test_flask_files_and_form(sentry_init, capture_events, app): def index(): assert list(request.form) == ["foo"] assert list(request.files) == ["file"] - assert not request.get_json() + try: + assert not request.get_json() + except UnsupportedMediaType: + # flask/werkzeug 3 + pass capture_message("hi") return "ok" @@ -545,9 +564,12 @@ def test_cli_commands_raise(app): def foo(): 1 / 0 + def create_app(*_): + return app + with pytest.raises(ZeroDivisionError): app.cli.main( - args=["foo"], prog_name="myapp", obj=ScriptInfo(create_app=lambda _: app) + args=["foo"], prog_name="myapp", obj=ScriptInfo(create_app=create_app) ) diff --git a/tox.ini b/tox.ini index 072b561b07..c38d60332c 100644 --- a/tox.ini +++ b/tox.ini @@ -95,6 +95,7 @@ envlist = {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9}-flask-v{0.11,0.12,1.0} {py2.7,py3.5,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-flask-v{1.1} {py3.6,py3.8,py3.9,py3.10,py3.11,py3.12}-flask-v{2.0} + {py3.10,py3.11,py3.12}-flask-v{3.0} # Gevent {py2.7,py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent @@ -333,12 +334,14 @@ deps = # Flask flask: flask-login - flask: Werkzeug<2.1.0 + flask-v{0.11,0.12,1.0,1.1,2.0}: Werkzeug<2.1.0 + flask-v{3.0}: Werkzeug flask-v0.11: Flask>=0.11,<0.12 flask-v0.12: Flask>=0.12,<0.13 flask-v1.0: Flask>=1.0,<1.1 flask-v1.1: Flask>=1.1,<1.2 flask-v2.0: Flask>=2.0,<2.1 + flask-v3.0: Flask>=3.0,<3.1 # Gevent # See http://www.gevent.org/install.html#older-versions-of-python From 0c9803a9fb3310103a4ea56f7e0037b2f5bc713d Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 16 Nov 2023 10:03:38 +0100 Subject: [PATCH 3/9] Do not create a span when task is triggered by Celery Beat (#2510) We create a span for submitting a Celery task for execution (when apply_async() is called). In cases where web frameworks are calling apply_async() this is fine, because the web framework created a transaction where the span is attached. When Celery Beat wakes up and is calling apply_async() this is not good, because there is no transaction and then the span ID of the newly created span will be given to the task as parent_span_id leading to orphaned transactions. So in case apply_async() is called by Celery Beat, we do not create a span for submitting the task for execution. --- sentry_sdk/integrations/celery.py | 34 +++++++++++++++++--- tests/integrations/celery/test_celery.py | 40 +++++++++++++++++++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/celery.py b/sentry_sdk/integrations/celery.py index 88c85d1264..51fbad8fcb 100644 --- a/sentry_sdk/integrations/celery.py +++ b/sentry_sdk/integrations/celery.py @@ -30,6 +30,7 @@ from typing import TypeVar from typing import Union + from sentry_sdk.tracing import Span from sentry_sdk._types import EventProcessor, Event, Hint, ExcInfo F = TypeVar("F", bound=Callable[..., Any]) @@ -133,6 +134,16 @@ def _now_seconds_since_epoch(): return time.time() +class NoOpMgr: + def __enter__(self): + # type: () -> None + return None + + def __exit__(self, exc_type, exc_value, traceback): + # type: (Any, Any, Any) -> None + return None + + def _wrap_apply_async(f): # type: (F) -> F @wraps(f) @@ -154,11 +165,26 @@ def apply_async(*args, **kwargs): if not propagate_traces: return f(*args, **kwargs) - with hub.start_span( - op=OP.QUEUE_SUBMIT_CELERY, description=args[0].name - ) as span: + try: + task_started_from_beat = args[1][0] == "BEAT" + except IndexError: + task_started_from_beat = False + + task = args[0] + + span_mgr = ( + hub.start_span(op=OP.QUEUE_SUBMIT_CELERY, description=task.name) + if not task_started_from_beat + else NoOpMgr() + ) # type: Union[Span, NoOpMgr] + + with span_mgr as span: with capture_internal_exceptions(): - headers = dict(hub.iter_trace_propagation_headers(span)) + headers = ( + dict(hub.iter_trace_propagation_headers(span)) + if span is not None + else {} + ) if integration.monitor_beat_tasks: headers.update( { diff --git a/tests/integrations/celery/test_celery.py b/tests/integrations/celery/test_celery.py index ec5574b513..bc2d36a619 100644 --- a/tests/integrations/celery/test_celery.py +++ b/tests/integrations/celery/test_celery.py @@ -3,7 +3,11 @@ import pytest from sentry_sdk import Hub, configure_scope, start_transaction, get_current_span -from sentry_sdk.integrations.celery import CeleryIntegration, _get_headers +from sentry_sdk.integrations.celery import ( + CeleryIntegration, + _get_headers, + _wrap_apply_async, +) from sentry_sdk._compat import text_type @@ -555,3 +559,37 @@ def dummy_task(self, message): headers={"sentry-propagate-traces": False}, ).get() assert transaction_trace_id != task_transaction_id + + +def test_apply_async_manually_span(sentry_init): + sentry_init( + integrations=[CeleryIntegration()], + ) + + def dummy_function(*args, **kwargs): + headers = kwargs.get("headers") + assert "sentry-trace" in headers + assert "baggage" in headers + + wrapped = _wrap_apply_async(dummy_function) + wrapped(mock.MagicMock(), (), headers={}) + + +def test_apply_async_from_beat_no_span(sentry_init): + sentry_init( + integrations=[CeleryIntegration()], + ) + + def dummy_function(*args, **kwargs): + headers = kwargs.get("headers") + assert "sentry-trace" not in headers + assert "baggage" not in headers + + wrapped = _wrap_apply_async(dummy_function) + wrapped( + mock.MagicMock(), + [ + "BEAT", + ], + headers={}, + ) From 9bf6c1329471329454a65434c4566bef3fbb212c Mon Sep 17 00:00:00 2001 From: Jonas Stendahl Date: Fri, 17 Nov 2023 09:34:42 +0100 Subject: [PATCH 4/9] Make async gRPC less noisy (#2507) --- sentry_sdk/integrations/grpc/aio/server.py | 4 +++- tests/integrations/grpc/test_grpc_aio.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/grpc/aio/server.py b/sentry_sdk/integrations/grpc/aio/server.py index 56d21a90a1..ba19eb947c 100644 --- a/sentry_sdk/integrations/grpc/aio/server.py +++ b/sentry_sdk/integrations/grpc/aio/server.py @@ -13,7 +13,7 @@ try: import grpc from grpc import HandlerCallDetails, RpcMethodHandler - from grpc.aio import ServicerContext + from grpc.aio import AbortError, ServicerContext except ImportError: raise DidNotEnable("grpcio is not installed") @@ -52,6 +52,8 @@ async def wrapped(request, context): with hub.start_transaction(transaction=transaction): try: return await handler.unary_unary(request, context) + except AbortError: + raise except Exception as exc: event, hint = event_from_exception( exc, diff --git a/tests/integrations/grpc/test_grpc_aio.py b/tests/integrations/grpc/test_grpc_aio.py index d5a716bb4b..0b8571adca 100644 --- a/tests/integrations/grpc/test_grpc_aio.py +++ b/tests/integrations/grpc/test_grpc_aio.py @@ -124,6 +124,21 @@ async def test_grpc_server_exception(capture_events, grpc_server): assert event["exception"]["values"][0]["mechanism"]["type"] == "grpc" +@pytest.mark.asyncio +async def test_grpc_server_abort(capture_events, grpc_server): + events = capture_events() + + async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: + stub = gRPCTestServiceStub(channel) + try: + await stub.TestServe(gRPCTestMessage(text="abort")) + raise AssertionError() + except Exception: + pass + + assert len(events) == 1 + + @pytest.mark.asyncio async def test_grpc_client_starts_span( grpc_server, sentry_init, capture_events_forksafe @@ -218,6 +233,9 @@ async def TestServe(cls, request, context): # noqa: N802 if request.text == "exception": raise cls.TestException() + if request.text == "abort": + await context.abort(grpc.StatusCode.ABORTED) + return gRPCTestMessage(text=request.text) @classmethod From b3ccf96715a8634759289161e9f97ecae27030c0 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Fri, 17 Nov 2023 20:24:20 -0500 Subject: [PATCH 5/9] Ensure `RedisIntegration` is disabled, unless `redis` is installed (#2504) * Add test to ensure redis integration disabled unless installed * Integrations added to enabled list if actually installed * Move test to test_basics.py * Code review suggestions * Fixed test failures * Add unit test to check multiple `setup_integrations` calls * fix type hint for 2.7 * Added staticmethod * Move test to `test_basics` --- sentry_sdk/integrations/__init__.py | 17 +++++++-- tests/conftest.py | 6 ++-- tests/test_basics.py | 56 +++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 0fe958d217..21f7188ff1 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -16,6 +16,11 @@ _installer_lock = Lock() + +# Set of all integration identifiers we have attempted to install +_processed_integrations = set() # type: Set[str] + +# Set of all integration identifiers we have actually installed _installed_integrations = set() # type: Set[str] @@ -121,7 +126,7 @@ def setup_integrations( for identifier, integration in iteritems(integrations): with _installer_lock: - if identifier not in _installed_integrations: + if identifier not in _processed_integrations: logger.debug( "Setting up previously not enabled integration %s", identifier ) @@ -144,8 +149,16 @@ def setup_integrations( logger.debug( "Did not enable default integration %s: %s", identifier, e ) + else: + _installed_integrations.add(identifier) + + _processed_integrations.add(identifier) - _installed_integrations.add(identifier) + integrations = { + identifier: integration + for identifier, integration in iteritems(integrations) + if identifier in _installed_integrations + } for identifier in integrations: logger.debug("Enabling integration %s", identifier) diff --git a/tests/conftest.py b/tests/conftest.py index d9d88067dc..5b0f1a8493 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,7 +30,7 @@ import sentry_sdk from sentry_sdk._compat import iteritems, reraise, string_types from sentry_sdk.envelope import Envelope -from sentry_sdk.integrations import _installed_integrations # noqa: F401 +from sentry_sdk.integrations import _processed_integrations # noqa: F401 from sentry_sdk.profiler import teardown_profiler from sentry_sdk.transport import Transport from sentry_sdk.utils import capture_internal_exceptions @@ -187,8 +187,8 @@ def reset_integrations(): with a clean slate to ensure monkeypatching works well, but this also means some other stuff will be monkeypatched twice. """ - global _installed_integrations - _installed_integrations.clear() + global _processed_integrations + _processed_integrations.clear() @pytest.fixture diff --git a/tests/test_basics.py b/tests/test_basics.py index b2b8846eb9..2c2dcede3f 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -18,8 +18,13 @@ Hub, ) from sentry_sdk._compat import reraise -from sentry_sdk.integrations import _AUTO_ENABLING_INTEGRATIONS +from sentry_sdk.integrations import ( + _AUTO_ENABLING_INTEGRATIONS, + Integration, + setup_integrations, +) from sentry_sdk.integrations.logging import LoggingIntegration +from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.scope import ( # noqa: F401 add_global_event_processor, global_event_processors, @@ -28,6 +33,36 @@ from sentry_sdk.tracing_utils import has_tracing_enabled +def _redis_installed(): # type: () -> bool + """ + Determines whether Redis is installed. + """ + try: + import redis # noqa: F401 + except ImportError: + return False + + return True + + +class NoOpIntegration(Integration): + """ + A simple no-op integration for testing purposes. + """ + + identifier = "noop" + + @staticmethod + def setup_once(): # type: () -> None + pass + + def __eq__(self, __value): # type: (object) -> bool + """ + All instances of NoOpIntegration should be considered equal to each other. + """ + return type(__value) == type(self) + + def test_processors(sentry_init, capture_events): sentry_init() events = capture_events() @@ -59,8 +94,8 @@ def test_auto_enabling_integrations_catches_import_error(sentry_init, caplog): sentry_init(auto_enabling_integrations=True, debug=True) for import_string in _AUTO_ENABLING_INTEGRATIONS: - # Ignore redis in the test case, because it is installed as a - # dependency for running tests, and therefore always enabled. + # Ignore redis in the test case, because it does not raise a DidNotEnable + # exception on import; rather, it raises the exception upon enabling. if _AUTO_ENABLING_INTEGRATIONS[redis_index] == import_string: continue @@ -686,3 +721,18 @@ def test_functions_to_trace_with_class(sentry_init, capture_events): assert len(event["spans"]) == 2 assert event["spans"][0]["description"] == "tests.test_basics.WorldGreeter.greet" assert event["spans"][1]["description"] == "tests.test_basics.WorldGreeter.greet" + + +@pytest.mark.skipif(_redis_installed(), reason="skipping because redis is installed") +def test_redis_disabled_when_not_installed(sentry_init): + sentry_init() + + assert Hub.current.get_integration(RedisIntegration) is None + + +def test_multiple_setup_integrations_calls(): + first_call_return = setup_integrations([NoOpIntegration()], with_defaults=False) + assert first_call_return == {NoOpIntegration.identifier: NoOpIntegration()} + + second_call_return = setup_integrations([NoOpIntegration()], with_defaults=False) + assert second_call_return == {NoOpIntegration.identifier: NoOpIntegration()} From 5c17491a45363eb0c408eb4d3ada3a93098dfa82 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Mon, 20 Nov 2023 11:43:13 +0100 Subject: [PATCH 6/9] Fix Quart integration for Quart 0.19.4 (#2516) * is_coroutine_function was removed from quart, taking from asyncio directly --- sentry_sdk/integrations/quart.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 38420ec795..4dee751d65 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +import asyncio import inspect import threading @@ -45,7 +46,6 @@ request_started, websocket_started, ) - from quart.utils import is_coroutine_function # type: ignore except ImportError: raise DidNotEnable("Quart is not installed") else: @@ -113,7 +113,9 @@ def _sentry_route(*args, **kwargs): def decorator(old_func): # type: (Any) -> Any - if inspect.isfunction(old_func) and not is_coroutine_function(old_func): + if inspect.isfunction(old_func) and not asyncio.iscoroutinefunction( + old_func + ): @wraps(old_func) def _sentry_func(*args, **kwargs): From 91676ecbb9fa0584b4c7484e584bfe81de711903 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Mon, 20 Nov 2023 12:34:15 +0100 Subject: [PATCH 7/9] Handling asgi body in the right way. For real (#2513) Handling the request body in ASGI applications. By reading the body first it gets cached (by for example Django) which makes it possible to read the body multiple times. --- sentry_sdk/integrations/_wsgi_common.py | 22 +++- sentry_sdk/integrations/django/asgi.py | 6 - sentry_sdk/integrations/django/views.py | 6 +- tests/integrations/django/asgi/image.png | Bin 0 -> 308 bytes tests/integrations/django/asgi/test_asgi.py | 127 ++++++++++++++++++-- tests/integrations/django/myapp/views.py | 6 +- 6 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 tests/integrations/django/asgi/image.png diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 585abe25de..5a41654498 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import json from copy import deepcopy @@ -7,6 +9,12 @@ from sentry_sdk._types import TYPE_CHECKING +try: + from django.http.request import RawPostDataException +except ImportError: + RawPostDataException = None + + if TYPE_CHECKING: import sentry_sdk @@ -67,10 +75,22 @@ def extract_into_event(self, event): if not request_body_within_bounds(client, content_length): data = AnnotatedValue.removed_because_over_size_limit() else: + # First read the raw body data + # It is important to read this first because if it is Django + # it will cache the body and then we can read the cached version + # again in parsed_body() (or json() or wherever). + raw_data = None + try: + raw_data = self.raw_data() + except (RawPostDataException, ValueError): + # If DjangoRestFramework is used it already read the body for us + # so reading it here will fail. We can ignore this. + pass + parsed_body = self.parsed_body() if parsed_body is not None: data = parsed_body - elif self.raw_data(): + elif raw_data: data = AnnotatedValue.removed_because_raw_data() else: data = None diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index bd785a23c2..18f6a58811 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -94,12 +94,6 @@ def sentry_patched_create_request(self, *args, **kwargs): with hub.configure_scope() as scope: request, error_response = old_create_request(self, *args, **kwargs) - - # read the body once, to signal Django to cache the body stream - # so we can read the body in our event processor - # (otherwise Django closes the body stream and makes it impossible to read it again) - _ = request.body - scope.add_event_processor(_make_asgi_request_event_processor(request)) return request, error_response diff --git a/sentry_sdk/integrations/django/views.py b/sentry_sdk/integrations/django/views.py index c1034d0d85..d918afad66 100644 --- a/sentry_sdk/integrations/django/views.py +++ b/sentry_sdk/integrations/django/views.py @@ -47,13 +47,13 @@ def sentry_patched_make_view_atomic(self, *args, **kwargs): hub = Hub.current integration = hub.get_integration(DjangoIntegration) - if integration is not None and integration.middleware_spans: - if ( + is_async_view = ( iscoroutinefunction is not None and wrap_async_view is not None and iscoroutinefunction(callback) - ): + ) + if is_async_view: sentry_wrapped_callback = wrap_async_view(hub, callback) else: sentry_wrapped_callback = _wrap_sync_view(hub, callback) diff --git a/tests/integrations/django/asgi/image.png b/tests/integrations/django/asgi/image.png new file mode 100644 index 0000000000000000000000000000000000000000..8db277a9fc653b30dd5f1598b353653b55454d6e GIT binary patch literal 308 zcmV-40n7f0P)@bR~YD@IZ1@1DmneO@gCFE1BE zW>PbR&BqN^3|IIJXwvg%uNnG*R@b#;GTgfP0Bm|{RtQ2NND;^cBU4R=$QUmMkUP64 z7BQ6O_W?C!Fh~KN0X7jN0kRI{u3I-AGN@_DgH1Cs)nZt&WIIq(F$3ex>ks}*N{KKu z)y`l@%?tsX1~R3oW(LEI`Lzs', + "", + ), + ( + True, + "POST", + [ + (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"), + (b"content-length", BODY_FORM_CONTENT_LENGTH), + ], + "post_echo_async", + BODY_FORM, + {"password": "hello123", "photo": "", "username": "Jane"}, + ), + ( + False, + "POST", + [(b"content-type", b"text/plain")], + "post_echo_async", + b"", + None, + ), + ( + False, + "POST", + [(b"content-type", b"text/plain")], + "post_echo_async", + b"some raw text body", + "", + ), + ( + False, + "POST", + [(b"content-type", b"application/json")], + "post_echo_async", + b'{"username":"xyz","password":"xyz"}', + {"username": "xyz", "password": "[Filtered]"}, + ), + ( + False, + "POST", + [(b"content-type", b"application/xml")], + "post_echo_async", + b'', + "", + ), + ( + False, + "POST", + [ + (b"content-type", b"multipart/form-data; boundary=fd721ef49ea403a6"), + (b"content-length", BODY_FORM_CONTENT_LENGTH), + ], + "post_echo_async", + BODY_FORM, + {"password": "[Filtered]", "photo": "", "username": "Jane"}, + ), ], ) @pytest.mark.asyncio @@ -388,28 +479,42 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) async def test_asgi_request_body( - sentry_init, capture_envelopes, application, body, expected_return_data + sentry_init, + capture_envelopes, + application, + send_default_pii, + method, + headers, + url_name, + body, + expected_data, ): - sentry_init(integrations=[DjangoIntegration()], send_default_pii=True) + sentry_init( + send_default_pii=send_default_pii, + integrations=[ + DjangoIntegration(), + ], + ) envelopes = capture_envelopes() comm = HttpCommunicator( application, - method="POST", - path=reverse("post_echo_async"), + method=method, + headers=headers, + path=reverse(url_name), body=body, - headers=[(b"content-type", b"application/json")], ) response = await comm.get_response() - assert response["status"] == 200 + + await comm.wait() assert response["body"] == body (envelope,) = envelopes event = envelope.get_event() - if expected_return_data is not None: - assert event["request"]["data"] == expected_return_data + if expected_data is not None: + assert event["request"]["data"] == expected_data else: assert "data" not in event["request"] diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py index 6362adc121..08262b4e8a 100644 --- a/tests/integrations/django/myapp/views.py +++ b/tests/integrations/django/myapp/views.py @@ -237,10 +237,10 @@ def thread_ids_sync(*args, **kwargs): ) exec( - """@csrf_exempt -def post_echo_async(request): + """async def post_echo_async(request): sentry_sdk.capture_message("hi") - return HttpResponse(request.body)""" + return HttpResponse(request.body) +post_echo_async.csrf_exempt = True""" ) else: async_message = None From b9d24646a8a1ae6162ac895a0668f5aaa15460c2 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 21 Nov 2023 09:50:39 +0000 Subject: [PATCH 8/9] release: 1.36.0 --- CHANGELOG.md | 12 ++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71cd22b055..38522250e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.36.0 + +### Various fixes & improvements + +- Handling asgi body in the right way. For real (#2513) by @antonpirker +- Fix Quart integration for Quart 0.19.4 (#2516) by @antonpirker +- Ensure `RedisIntegration` is disabled, unless `redis` is installed (#2504) by @szokeasaurusrex +- Make async gRPC less noisy (#2507) by @jyggen +- Do not create a span when task is triggered by Celery Beat (#2510) by @antonpirker +- Test with Flask 3.0 (#2506) by @sentrivana +- feat(integrations): Support Django 5.0 (#2490) by @sentrivana + ## 1.35.0 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 1d4d611be6..5c21f26ce6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "1.35.0" +release = "1.36.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index bceb9439a0..f51ba52afc 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -286,4 +286,4 @@ def _get_default_options(): del _get_default_options -VERSION = "1.35.0" +VERSION = "1.36.0" diff --git a/setup.py b/setup.py index 1d1089c6ee..62bde9b877 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="1.35.0", + version="1.36.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From 89ba92a377c4667d4b1a8c4fbe4d480765383c29 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Tue, 21 Nov 2023 10:55:23 +0100 Subject: [PATCH 9/9] Updated changelog --- CHANGELOG.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38522250e1..b0c7f92fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,14 @@ ## 1.36.0 -### Various fixes & improvements -- Handling asgi body in the right way. For real (#2513) by @antonpirker -- Fix Quart integration for Quart 0.19.4 (#2516) by @antonpirker -- Ensure `RedisIntegration` is disabled, unless `redis` is installed (#2504) by @szokeasaurusrex -- Make async gRPC less noisy (#2507) by @jyggen -- Do not create a span when task is triggered by Celery Beat (#2510) by @antonpirker -- Test with Flask 3.0 (#2506) by @sentrivana -- feat(integrations): Support Django 5.0 (#2490) by @sentrivana +- Django: Support Django 5.0 (#2490) by @sentrivana +- Django: Handling ASGI body in the right way. (#2513) by @antonpirker +- Flask: Test with Flask 3.0 (#2506) by @sentrivana +- Celery: Do not create a span when task is triggered by Celery Beat (#2510) by @antonpirker +- Redis: Ensure `RedisIntegration` is disabled, unless `redis` is installed (#2504) by @szokeasaurusrex +- Quart: Fix Quart integration for Quart 0.19.4 (#2516) by @antonpirker +- gRPC: Make async gRPC less noisy (#2507) by @jyggen ## 1.35.0