From be64348d60a3843c0c1bfc1446558642637ff66b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:03:08 +0000 Subject: [PATCH 01/13] build(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0 (#3617) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Daniel Szoke --- .github/workflows/test-integrations-ai.yml | 4 ++-- .github/workflows/test-integrations-aws-lambda.yml | 2 +- .github/workflows/test-integrations-cloud-computing.yml | 4 ++-- .github/workflows/test-integrations-common.yml | 2 +- .github/workflows/test-integrations-data-processing.yml | 4 ++-- .github/workflows/test-integrations-databases.yml | 4 ++-- .github/workflows/test-integrations-graphql.yml | 4 ++-- .github/workflows/test-integrations-miscellaneous.yml | 4 ++-- .github/workflows/test-integrations-networking.yml | 4 ++-- .github/workflows/test-integrations-web-frameworks-1.yml | 4 ++-- .github/workflows/test-integrations-web-frameworks-2.yml | 4 ++-- scripts/split-tox-gh-actions/templates/test_group.jinja | 2 +- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 1a9f9a6e1b..03ef169ec9 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -78,7 +78,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -150,7 +150,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-aws-lambda.yml b/.github/workflows/test-integrations-aws-lambda.yml index d1996d288d..b1127421b2 100644 --- a/.github/workflows/test-integrations-aws-lambda.yml +++ b/.github/workflows/test-integrations-aws-lambda.yml @@ -97,7 +97,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-cloud-computing.yml b/.github/workflows/test-integrations-cloud-computing.yml index ecaf412274..e717bc1695 100644 --- a/.github/workflows/test-integrations-cloud-computing.yml +++ b/.github/workflows/test-integrations-cloud-computing.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 03673b8061..d278ba9469 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -62,7 +62,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-data-processing.yml b/.github/workflows/test-integrations-data-processing.yml index f2029df24f..91b00d3337 100644 --- a/.github/workflows/test-integrations-data-processing.yml +++ b/.github/workflows/test-integrations-data-processing.yml @@ -92,7 +92,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -178,7 +178,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-databases.yml b/.github/workflows/test-integrations-databases.yml index 6a9f43eac0..4c96cb57ea 100644 --- a/.github/workflows/test-integrations-databases.yml +++ b/.github/workflows/test-integrations-databases.yml @@ -101,7 +101,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -196,7 +196,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index 3f35caa706..e613432402 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-miscellaneous.yml b/.github/workflows/test-integrations-miscellaneous.yml index 5761fa4434..f64c046cfd 100644 --- a/.github/workflows/test-integrations-miscellaneous.yml +++ b/.github/workflows/test-integrations-miscellaneous.yml @@ -78,7 +78,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -150,7 +150,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-networking.yml b/.github/workflows/test-integrations-networking.yml index 5469cf89a1..6037ec74c4 100644 --- a/.github/workflows/test-integrations-networking.yml +++ b/.github/workflows/test-integrations-networking.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-frameworks-1.yml b/.github/workflows/test-integrations-web-frameworks-1.yml index 0a1e2935fb..e3d065fdde 100644 --- a/.github/workflows/test-integrations-web-frameworks-1.yml +++ b/.github/workflows/test-integrations-web-frameworks-1.yml @@ -92,7 +92,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -178,7 +178,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-frameworks-2.yml b/.github/workflows/test-integrations-web-frameworks-2.yml index c6e2268a43..a03f7dc2dc 100644 --- a/.github/workflows/test-integrations-web-frameworks-2.yml +++ b/.github/workflows/test-integrations-web-frameworks-2.yml @@ -98,7 +98,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -190,7 +190,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split-tox-gh-actions/templates/test_group.jinja index f232fb0bc4..ce3350ae39 100644 --- a/scripts/split-tox-gh-actions/templates/test_group.jinja +++ b/scripts/split-tox-gh-actions/templates/test_group.jinja @@ -92,7 +92,7 @@ - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml From a31c54f86eea23a5dfe8da3ee7dbe366fc7d813d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 7 Oct 2024 13:53:48 +0100 Subject: [PATCH 02/13] fix: Open relevant error when SpotlightMiddleware is on (#3614) This fixes an issue with the recent SpotlightMiddleware patch where the error triggered the page was not highlighted/opened automatically. It changes the semapics of `capture_event` and methods depending on this a bit: we now return the event_id if the error is sent to spotlight even if it was not sent upstream to Sentry servers. --- sentry_sdk/client.py | 19 +++++++++---------- sentry_sdk/spotlight.py | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 9d30bb45f2..b1e7868031 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -753,18 +753,16 @@ def capture_event( :returns: An event ID. May be `None` if there is no DSN set or of if the SDK decided to discard the event for other reasons. In such situations setting `debug=True` on `init()` may help. """ - if hint is None: - hint = {} - event_id = event.get("event_id") hint = dict(hint or ()) # type: Hint - if event_id is None: - event["event_id"] = event_id = uuid.uuid4().hex if not self._should_capture(event, hint, scope): return None profile = event.pop("profile", None) + event_id = event.get("event_id") + if event_id is None: + event["event_id"] = event_id = uuid.uuid4().hex event_opt = self._prepare_event(event, hint, scope) if event_opt is None: return None @@ -812,15 +810,16 @@ def capture_event( for attachment in attachments or (): envelope.add_item(attachment.to_envelope_item()) + return_value = None if self.spotlight: self.spotlight.capture_envelope(envelope) + return_value = event_id - if self.transport is None: - return None - - self.transport.capture_envelope(envelope) + if self.transport is not None: + self.transport.capture_envelope(envelope) + return_value = event_id - return event_id + return return_value def capture_session( self, session # type: Session diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index 3e8072b5d8..e21bf56545 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -79,14 +79,22 @@ def process_exception(self, _request, exception): spotlight_url = spotlight_client.url.rsplit("/", 1)[0] try: - spotlight = ( - urllib.request.urlopen(spotlight_url).read().decode("utf-8") - ).replace("", f'') + spotlight = urllib.request.urlopen(spotlight_url).read().decode("utf-8") except urllib.error.URLError: return None else: - sentry_sdk.api.capture_exception(exception) - return HttpResponseServerError(spotlight) + event_id = sentry_sdk.api.capture_exception(exception) + return HttpResponseServerError( + spotlight.replace( + "", + ( + f'' + ''.format( + event_id=event_id + ) + ), + ) + ) except ImportError: settings = None From 2d2e5488172972498aec5c2eaf8a0ba62937e840 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:03:46 +0200 Subject: [PATCH 03/13] feat: Add `__notes__` support (#3620) * Add support for add_note() * Ignore non-str notes * minor tweaks --------- Co-authored-by: Arjen Nienhuis --- sentry_sdk/utils.py | 14 ++++++++++++-- tests/test_basics.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 44cb98bfed..3c86564ef8 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -713,11 +713,21 @@ def get_errno(exc_value): def get_error_message(exc_value): # type: (Optional[BaseException]) -> str - return ( + message = ( getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value) - ) + ) # type: str + + # __notes__ should be a list of strings when notes are added + # via add_note, but can be anything else if __notes__ is set + # directly. We only support strings in __notes__, since that + # is the correct use. + notes = getattr(exc_value, "__notes__", None) # type: object + if isinstance(notes, list) and len(notes) > 0: + message += "\n" + "\n".join(note for note in notes if isinstance(note, str)) + + return message def single_exception_from_error_tuple( diff --git a/tests/test_basics.py b/tests/test_basics.py index 139f919a68..91addc6219 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -999,3 +999,46 @@ def test_hub_current_deprecation_warning(): def test_hub_main_deprecation_warnings(): with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning): Hub.main + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes(sentry_init, capture_events): + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("Test 123") + e.add_note("another note") + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nTest 123\nanother note" + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes_safe_str(sentry_init, capture_events): + class Note2: + def __repr__(self): + raise TypeError + + def __str__(self): + raise TypeError + + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("note 1") + e.__notes__.append(Note2()) # type: ignore + e.add_note("note 3") + e.__notes__.append(2) # type: ignore + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nnote 1\nnote 3" From 4f79aecf935fcc2c4728ae15368cac9a10687d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20=C4=8Ciha=C5=99?= Date: Tue, 8 Oct 2024 10:22:19 +0200 Subject: [PATCH 04/13] fix(django): improve getting psycopg3 connection info (#3580) Fetch the few needed parameters manually instead of relying on `get_parameters()` which adds visible overhead due to excluding default values for parameters. --- sentry_sdk/integrations/django/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index c9f20dd49b..e68f0cacef 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -717,8 +717,18 @@ def _set_db_data(span, cursor_or_db): connection_params = cursor_or_db.connection.get_dsn_parameters() else: try: - # psycopg3 - connection_params = cursor_or_db.connection.info.get_parameters() + # psycopg3, only extract needed params as get_parameters + # can be slow because of the additional logic to filter out default + # values + connection_params = { + "dbname": cursor_or_db.connection.info.dbname, + "port": cursor_or_db.connection.info.port, + } + # PGhost returns host or base dir of UNIX socket as an absolute path + # starting with /, use it only when it contains host + pg_host = cursor_or_db.connection.info.host + if pg_host and not pg_host.startswith("/"): + connection_params["host"] = pg_host except Exception: connection_params = db.get_connection_params() From d34c99af365bf020af561d47b689da5abbb5c7d7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 8 Oct 2024 09:43:59 +0100 Subject: [PATCH 05/13] feat: Add opportunistic Brotli compression (#3612) Brotli level 4 and 5 offer comparable or better compression to GZip level 9 (which is our default) with better performance. This patch adds opportunistic Brotli compression at level 4 (to be conservative) when it detects the `brotli` module is available. It also provides some escape hatches through `transport_compression_level` and `transport_compression_algo` experiment configs to fine tune the behavior. In the future, we may want to bump the default level from 4 to 5 for better compression. --------- Co-authored-by: Ivana Kellyer --- requirements-testing.txt | 1 + sentry_sdk/consts.py | 7 + sentry_sdk/transport.py | 215 ++++++++++++--------- tests/integrations/aiohttp/test_aiohttp.py | 2 +- tests/test_transport.py | 36 +++- 5 files changed, 167 insertions(+), 94 deletions(-) diff --git a/requirements-testing.txt b/requirements-testing.txt index 0f42d6a7df..dfbd821845 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -13,3 +13,4 @@ pysocks socksio httpcore[http2] setuptools +Brotli diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 9a6c08d0fd..631edd8a83 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -18,6 +18,11 @@ class EndpointType(Enum): ENVELOPE = "envelope" +class CompressionAlgo(Enum): + GZIP = "gzip" + BROTLI = "br" + + if TYPE_CHECKING: import sentry_sdk @@ -59,6 +64,8 @@ class EndpointType(Enum): "continuous_profiling_mode": Optional[ContinuousProfilerMode], "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], + "transport_compression_level": Optional[int], + "transport_compression_algo": Optional[CompressionAlgo], "transport_num_pools": Optional[int], "transport_http2": Optional[bool], "enable_metrics": Optional[bool], diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index 7a6b4f07b8..a43ecabfb6 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -10,6 +10,11 @@ from collections import defaultdict from urllib.request import getproxies +try: + import brotli # type: ignore +except ImportError: + brotli = None + import urllib3 import certifi @@ -30,6 +35,7 @@ from typing import List from typing import Mapping from typing import Optional + from typing import Self from typing import Tuple from typing import Type from typing import Union @@ -62,20 +68,16 @@ class Transport(ABC): parsed_dsn = None # type: Optional[Dsn] - def __init__( - self, options=None # type: Optional[Dict[str, Any]] - ): - # type: (...) -> None + def __init__(self, options=None): + # type: (Self, Optional[Dict[str, Any]]) -> None self.options = options if options and options["dsn"] is not None and options["dsn"]: self.parsed_dsn = Dsn(options["dsn"]) else: self.parsed_dsn = None - def capture_event( - self, event # type: Event - ): - # type: (...) -> None + def capture_event(self, event): + # type: (Self, Event) -> None """ DEPRECATED: Please use capture_envelope instead. @@ -94,25 +96,23 @@ def capture_event( self.capture_envelope(envelope) @abstractmethod - def capture_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None + def capture_envelope(self, envelope): + # type: (Self, Envelope) -> None """ Send an envelope to Sentry. Envelopes are a data container format that can hold any type of data submitted to Sentry. We use it to send all event data (including errors, - transactions, crons checkins, etc.) to Sentry. + transactions, crons check-ins, etc.) to Sentry. """ pass def flush( self, - timeout, # type: float - callback=None, # type: Optional[Any] + timeout, + callback=None, ): - # type: (...) -> None + # type: (Self, float, Optional[Any]) -> None """ Wait `timeout` seconds for the current events to be sent out. @@ -122,7 +122,7 @@ def flush( return None def kill(self): - # type: () -> None + # type: (Self) -> None """ Forcefully kills the transport. @@ -157,11 +157,11 @@ def record_lost_event( return None def is_healthy(self): - # type: () -> bool + # type: (Self) -> bool return True def __del__(self): - # type: () -> None + # type: (Self) -> None try: self.kill() except Exception: @@ -169,16 +169,16 @@ def __del__(self): def _parse_rate_limits(header, now=None): - # type: (Any, Optional[datetime]) -> Iterable[Tuple[Optional[EventDataCategory], datetime]] + # type: (str, Optional[datetime]) -> Iterable[Tuple[Optional[EventDataCategory], datetime]] if now is None: now = datetime.now(timezone.utc) for limit in header.split(","): try: parameters = limit.strip().split(":") - retry_after, categories = parameters[:2] + retry_after_val, categories = parameters[:2] - retry_after = now + timedelta(seconds=int(retry_after)) + retry_after = now + timedelta(seconds=int(retry_after_val)) for category in categories and categories.split(";") or (None,): if category == "metric_bucket": try: @@ -187,10 +187,10 @@ def _parse_rate_limits(header, now=None): namespaces = [] if not namespaces or "custom" in namespaces: - yield category, retry_after + yield category, retry_after # type: ignore else: - yield category, retry_after + yield category, retry_after # type: ignore except (LookupError, ValueError): continue @@ -198,10 +198,8 @@ def _parse_rate_limits(header, now=None): class BaseHttpTransport(Transport): """The base HTTP transport.""" - def __init__( - self, options # type: Dict[str, Any] - ): - # type: (...) -> None + def __init__(self, options): + # type: (Self, Dict[str, Any]) -> None from sentry_sdk.consts import VERSION Transport.__init__(self, options) @@ -217,13 +215,6 @@ def __init__( ) # type: DefaultDict[Tuple[EventDataCategory, str], int] self._last_client_report_sent = time.time() - compression_level = options.get("_experiments", {}).get( - "transport_zlib_compression_level" - ) - self._compression_level = ( - 9 if compression_level is None else int(compression_level) - ) - self._pool = self._make_pool( self.parsed_dsn, http_proxy=options["http_proxy"], @@ -237,6 +228,45 @@ def __init__( # Backwards compatibility for deprecated `self.hub_class` attribute self._hub_cls = sentry_sdk.Hub + experiments = options.get("_experiments", {}) + compression_level = experiments.get( + "transport_compression_level", + experiments.get("transport_zlib_compression_level"), + ) + compression_algo = experiments.get( + "transport_compression_algo", + ( + "gzip" + # if only compression level is set, assume gzip for backwards compatibility + # if we don't have brotli available, fallback to gzip + if compression_level is not None or brotli is None + else "br" + ), + ) + + if compression_algo == "br" and brotli is None: + logger.warning( + "You asked for brotli compression without the Brotli module, falling back to gzip -9" + ) + compression_algo = "gzip" + compression_level = None + + if compression_algo not in ("br", "gzip"): + logger.warning( + "Unknown compression algo %s, disabling compression", compression_algo + ) + self._compression_level = 0 + self._compression_algo = None + else: + self._compression_algo = compression_algo + + if compression_level is not None: + self._compression_level = compression_level + elif self._compression_algo == "gzip": + self._compression_level = 9 + elif self._compression_algo == "br": + self._compression_level = 4 + def record_lost_event( self, reason, # type: str @@ -272,11 +302,11 @@ def record_lost_event( self._discarded_events[data_category, reason] += quantity def _get_header_value(self, response, header): - # type: (Any, str) -> Optional[str] + # type: (Self, Any, str) -> Optional[str] return response.headers.get(header) def _update_rate_limits(self, response): - # type: (Union[urllib3.BaseHTTPResponse, httpcore.Response]) -> None + # type: (Self, Union[urllib3.BaseHTTPResponse, httpcore.Response]) -> None # new sentries with more rate limit insights. We honor this header # no matter of the status code to update our internal rate limits. @@ -302,12 +332,12 @@ def _update_rate_limits(self, response): def _send_request( self, - body, # type: bytes - headers, # type: Dict[str, str] - endpoint_type=EndpointType.ENVELOPE, # type: EndpointType - envelope=None, # type: Optional[Envelope] + body, + headers, + endpoint_type=EndpointType.ENVELOPE, + envelope=None, ): - # type: (...) -> None + # type: (Self, bytes, Dict[str, str], EndpointType, Optional[Envelope]) -> None def record_loss(reason): # type: (str) -> None @@ -357,12 +387,12 @@ def record_loss(reason): finally: response.close() - def on_dropped_event(self, reason): - # type: (str) -> None + def on_dropped_event(self, _reason): + # type: (Self, str) -> None return None def _fetch_pending_client_report(self, force=False, interval=60): - # type: (bool, int) -> Optional[Item] + # type: (Self, bool, int) -> Optional[Item] if not self.options["send_client_reports"]: return None @@ -393,7 +423,7 @@ def _fetch_pending_client_report(self, force=False, interval=60): ) def _flush_client_reports(self, force=False): - # type: (bool) -> None + # type: (Self, bool) -> None client_report = self._fetch_pending_client_report(force=force, interval=60) if client_report is not None: self.capture_envelope(Envelope(items=[client_report])) @@ -414,23 +444,21 @@ def _disabled(bucket): return _disabled(category) or _disabled(None) def _is_rate_limited(self): - # type: () -> bool + # type: (Self) -> bool return any( ts > datetime.now(timezone.utc) for ts in self._disabled_until.values() ) def _is_worker_full(self): - # type: () -> bool + # type: (Self) -> bool return self._worker.full() def is_healthy(self): - # type: () -> bool + # type: (Self) -> bool return not (self._is_worker_full() or self._is_rate_limited()) - def _send_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None + def _send_envelope(self, envelope): + # type: (Self, Envelope) -> None # remove all items from the envelope which are over quota new_items = [] @@ -458,14 +486,7 @@ def _send_envelope( if client_report_item is not None: envelope.items.append(client_report_item) - body = io.BytesIO() - if self._compression_level == 0: - envelope.serialize_into(body) - else: - with gzip.GzipFile( - fileobj=body, mode="w", compresslevel=self._compression_level - ) as f: - envelope.serialize_into(f) + content_encoding, body = self._serialize_envelope(envelope) assert self.parsed_dsn is not None logger.debug( @@ -478,8 +499,8 @@ def _send_envelope( headers = { "Content-Type": "application/x-sentry-envelope", } - if self._compression_level > 0: - headers["Content-Encoding"] = "gzip" + if content_encoding: + headers["Content-Encoding"] = content_encoding self._send_request( body.getvalue(), @@ -489,12 +510,34 @@ def _send_envelope( ) return None + def _serialize_envelope(self, envelope): + # type: (Self, Envelope) -> tuple[Optional[str], io.BytesIO] + content_encoding = None + body = io.BytesIO() + if self._compression_level == 0 or self._compression_algo is None: + envelope.serialize_into(body) + else: + content_encoding = self._compression_algo + if self._compression_algo == "br" and brotli is not None: + body.write( + brotli.compress( + envelope.serialize(), quality=self._compression_level + ) + ) + else: # assume gzip as we sanitize the algo value in init + with gzip.GzipFile( + fileobj=body, mode="w", compresslevel=self._compression_level + ) as f: + envelope.serialize_into(f) + + return content_encoding, body + def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + # type: (Self, Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] raise NotImplementedError() def _in_no_proxy(self, parsed_dsn): - # type: (Dsn) -> bool + # type: (Self, Dsn) -> bool no_proxy = getproxies().get("no") if not no_proxy: return False @@ -524,7 +567,7 @@ def _request( body, headers, ): - # type: (str, EndpointType, Any, Mapping[str, str]) -> Union[urllib3.BaseHTTPResponse, httpcore.Response] + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> Union[urllib3.BaseHTTPResponse, httpcore.Response] raise NotImplementedError() def capture_envelope( @@ -544,10 +587,10 @@ def send_envelope_wrapper(): def flush( self, - timeout, # type: float - callback=None, # type: Optional[Any] + timeout, + callback=None, ): - # type: (...) -> None + # type: (Self, float, Optional[Callable[[int, float], None]]) -> None logger.debug("Flushing HTTP transport") if timeout > 0: @@ -555,7 +598,7 @@ def flush( self._worker.flush(timeout, callback) def kill(self): - # type: () -> None + # type: (Self) -> None logger.debug("Killing HTTP transport") self._worker.kill() @@ -571,14 +614,14 @@ def _warn_hub_cls(): @property def hub_cls(self): - # type: () -> type[sentry_sdk.Hub] + # type: (Self) -> type[sentry_sdk.Hub] """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" HttpTransport._warn_hub_cls() return self._hub_cls @hub_cls.setter def hub_cls(self, value): - # type: (type[sentry_sdk.Hub]) -> None + # type: (Self, type[sentry_sdk.Hub]) -> None """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" HttpTransport._warn_hub_cls() self._hub_cls = value @@ -589,7 +632,7 @@ class HttpTransport(BaseHttpTransport): _pool: Union[PoolManager, ProxyManager] def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + # type: (Self, Any, Any, Any) -> Dict[str, Any] num_pools = self.options.get("_experiments", {}).get("transport_num_pools") options = { @@ -631,9 +674,9 @@ def _make_pool( parsed_dsn, # type: Dsn http_proxy, # type: Optional[str] https_proxy, # type: Optional[str] - ca_certs, # type: Optional[Any] - cert_file, # type: Optional[Any] - key_file, # type: Optional[Any] + ca_certs, # type: Any + cert_file, # type: Any + key_file, # type: Any proxy_headers, # type: Optional[Dict[str, str]] ): # type: (...) -> Union[PoolManager, ProxyManager] @@ -682,7 +725,7 @@ def _request( body, headers, ): - # type: (str, EndpointType, Any, Mapping[str, str]) -> urllib3.BaseHTTPResponse + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> urllib3.BaseHTTPResponse return self._pool.request( method, self._auth.get_api_url(endpoint_type), @@ -696,10 +739,8 @@ def _request( except ImportError: # Sorry, no Http2Transport for you class Http2Transport(HttpTransport): - def __init__( - self, options # type: Dict[str, Any] - ): - # type: (...) -> None + def __init__(self, options): + # type: (Self, Dict[str, Any]) -> None super().__init__(options) logger.warning( "You tried to use HTTP2Transport but don't have httpcore[http2] installed. Falling back to HTTPTransport." @@ -716,7 +757,7 @@ class Http2Transport(BaseHttpTransport): # type: ignore ] def _get_header_value(self, response, header): - # type: (httpcore.Response, str) -> Optional[str] + # type: (Self, httpcore.Response, str) -> Optional[str] return next( ( val.decode("ascii") @@ -733,7 +774,7 @@ def _request( body, headers, ): - # type: (str, EndpointType, Any, Mapping[str, str]) -> httpcore.Response + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> httpcore.Response response = self._pool.request( method, self._auth.get_api_url(endpoint_type), @@ -743,7 +784,7 @@ def _request( return response def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + # type: (Any, Any, Any) -> Dict[str, Any] options = { "http2": True, "retries": 3, @@ -783,9 +824,9 @@ def _make_pool( parsed_dsn, # type: Dsn http_proxy, # type: Optional[str] https_proxy, # type: Optional[str] - ca_certs, # type: Optional[Any] - cert_file, # type: Optional[Any] - key_file, # type: Optional[Any] + ca_certs, # type: Any + cert_file, # type: Any + key_file, # type: Any proxy_headers, # type: Optional[Dict[str, str]] ): # type: (...) -> Union[httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool] diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 5b25629a83..cd65e7cdd5 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -55,7 +55,7 @@ async def hello(request): assert request["url"] == "http://{host}/".format(host=host) assert request["headers"] == { "Accept": "*/*", - "Accept-Encoding": "gzip, deflate", + "Accept-Encoding": mock.ANY, "Host": host, "User-Agent": request["headers"]["User-Agent"], "baggage": mock.ANY, diff --git a/tests/test_transport.py b/tests/test_transport.py index 8c69a47c54..1c7bc8aac2 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone from unittest import mock +import brotli import pytest from pytest_localserver.http import WSGIServer from werkzeug.wrappers import Request, Response @@ -54,9 +55,13 @@ def __call__(self, environ, start_response): """ request = Request(environ) event = envelope = None - if request.headers.get("content-encoding") == "gzip": + content_encoding = request.headers.get("content-encoding") + if content_encoding == "gzip": rdr = gzip.GzipFile(fileobj=io.BytesIO(request.data)) compressed = True + elif content_encoding == "br": + rdr = io.BytesIO(brotli.decompress(request.data)) + compressed = True else: rdr = io.BytesIO(request.data) compressed = False @@ -117,7 +122,8 @@ def mock_transaction_envelope(span_count): @pytest.mark.parametrize("debug", (True, False)) @pytest.mark.parametrize("client_flush_method", ["close", "flush"]) @pytest.mark.parametrize("use_pickle", (True, False)) -@pytest.mark.parametrize("compression_level", (0, 9)) +@pytest.mark.parametrize("compression_level", (0, 9, None)) +@pytest.mark.parametrize("compression_algo", ("gzip", "br", "", None)) @pytest.mark.parametrize( "http2", [True, False] if sys.version_info >= (3, 8) else [False] ) @@ -131,14 +137,18 @@ def test_transport_works( client_flush_method, use_pickle, compression_level, + compression_algo, http2, maybe_monkeypatched_threading, ): caplog.set_level(logging.DEBUG) - experiments = { - "transport_zlib_compression_level": compression_level, - } + experiments = {} + if compression_level is not None: + experiments["transport_compression_level"] = compression_level + + if compression_algo is not None: + experiments["transport_compression_algo"] = compression_algo if http2: experiments["transport_http2"] = True @@ -164,7 +174,21 @@ def test_transport_works( out, err = capsys.readouterr() assert not err and not out assert capturing_server.captured - assert capturing_server.captured[0].compressed == (compression_level > 0) + should_compress = ( + # default is to compress with brotli if available, gzip otherwise + (compression_level is None) + or ( + # setting compression level to 0 means don't compress + compression_level + > 0 + ) + ) and ( + # if we couldn't resolve to a known algo, we don't compress + compression_algo + != "" + ) + + assert capturing_server.captured[0].compressed == should_compress assert any("Sending envelope" in record.msg for record in caplog.records) == debug From d0eca65aa155a3a6e391b013e6b30ed9e0e3ad23 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:12:57 +0200 Subject: [PATCH 06/13] feat(bottle): Add `failed_request_status_codes` (#3618) --- sentry_sdk/integrations/bottle.py | 50 +++++++++++---- tests/integrations/bottle/test_bottle.py | 81 +++++++++++++++++++++++- 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index 6dae8d9188..a2d6b51033 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -9,13 +9,19 @@ parse_version, transaction_from_function, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import ( + Integration, + DidNotEnable, + _DEFAULT_FAILED_REQUEST_STATUS_CODES, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Set + from sentry_sdk.integrations.wsgi import _ScopedResponse from typing import Any from typing import Dict @@ -28,6 +34,7 @@ try: from bottle import ( Bottle, + HTTPResponse, Route, request as bottle_request, __version__ as BOTTLE_VERSION, @@ -45,8 +52,13 @@ class BottleIntegration(Integration): transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__( + self, + transaction_style="endpoint", # type: str + *, + failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int] + ): + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( @@ -54,6 +66,7 @@ def __init__(self, transaction_style="endpoint"): % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.failed_request_status_codes = failed_request_status_codes @staticmethod def setup_once(): @@ -102,26 +115,29 @@ def _patched_handle(self, environ): old_make_callback = Route._make_callback - @ensure_integration_enabled(BottleIntegration, old_make_callback) + @functools.wraps(old_make_callback) def patched_make_callback(self, *args, **kwargs): # type: (Route, *object, **object) -> Any - client = sentry_sdk.get_client() prepared_callback = old_make_callback(self, *args, **kwargs) + integration = sentry_sdk.get_client().get_integration(BottleIntegration) + if integration is None: + return prepared_callback + def wrapped_callback(*args, **kwargs): # type: (*object, **object) -> Any - try: res = prepared_callback(*args, **kwargs) except Exception as exception: - event, hint = event_from_exception( - exception, - client_options=client.options, - mechanism={"type": "bottle", "handled": False}, - ) - sentry_sdk.capture_event(event, hint=hint) + _capture_exception(exception, handled=False) raise exception + if ( + isinstance(res, HTTPResponse) + and res.status_code in integration.failed_request_status_codes + ): + _capture_exception(res, handled=True) + return res return wrapped_callback @@ -191,3 +207,13 @@ def event_processor(event, hint): return event return event_processor + + +def _capture_exception(exception, handled): + # type: (BaseException, bool) -> None + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "bottle", "handled": handled}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py index 9dd23cf45a..9cc436a229 100644 --- a/tests/integrations/bottle/test_bottle.py +++ b/tests/integrations/bottle/test_bottle.py @@ -3,12 +3,14 @@ import logging from io import BytesIO -from bottle import Bottle, debug as set_debug, abort, redirect +from bottle import Bottle, debug as set_debug, abort, redirect, HTTPResponse from sentry_sdk import capture_message +from sentry_sdk.integrations.bottle import BottleIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH from sentry_sdk.integrations.logging import LoggingIntegration from werkzeug.test import Client +from werkzeug.wrappers import Response import sentry_sdk.integrations.bottle as bottle_sentry @@ -445,3 +447,80 @@ def test_span_origin( (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.bottle" + + +@pytest.mark.parametrize("raise_error", [True, False]) +@pytest.mark.parametrize( + ("integration_kwargs", "status_code", "should_capture"), + ( + ({}, None, False), + ({}, 400, False), + ({}, 451, False), # Highest 4xx status code + ({}, 500, True), + ({}, 511, True), # Highest 5xx status code + ({"failed_request_status_codes": set()}, 500, False), + ({"failed_request_status_codes": set()}, 511, False), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 404, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 500, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 400, False), + ), +) +def test_failed_request_status_codes( + sentry_init, + capture_events, + integration_kwargs, + status_code, + should_capture, + raise_error, +): + sentry_init(integrations=[BottleIntegration(**integration_kwargs)]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + if status_code is not None: + response = HTTPResponse(status=status_code) + if raise_error: + raise response + else: + return response + return "OK" + + client = Client(app, Response) + response = client.get("/") + + expected_status = 200 if status_code is None else status_code + assert response.status_code == expected_status + + if should_capture: + (event,) = events + assert event["exception"]["values"][0]["type"] == "HTTPResponse" + else: + assert not events + + +def test_failed_request_status_codes_non_http_exception(sentry_init, capture_events): + """ + If an exception, which is not an instance of HTTPResponse, is raised, it should be captured, even if + failed_request_status_codes is empty. + """ + sentry_init(integrations=[BottleIntegration(failed_request_status_codes=set())]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + 1 / 0 + + client = Client(app, Response) + + try: + client.get("/") + except ZeroDivisionError: + pass + + (event,) = events + assert event["exception"]["values"][0]["type"] == "ZeroDivisionError" From c110ff38435d2707bcfe19ff164307ff41c20196 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 8 Oct 2024 11:27:55 +0200 Subject: [PATCH 07/13] Add 3.13 to basepython (#3589) --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 9725386f4c..8857d1cb35 100644 --- a/tox.ini +++ b/tox.ini @@ -766,6 +766,7 @@ basepython = py3.10: python3.10 py3.11: python3.11 py3.12: python3.12 + py3.13: python3.13 # Python version is pinned here because flake8 actually behaves differently # depending on which version is used. You can patch this out to point to From 3945fc118f2fbc3809a1d32e4782e54f445cb882 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 8 Oct 2024 11:42:28 +0200 Subject: [PATCH 08/13] Add 3.13 to setup.py (#3574) --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 0432533247..57e61b2969 100644 --- a/setup.py +++ b/setup.py @@ -99,6 +99,7 @@ def get_file_text(file_name): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", ], options={"bdist_wheel": {"universal": "1"}}, From 01b468724ad63b814c742eb57053fa1e46d7f34f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 8 Oct 2024 11:57:08 +0200 Subject: [PATCH 09/13] Remove flaky test (#3626) --- tests/test_basics.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/test_basics.py b/tests/test_basics.py index 91addc6219..ad20bb9fd5 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -34,7 +34,6 @@ setup_integrations, ) from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.stdlib import StdlibIntegration from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import get_sdk_name, reraise @@ -887,13 +886,6 @@ def test_functions_to_trace_with_class(sentry_init, capture_events): assert event["spans"][1]["description"] == "tests.test_basics.WorldGreeter.greet" -def test_redis_disabled_when_not_installed(sentry_init): - with ModuleImportErrorSimulator(["redis"], ImportError): - sentry_init() - - assert sentry_sdk.get_client().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()} From 0df20a76a4c8f2ac4deea461038ebc479394c14d Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 8 Oct 2024 11:54:51 +0000 Subject: [PATCH 10/13] release: 2.16.0 --- CHANGELOG.md | 22 ++++++++++++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db062694d..b62d184ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 2.16.0 + +### Various fixes & improvements + +- Remove flaky test (#3626) by @sentrivana +- Add 3.13 to setup.py (#3574) by @sentrivana +- Add 3.13 to basepython (#3589) by @sentrivana +- feat(bottle): Add `failed_request_status_codes` (#3618) by @szokeasaurusrex +- feat: Add opportunistic Brotli compression (#3612) by @BYK +- fix(django): improve getting psycopg3 connection info (#3580) by @nijel +- feat: Add `__notes__` support (#3620) by @szokeasaurusrex +- fix: Open relevant error when SpotlightMiddleware is on (#3614) by @BYK +- build(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0 (#3617) by @dependabot +- feat(django): Add SpotlightMiddleware when Spotlight is enabled (#3600) by @BYK +- feat: Add httpcore based HTTP2Transport (#3588) by @BYK +- Add http_methods_to_capture to ASGI Django (#3607) by @sentrivana +- ref(bottle): Delete never-reached code (#3605) by @szokeasaurusrex +- Remove useless makefile targets (#3604) by @antonpirker +- Simplify tox version spec (#3609) by @sentrivana +- Consolidate contributing docs (#3606) by @antonpirker +- Fix type of sample_rate in DSC (and add explanatory tests) (#3603) by @antonpirker + ## 2.15.0 ### Integrations diff --git a/docs/conf.py b/docs/conf.py index c1a219e278..390f576219 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,7 +28,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.15.0" +release = "2.16.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 631edd8a83..5c79615da3 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -574,4 +574,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.15.0" +VERSION = "2.16.0" diff --git a/setup.py b/setup.py index 57e61b2969..2bf78cbf69 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.15.0", + version="2.16.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From b73191073b7c8e371a21461ae57a0b97f1c4de00 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 8 Oct 2024 14:05:17 +0200 Subject: [PATCH 11/13] Update CHANGELOG.md --- CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b62d184ad4..5757b6af5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,25 +2,65 @@ ## 2.16.0 -### Various fixes & improvements +### Integrations + +- Bottle: Add `failed_request_status_codes` (#3618) by @szokeasaurusrex + + You can now define a set of integers that will determine which status codes + should be reported to Sentry. + + ```python + sentry_sdk.init( + integrations=[ + BottleIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ) + ] + ) + ``` + + Examples of valid `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- Bottle: Delete never-reached code (#3605) by @szokeasaurusrex +- Redis: Remove flaky test (#3626) by @sentrivana +- Django: Improve getting `psycopg3` connection info (#3580) by @nijel +- Django: Add `SpotlightMiddleware` when Spotlight is enabled (#3600) by @BYK +- Django: Open relevant error when `SpotlightMiddleware` is on (#3614) by @BYK +- Django: Support `http_methods_to_capture` in ASGI Django (#3607) by @sentrivana + + ASGI Django now also supports the `http_methods_to_capture` integration option. This is a configurable tuple of HTTP method verbs that should create a transaction in Sentry. The default is `("CONNECT", "DELETE", "GET", "PATCH", "POST", "PUT", "TRACE",)`. `OPTIONS` and `HEAD` are not included by default. + + Here's how to use it: + + ```python + sentry_sdk.init( + integrations=[ + DjangoIntegration( + http_methods_to_capture=("GET", "POST"), + ), + ], + ) + ``` + +### Miscellaneous -- Remove flaky test (#3626) by @sentrivana - Add 3.13 to setup.py (#3574) by @sentrivana - Add 3.13 to basepython (#3589) by @sentrivana -- feat(bottle): Add `failed_request_status_codes` (#3618) by @szokeasaurusrex -- feat: Add opportunistic Brotli compression (#3612) by @BYK -- fix(django): improve getting psycopg3 connection info (#3580) by @nijel -- feat: Add `__notes__` support (#3620) by @szokeasaurusrex -- fix: Open relevant error when SpotlightMiddleware is on (#3614) by @BYK -- build(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0 (#3617) by @dependabot -- feat(django): Add SpotlightMiddleware when Spotlight is enabled (#3600) by @BYK -- feat: Add httpcore based HTTP2Transport (#3588) by @BYK -- Add http_methods_to_capture to ASGI Django (#3607) by @sentrivana -- ref(bottle): Delete never-reached code (#3605) by @szokeasaurusrex +- Fix type of sample_rate in DSC (and add explanatory tests) (#3603) by @antonpirker +- Add `httpcore` based `HTTP2Transport` (#3588) by @BYK +- Add opportunistic Brotli compression (#3612) by @BYK +- Add `__notes__` support (#3620) by @szokeasaurusrex - Remove useless makefile targets (#3604) by @antonpirker - Simplify tox version spec (#3609) by @sentrivana - Consolidate contributing docs (#3606) by @antonpirker -- Fix type of sample_rate in DSC (and add explanatory tests) (#3603) by @antonpirker +- Bump codecov/codecov-action from 4.5.0 to 4.6.0 (#3617) by @dependabot ## 2.15.0 @@ -40,6 +80,7 @@ ), ], ) + ``` - Django: Allow ASGI to use `drf_request` in `DjangoRequestExtractor` (#3572) by @PakawiNz - Django: Don't let `RawPostDataException` bubble up (#3553) by @sentrivana From 90986018b8512831313636a3aae8afc8fe2f02d7 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 8 Oct 2024 14:12:12 +0200 Subject: [PATCH 12/13] Fix changelog formatting --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5757b6af5a..78aad7d292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,10 +42,10 @@ ```python sentry_sdk.init( integrations=[ - DjangoIntegration( - http_methods_to_capture=("GET", "POST"), - ), - ], + DjangoIntegration( + http_methods_to_capture=("GET", "POST"), + ), + ], ) ``` @@ -53,14 +53,14 @@ - Add 3.13 to setup.py (#3574) by @sentrivana - Add 3.13 to basepython (#3589) by @sentrivana -- Fix type of sample_rate in DSC (and add explanatory tests) (#3603) by @antonpirker +- Fix type of `sample_rate` in DSC (and add explanatory tests) (#3603) by @antonpirker - Add `httpcore` based `HTTP2Transport` (#3588) by @BYK - Add opportunistic Brotli compression (#3612) by @BYK - Add `__notes__` support (#3620) by @szokeasaurusrex - Remove useless makefile targets (#3604) by @antonpirker - Simplify tox version spec (#3609) by @sentrivana - Consolidate contributing docs (#3606) by @antonpirker -- Bump codecov/codecov-action from 4.5.0 to 4.6.0 (#3617) by @dependabot +- Bump `codecov/codecov-action` from `4.5.0` to `4.6.0` (#3617) by @dependabot ## 2.15.0 From ce604f97dee775b5226b2f3824dd1be4410a932b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 9 Oct 2024 09:32:52 +0200 Subject: [PATCH 13/13] Remove ensure_integration_enabled_async (#3632) --- sentry_sdk/utils.py | 61 ------------------------------- tests/test_utils.py | 88 +-------------------------------------------- 2 files changed, 1 insertion(+), 148 deletions(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 3c86564ef8..4d07974809 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -31,8 +31,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from collections.abc import Awaitable - from types import FrameType, TracebackType from typing import ( Any, @@ -1731,12 +1729,6 @@ def _no_op(*_a, **_k): pass -async def _no_op_async(*_a, **_k): - # type: (*Any, **Any) -> None - """No-op function for ensure_integration_enabled_async.""" - pass - - if TYPE_CHECKING: @overload @@ -1803,59 +1795,6 @@ def runner(*args: "P.args", **kwargs: "P.kwargs"): return patcher -if TYPE_CHECKING: - - # mypy has some trouble with the overloads, hence the ignore[no-overload-impl] - @overload # type: ignore[no-overload-impl] - def ensure_integration_enabled_async( - integration, # type: type[sentry_sdk.integrations.Integration] - original_function, # type: Callable[P, Awaitable[R]] - ): - # type: (...) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]] - ... - - @overload - def ensure_integration_enabled_async( - integration, # type: type[sentry_sdk.integrations.Integration] - ): - # type: (...) -> Callable[[Callable[P, Awaitable[None]]], Callable[P, Awaitable[None]]] - ... - - -# The ignore[no-redef] also needed because mypy is struggling with these overloads. -def ensure_integration_enabled_async( # type: ignore[no-redef] - integration, # type: type[sentry_sdk.integrations.Integration] - original_function=_no_op_async, # type: Union[Callable[P, Awaitable[R]], Callable[P, Awaitable[None]]] -): - # type: (...) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]] - """ - Version of `ensure_integration_enabled` for decorating async functions. - - Please refer to the `ensure_integration_enabled` documentation for more information. - """ - - if TYPE_CHECKING: - # Type hint to ensure the default function has the right typing. The overloads - # ensure the default _no_op function is only used when R is None. - original_function = cast(Callable[P, Awaitable[R]], original_function) - - def patcher(sentry_patched_function): - # type: (Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]] - async def runner(*args: "P.args", **kwargs: "P.kwargs"): - # type: (...) -> R - if sentry_sdk.get_client().get_integration(integration) is None: - return await original_function(*args, **kwargs) - - return await sentry_patched_function(*args, **kwargs) - - if original_function is _no_op_async: - return wraps(sentry_patched_function)(runner) - - return wraps(original_function)(runner) - - return patcher - - if PY37: def nanosecond_time(): diff --git a/tests/test_utils.py b/tests/test_utils.py index eaf382c773..87e2659a12 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -31,14 +31,12 @@ _get_installed_modules, _generate_installed_modules, ensure_integration_enabled, - ensure_integration_enabled_async, ) class TestIntegration(Integration): """ - Test integration for testing ensure_integration_enabled and - ensure_integration_enabled_async decorators. + Test integration for testing ensure_integration_enabled decorator. """ identifier = "test" @@ -783,90 +781,6 @@ def function_to_patch(): assert patched_function.__name__ == "function_to_patch" -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_integration_enabled(sentry_init): - # Setup variables and functions for the test - async def original_function(): - return "original" - - async def function_to_patch(): - return "patched" - - sentry_init(integrations=[TestIntegration()]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async( - TestIntegration, original_function - )(function_to_patch) - - assert await patched_function() == "patched" - assert patched_function.__name__ == "original_function" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_integration_disabled(sentry_init): - # Setup variables and functions for the test - async def original_function(): - return "original" - - async def function_to_patch(): - return "patched" - - sentry_init(integrations=[]) # TestIntegration is disabled - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async( - TestIntegration, original_function - )(function_to_patch) - - assert await patched_function() == "original" - assert patched_function.__name__ == "original_function" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_no_original_function_enabled( - sentry_init, -): - shared_variable = "original" - - async def function_to_patch(): - nonlocal shared_variable - shared_variable = "patched" - - sentry_init(integrations=[TestIntegration]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async(TestIntegration)( - function_to_patch - ) - await patched_function() - - assert shared_variable == "patched" - assert patched_function.__name__ == "function_to_patch" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_no_original_function_disabled( - sentry_init, -): - shared_variable = "original" - - async def function_to_patch(): - nonlocal shared_variable - shared_variable = "patched" - - sentry_init(integrations=[]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async(TestIntegration)( - function_to_patch - ) - await patched_function() - - assert shared_variable == "original" - assert patched_function.__name__ == "function_to_patch" - - @pytest.mark.parametrize( "delta,expected_milliseconds", [