From 5f71872c8abf2ee0cd0f4a35e1771f0a097e6938 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 3 Apr 2025 12:38:30 +0200 Subject: [PATCH 01/24] fix(asyncio): Remove shutdown handler (#4237) Remove the shutdown handler from the asyncio integration. It's only purpose was to log a message, but it looks like it has [unintended side effects](https://github.com/getsentry/sentry-python/issues/4234). Closes https://github.com/getsentry/sentry-python/issues/4234 --- sentry_sdk/integrations/asyncio.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/sentry_sdk/integrations/asyncio.py b/sentry_sdk/integrations/asyncio.py index 9326c16e9a..ae580ca038 100644 --- a/sentry_sdk/integrations/asyncio.py +++ b/sentry_sdk/integrations/asyncio.py @@ -1,5 +1,4 @@ import sys -import signal import sentry_sdk from sentry_sdk.consts import OP @@ -37,22 +36,6 @@ def patch_asyncio(): loop = asyncio.get_running_loop() orig_task_factory = loop.get_task_factory() - # Add a shutdown handler to log a helpful message - def shutdown_handler(): - # type: () -> None - logger.info( - "AsyncIO is shutting down. If you see 'Task was destroyed but it is pending!' " - "errors with '_task_with_sentry_span_creation', these are normal during shutdown " - "and not a problem with your code or Sentry." - ) - - try: - loop.add_signal_handler(signal.SIGINT, shutdown_handler) - loop.add_signal_handler(signal.SIGTERM, shutdown_handler) - except (NotImplementedError, AttributeError): - # Signal handlers might not be supported on all platforms - pass - def _sentry_task_factory(loop, coro, **kwargs): # type: (asyncio.AbstractEventLoop, Coroutine[Any, Any, Any], Any) -> asyncio.Future[Any] From 2b3b82d492ece2634e23ffeb2dd589dcce284c10 Mon Sep 17 00:00:00 2001 From: Mahmoodreza <47904885+moodix@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:49:47 +0300 Subject: [PATCH 02/24] fix: Handle JSONDecodeError gracefully in StarletteRequestExtractor (#4226) Previously, when encountering malformed JSON in request bodies, the json() method would raise a JSONDecodeError. This change updates the method to catch the exception and return None instead, providing more consistent behavior and preventing unexpected crashes. Added a test case to verify this error handling behavior. --- sentry_sdk/integrations/starlette.py | 7 ++++-- .../integrations/starlette/test_starlette.py | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index dbb47dff58..d0f0bf2045 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -3,6 +3,7 @@ import warnings from collections.abc import Set from copy import deepcopy +from json import JSONDecodeError import sentry_sdk from sentry_sdk.consts import OP @@ -680,8 +681,10 @@ async def json(self): # type: (StarletteRequestExtractor) -> Optional[Dict[str, Any]] if not self.is_json(): return None - - return await self.request.json() + try: + return await self.request.json() + except JSONDecodeError: + return None def _transaction_name_from_router(scope): diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 3289f69ed6..bc445bf8f2 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -1354,3 +1354,28 @@ async def _error(_): client.get("/error") assert len(events) == int(expected_error) + + +@pytest.mark.asyncio +async def test_starletterequestextractor_malformed_json_error_handling(sentry_init): + scope = SCOPE.copy() + scope["headers"] = [ + [b"content-type", b"application/json"], + ] + starlette_request = starlette.requests.Request(scope) + + malformed_json = "{invalid json" + malformed_messages = [ + {"type": "http.request", "body": malformed_json.encode("utf-8")}, + {"type": "http.disconnect"}, + ] + + side_effect = [_mock_receive(msg) for msg in malformed_messages] + starlette_request._receive = mock.Mock(side_effect=side_effect) + + extractor = StarletteRequestExtractor(starlette_request) + + assert extractor.is_json() + + result = await extractor.json() + assert result is None From f1a8db0a654f8a59e8b00afd7a6fd89a508b1a10 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 3 Apr 2025 16:50:27 +0200 Subject: [PATCH 03/24] tests: Move django under toxgen (#4238) --- .github/workflows/test-integrations-web-1.yml | 2 +- scripts/populate_tox/config.py | 19 ++++ scripts/populate_tox/populate_tox.py | 1 - scripts/populate_tox/tox.jinja | 44 -------- tox.ini | 101 +++++++++--------- 5 files changed, 68 insertions(+), 99 deletions(-) diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml index a294301dbc..6d3e62a78a 100644 --- a/.github/workflows/test-integrations-web-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8","3.10","3.12","3.13"] + python-version: ["3.8","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 3e8f6cf898..0bacfcaa7b 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -29,6 +29,25 @@ "clickhouse_driver": { "package": "clickhouse-driver", }, + "django": { + "package": "django", + "deps": { + "*": [ + "psycopg2-binary", + "djangorestframework", + "pytest-django", + "Werkzeug", + ], + ">=3.0": ["pytest-asyncio"], + ">=2.2,<3.1": ["six"], + "<3.3": [ + "djangorestframework>=3.0,<4.0", + "Werkzeug<2.1.0", + ], + "<3.1": ["pytest-django<4.0"], + ">=2.0": ["channels[daphne]"], + }, + }, "dramatiq": { "package": "dramatiq", }, diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index d1e6cbca71..df45e30ed9 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -69,7 +69,6 @@ "boto3", "chalice", "cohere", - "django", "fastapi", "gcp", "httpx", diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 1514ff197a..e599f45436 100644 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -80,21 +80,6 @@ envlist = {py3.9,py3.11,py3.12}-cohere-v5 {py3.9,py3.11,py3.12}-cohere-latest - # Django - # - Django 1.x - {py3.6,py3.7}-django-v{1.11} - # - Django 2.x - {py3.6,py3.7}-django-v{2.0} - {py3.6,py3.9}-django-v{2.2} - # - Django 3.x - {py3.6,py3.9}-django-v{3.0} - {py3.6,py3.9,py3.11}-django-v{3.2} - # - Django 4.x - {py3.8,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,5.1} - {py3.10,py3.12,py3.13}-django-latest - # FastAPI {py3.7,py3.10}-fastapi-v{0.79} {py3.8,py3.12,py3.13}-fastapi-latest @@ -267,35 +252,6 @@ deps = cohere-v5: cohere~=5.3.3 cohere-latest: cohere - # Django - django: psycopg2-binary - django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0 - django-v{2.0,2.2,3.0,3.2,4.0,4.1,4.2,5.0,5.1}: channels[daphne] - django-v{2.2,3.0}: six - django-v{1.11,2.0,2.2,3.0,3.2}: Werkzeug<2.1.0 - django-v{1.11,2.0,2.2,3.0}: pytest-django<4.0 - django-v{3.2,4.0,4.1,4.2,5.0,5.1}: pytest-django - django-v{4.0,4.1,4.2,5.0,5.1}: djangorestframework - django-v{4.0,4.1,4.2,5.0,5.1}: pytest-asyncio - django-v{4.0,4.1,4.2,5.0,5.1}: Werkzeug - django-latest: djangorestframework - django-latest: pytest-asyncio - django-latest: pytest-django - django-latest: Werkzeug - django-latest: channels[daphne] - - django-v1.11: Django~=1.11.0 - django-v2.0: Django~=2.0.0 - django-v2.2: Django~=2.2.0 - django-v3.0: Django~=3.0.0 - django-v3.2: Django~=3.2.0 - django-v4.0: Django~=4.0.0 - django-v4.1: Django~=4.1.0 - django-v4.2: Django~=4.2.0 - django-v5.0: Django~=5.0.0 - django-v5.1: Django==5.1rc1 - django-latest: Django - # FastAPI fastapi: httpx # (this is a dependency of httpx) diff --git a/tox.ini b/tox.ini index a093b4de00..1854b0f711 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-03-31T10:49:05.789167+00:00 +# Last generated: 2025-04-03T11:46:44.595900+00:00 [tox] requires = @@ -80,21 +80,6 @@ envlist = {py3.9,py3.11,py3.12}-cohere-v5 {py3.9,py3.11,py3.12}-cohere-latest - # Django - # - Django 1.x - {py3.6,py3.7}-django-v{1.11} - # - Django 2.x - {py3.6,py3.7}-django-v{2.0} - {py3.6,py3.9}-django-v{2.2} - # - Django 3.x - {py3.6,py3.9}-django-v{3.0} - {py3.6,py3.9,py3.11}-django-v{3.2} - # - Django 4.x - {py3.8,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,5.1} - {py3.10,py3.12,py3.13}-django-latest - # FastAPI {py3.7,py3.10}-fastapi-v{0.79} {py3.8,py3.12,py3.13}-fastapi-latest @@ -217,7 +202,7 @@ envlist = {py3.8,py3.10,py3.11}-strawberry-v0.209.8 {py3.8,py3.11,py3.12}-strawberry-v0.227.7 {py3.8,py3.11,py3.12}-strawberry-v0.245.0 - {py3.9,py3.12,py3.13}-strawberry-v0.262.6 + {py3.9,py3.12,py3.13}-strawberry-v0.263.0 # ~~~ Network ~~~ @@ -230,8 +215,7 @@ envlist = # ~~~ Tasks ~~~ {py3.6,py3.7,py3.8}-celery-v4.4.7 {py3.6,py3.7,py3.8}-celery-v5.0.5 - {py3.8,py3.11,py3.12}-celery-v5.4.0 - {py3.8,py3.12,py3.13}-celery-v5.5.0rc5 + {py3.8,py3.12,py3.13}-celery-v5.5.0 {py3.6,py3.7}-dramatiq-v1.9.0 {py3.6,py3.8,py3.9}-dramatiq-v1.12.3 @@ -245,6 +229,14 @@ envlist = # ~~~ Web 1 ~~~ + {py3.6}-django-v1.11.9 + {py3.6,py3.7}-django-v1.11.29 + {py3.6,py3.8,py3.9}-django-v2.2.28 + {py3.6,py3.9,py3.10}-django-v3.2.25 + {py3.8,py3.11,py3.12}-django-v4.2.20 + {py3.10,py3.11,py3.12}-django-v5.0.9 + {py3.10,py3.12,py3.13}-django-v5.2 + {py3.6,py3.7,py3.8}-flask-v1.1.4 {py3.8,py3.12,py3.13}-flask-v2.3.3 {py3.8,py3.12,py3.13}-flask-v3.0.3 @@ -293,7 +285,7 @@ envlist = {py3.6,py3.7,py3.8}-trytond-v5.8.16 {py3.8,py3.10,py3.11}-trytond-v6.8.17 {py3.8,py3.11,py3.12}-trytond-v7.0.9 - {py3.8,py3.11,py3.12}-trytond-v7.4.8 + {py3.8,py3.11,py3.12}-trytond-v7.4.9 {py3.7,py3.12,py3.13}-typer-v0.15.2 @@ -389,35 +381,6 @@ deps = cohere-v5: cohere~=5.3.3 cohere-latest: cohere - # Django - django: psycopg2-binary - django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0 - django-v{2.0,2.2,3.0,3.2,4.0,4.1,4.2,5.0,5.1}: channels[daphne] - django-v{2.2,3.0}: six - django-v{1.11,2.0,2.2,3.0,3.2}: Werkzeug<2.1.0 - django-v{1.11,2.0,2.2,3.0}: pytest-django<4.0 - django-v{3.2,4.0,4.1,4.2,5.0,5.1}: pytest-django - django-v{4.0,4.1,4.2,5.0,5.1}: djangorestframework - django-v{4.0,4.1,4.2,5.0,5.1}: pytest-asyncio - django-v{4.0,4.1,4.2,5.0,5.1}: Werkzeug - django-latest: djangorestframework - django-latest: pytest-asyncio - django-latest: pytest-django - django-latest: Werkzeug - django-latest: channels[daphne] - - django-v1.11: Django~=1.11.0 - django-v2.0: Django~=2.0.0 - django-v2.2: Django~=2.2.0 - django-v3.0: Django~=3.0.0 - django-v3.2: Django~=3.2.0 - django-v4.0: Django~=4.0.0 - django-v4.1: Django~=4.1.0 - django-v4.2: Django~=4.2.0 - django-v5.0: Django~=5.0.0 - django-v5.1: Django==5.1rc1 - django-latest: Django - # FastAPI fastapi: httpx # (this is a dependency of httpx) @@ -611,7 +574,7 @@ deps = strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8 strawberry-v0.227.7: strawberry-graphql[fastapi,flask]==0.227.7 strawberry-v0.245.0: strawberry-graphql[fastapi,flask]==0.245.0 - strawberry-v0.262.6: strawberry-graphql[fastapi,flask]==0.262.6 + strawberry-v0.263.0: strawberry-graphql[fastapi,flask]==0.263.0 strawberry: httpx strawberry-v0.209.8: pydantic<2.11 strawberry-v0.227.7: pydantic<2.11 @@ -632,8 +595,7 @@ deps = # ~~~ Tasks ~~~ celery-v4.4.7: celery==4.4.7 celery-v5.0.5: celery==5.0.5 - celery-v5.4.0: celery==5.4.0 - celery-v5.5.0rc5: celery==5.5.0rc5 + celery-v5.5.0: celery==5.5.0 celery: newrelic celery: redis py3.7-celery: importlib-metadata<5.0 @@ -650,6 +612,39 @@ deps = # ~~~ Web 1 ~~~ + django-v1.11.9: django==1.11.9 + django-v1.11.29: django==1.11.29 + django-v2.2.28: django==2.2.28 + django-v3.2.25: django==3.2.25 + django-v4.2.20: django==4.2.20 + django-v5.0.9: django==5.0.9 + django-v5.2: django==5.2 + django: psycopg2-binary + django: djangorestframework + django: pytest-django + django: Werkzeug + django-v3.2.25: pytest-asyncio + django-v4.2.20: pytest-asyncio + django-v5.0.9: pytest-asyncio + django-v5.2: pytest-asyncio + django-v2.2.28: six + django-v1.11.9: djangorestframework>=3.0,<4.0 + django-v1.11.9: Werkzeug<2.1.0 + django-v1.11.29: djangorestframework>=3.0,<4.0 + django-v1.11.29: Werkzeug<2.1.0 + django-v2.2.28: djangorestframework>=3.0,<4.0 + django-v2.2.28: Werkzeug<2.1.0 + django-v3.2.25: djangorestframework>=3.0,<4.0 + django-v3.2.25: Werkzeug<2.1.0 + django-v1.11.9: pytest-django<4.0 + django-v1.11.29: pytest-django<4.0 + django-v2.2.28: pytest-django<4.0 + django-v2.2.28: channels[daphne] + django-v3.2.25: channels[daphne] + django-v4.2.20: channels[daphne] + django-v5.0.9: channels[daphne] + django-v5.2: channels[daphne] + flask-v1.1.4: flask==1.1.4 flask-v2.3.3: flask==2.3.3 flask-v3.0.3: flask==3.0.3 @@ -731,7 +726,7 @@ deps = trytond-v5.8.16: trytond==5.8.16 trytond-v6.8.17: trytond==6.8.17 trytond-v7.0.9: trytond==7.0.9 - trytond-v7.4.8: trytond==7.4.8 + trytond-v7.4.9: trytond==7.4.9 trytond: werkzeug trytond-v4.6.9: werkzeug<1.0 trytond-v4.8.18: werkzeug<1.0 From 5147ab9fdf3e1a8a42fefbd665743ae01998ba66 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Thu, 3 Apr 2025 16:56:15 +0200 Subject: [PATCH 04/24] feat(breadcrumbs): add `_meta` information for truncation of breadcrumbs (#4007) - Implements annotations for breadcrumbs - Adds an `int` field to `Scope` to track the number of truncated breadcrumbs - When scopes are merged, the number of breadcrumbs that were removed are added - If breadcrumbs were truncated, add the original number of breadcrumbs to `_meta` - Closes https://github.com/getsentry/projects/issues/593 --------- Co-authored-by: Anton Pirker --- sentry_sdk/_types.py | 15 +++++++++++++-- sentry_sdk/client.py | 16 +++++++++++++++- sentry_sdk/scope.py | 30 +++++++++++++++++++++++------- sentry_sdk/scrubber.py | 5 ++++- tests/test_scrubber.py | 20 ++++++++++++++------ 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 22b91b202f..9bcb5a61f9 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -30,6 +30,17 @@ def __eq__(self, other): return self.value == other.value and self.metadata == other.metadata + def __str__(self): + # type: (AnnotatedValue) -> str + return str({"value": str(self.value), "metadata": str(self.metadata)}) + + def __len__(self): + # type: (AnnotatedValue) -> int + if self.value is not None: + return len(self.value) + else: + return 0 + @classmethod def removed_because_raw_data(cls): # type: () -> AnnotatedValue @@ -152,8 +163,8 @@ class SDKInfo(TypedDict): Event = TypedDict( "Event", { - "breadcrumbs": dict[ - Literal["values"], list[dict[str, Any]] + "breadcrumbs": Annotated[ + dict[Literal["values"], list[dict[str, Any]]] ], # TODO: We can expand on this type "check_in_id": str, "contexts": dict[str, dict[str, object]], diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 3350c1372a..4dfccb3132 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -498,6 +498,7 @@ def _prepare_event( # type: (...) -> Optional[Event] previous_total_spans = None # type: Optional[int] + previous_total_breadcrumbs = None # type: Optional[int] if event.get("timestamp") is None: event["timestamp"] = datetime.now(timezone.utc) @@ -534,6 +535,16 @@ def _prepare_event( dropped_spans = event.pop("_dropped_spans", 0) + spans_delta # type: int if dropped_spans > 0: previous_total_spans = spans_before + dropped_spans + if scope._n_breadcrumbs_truncated > 0: + breadcrumbs = event.get("breadcrumbs", {}) + values = ( + breadcrumbs.get("values", []) + if not isinstance(breadcrumbs, AnnotatedValue) + else [] + ) + previous_total_breadcrumbs = ( + len(values) + scope._n_breadcrumbs_truncated + ) if ( self.options["attach_stacktrace"] @@ -586,7 +597,10 @@ def _prepare_event( event["spans"] = AnnotatedValue( event.get("spans", []), {"len": previous_total_spans} ) - + if previous_total_breadcrumbs is not None: + event["breadcrumbs"] = AnnotatedValue( + event.get("breadcrumbs", []), {"len": previous_total_breadcrumbs} + ) # Postprocess the event here so that annotated types do # generally not surface in before_send if event is not None: diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index ce6037e6b6..f346569255 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -9,6 +9,7 @@ from functools import wraps from itertools import chain +from sentry_sdk._types import AnnotatedValue from sentry_sdk.attachments import Attachment from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER from sentry_sdk.feature_flags import FlagBuffer, DEFAULT_FLAG_CAPACITY @@ -186,6 +187,7 @@ class Scope: "_contexts", "_extras", "_breadcrumbs", + "_n_breadcrumbs_truncated", "_event_processors", "_error_processors", "_should_capture", @@ -210,6 +212,7 @@ def __init__(self, ty=None, client=None): self._name = None # type: Optional[str] self._propagation_context = None # type: Optional[PropagationContext] + self._n_breadcrumbs_truncated = 0 # type: int self.client = NonRecordingClient() # type: sentry_sdk.client.BaseClient @@ -243,6 +246,7 @@ def __copy__(self): rv._extras = dict(self._extras) rv._breadcrumbs = copy(self._breadcrumbs) + rv._n_breadcrumbs_truncated = copy(self._n_breadcrumbs_truncated) rv._event_processors = list(self._event_processors) rv._error_processors = list(self._error_processors) rv._propagation_context = self._propagation_context @@ -916,6 +920,7 @@ def clear_breadcrumbs(self): # type: () -> None """Clears breadcrumb buffer.""" self._breadcrumbs = deque() # type: Deque[Breadcrumb] + self._n_breadcrumbs_truncated = 0 def add_attachment( self, @@ -983,6 +988,7 @@ def add_breadcrumb(self, crumb=None, hint=None, **kwargs): while len(self._breadcrumbs) > max_breadcrumbs: self._breadcrumbs.popleft() + self._n_breadcrumbs_truncated += 1 def start_transaction( self, @@ -1366,17 +1372,23 @@ def _apply_level_to_event(self, event, hint, options): def _apply_breadcrumbs_to_event(self, event, hint, options): # type: (Event, Hint, Optional[Dict[str, Any]]) -> None - event.setdefault("breadcrumbs", {}).setdefault("values", []).extend( - self._breadcrumbs - ) + event.setdefault("breadcrumbs", {}) + + # This check is just for mypy - + if not isinstance(event["breadcrumbs"], AnnotatedValue): + event["breadcrumbs"].setdefault("values", []) + event["breadcrumbs"]["values"].extend(self._breadcrumbs) # Attempt to sort timestamps try: - for crumb in event["breadcrumbs"]["values"]: - if isinstance(crumb["timestamp"], str): - crumb["timestamp"] = datetime_from_isoformat(crumb["timestamp"]) + if not isinstance(event["breadcrumbs"], AnnotatedValue): + for crumb in event["breadcrumbs"]["values"]: + if isinstance(crumb["timestamp"], str): + crumb["timestamp"] = datetime_from_isoformat(crumb["timestamp"]) - event["breadcrumbs"]["values"].sort(key=lambda crumb: crumb["timestamp"]) + event["breadcrumbs"]["values"].sort( + key=lambda crumb: crumb["timestamp"] + ) except Exception as err: logger.debug("Error when sorting breadcrumbs", exc_info=err) pass @@ -1564,6 +1576,10 @@ def update_from_scope(self, scope): self._extras.update(scope._extras) if scope._breadcrumbs: self._breadcrumbs.extend(scope._breadcrumbs) + if scope._n_breadcrumbs_truncated: + self._n_breadcrumbs_truncated = ( + self._n_breadcrumbs_truncated + scope._n_breadcrumbs_truncated + ) if scope._span: self._span = scope._span if scope._attachments: diff --git a/sentry_sdk/scrubber.py b/sentry_sdk/scrubber.py index 1df5573798..b0576c7e95 100644 --- a/sentry_sdk/scrubber.py +++ b/sentry_sdk/scrubber.py @@ -144,7 +144,10 @@ def scrub_breadcrumbs(self, event): # type: (Event) -> None with capture_internal_exceptions(): if "breadcrumbs" in event: - if "values" in event["breadcrumbs"]: + if ( + not isinstance(event["breadcrumbs"], AnnotatedValue) + and "values" in event["breadcrumbs"] + ): for value in event["breadcrumbs"]["values"]: if "data" in value: self.scrub_dict(value["data"]) diff --git a/tests/test_scrubber.py b/tests/test_scrubber.py index 2c462153dd..2cc5f4139f 100644 --- a/tests/test_scrubber.py +++ b/tests/test_scrubber.py @@ -119,25 +119,33 @@ def test_stack_var_scrubbing(sentry_init, capture_events): def test_breadcrumb_extra_scrubbing(sentry_init, capture_events): - sentry_init() + sentry_init(max_breadcrumbs=2) events = capture_events() - - logger.info("bread", extra=dict(foo=42, password="secret")) + logger.info("breadcrumb 1", extra=dict(foo=1, password="secret")) + logger.info("breadcrumb 2", extra=dict(bar=2, auth="secret")) + logger.info("breadcrumb 3", extra=dict(foobar=3, password="secret")) logger.critical("whoops", extra=dict(bar=69, auth="secret")) (event,) = events assert event["extra"]["bar"] == 69 assert event["extra"]["auth"] == "[Filtered]" - assert event["breadcrumbs"]["values"][0]["data"] == { - "foo": 42, + "bar": 2, + "auth": "[Filtered]", + } + assert event["breadcrumbs"]["values"][1]["data"] == { + "foobar": 3, "password": "[Filtered]", } assert event["_meta"]["extra"]["auth"] == {"": {"rem": [["!config", "s"]]}} assert event["_meta"]["breadcrumbs"] == { - "values": {"0": {"data": {"password": {"": {"rem": [["!config", "s"]]}}}}} + "": {"len": 3}, + "values": { + "0": {"data": {"auth": {"": {"rem": [["!config", "s"]]}}}}, + "1": {"data": {"password": {"": {"rem": [["!config", "s"]]}}}}, + }, } From adcfa0f6abf8850f3b007bde609d0f943f621786 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 3 Apr 2025 17:21:41 +0200 Subject: [PATCH 05/24] Trying to prevent the grpc setup from being flaky (#4233) Automatically select a port and not set it by hand also make creating of the channel more stable. --- tests/integrations/grpc/test_grpc.py | 163 ++++++++++--------- tests/integrations/grpc/test_grpc_aio.py | 190 +++++++++++++---------- 2 files changed, 197 insertions(+), 156 deletions(-) diff --git a/tests/integrations/grpc/test_grpc.py b/tests/integrations/grpc/test_grpc.py index a8872ef0b5..8d2698f411 100644 --- a/tests/integrations/grpc/test_grpc.py +++ b/tests/integrations/grpc/test_grpc.py @@ -1,10 +1,8 @@ -import os - import grpc import pytest from concurrent import futures -from typing import List, Optional +from typing import List, Optional, Tuple from unittest.mock import Mock from sentry_sdk import start_span, start_transaction @@ -19,25 +17,36 @@ ) -PORT = 50051 -PORT += os.getpid() % 100 # avoid port conflicts when running tests in parallel - - -def _set_up(interceptors: Optional[List[grpc.ServerInterceptor]] = None): +# Set up in-memory channel instead of network-based +def _set_up( + interceptors: Optional[List[grpc.ServerInterceptor]] = None, +) -> Tuple[grpc.Server, grpc.Channel]: + """ + Sets up a gRPC server and returns both the server and a channel connected to it. + This eliminates network dependencies and makes tests more reliable. + """ + # Create server with thread pool server = grpc.server( futures.ThreadPoolExecutor(max_workers=2), interceptors=interceptors, ) - add_gRPCTestServiceServicer_to_server(TestService(), server) - server.add_insecure_port("[::]:{}".format(PORT)) + # Add our test service to the server + servicer = TestService() + add_gRPCTestServiceServicer_to_server(servicer, server) + + # Use dynamic port allocation instead of hardcoded port + port = server.add_insecure_port("[::]:0") # Let gRPC choose an available port server.start() - return server + # Create channel connected to our server + channel = grpc.insecure_channel(f"localhost:{port}") # noqa: E231 + + return server, channel def _tear_down(server: grpc.Server): - server.stop(None) + server.stop(grace=None) # Immediate shutdown @pytest.mark.forked @@ -45,11 +54,11 @@ def test_grpc_server_starts_transaction(sentry_init, capture_events_forksafe): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) events = capture_events_forksafe() - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) - stub.TestServe(gRPCTestMessage(text="test")) + # Use the provided channel + stub = gRPCTestServiceStub(channel) + stub.TestServe(gRPCTestMessage(text="test")) _tear_down(server=server) @@ -76,11 +85,11 @@ def test_grpc_server_other_interceptors(sentry_init, capture_events_forksafe): mock_interceptor = Mock() mock_interceptor.intercept_service.side_effect = mock_intercept - server = _set_up(interceptors=[mock_interceptor]) + server, channel = _set_up(interceptors=[mock_interceptor]) - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) - stub.TestServe(gRPCTestMessage(text="test")) + # Use the provided channel + stub = gRPCTestServiceStub(channel) + stub.TestServe(gRPCTestMessage(text="test")) _tear_down(server=server) @@ -103,30 +112,30 @@ def test_grpc_server_continues_transaction(sentry_init, capture_events_forksafe) sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) events = capture_events_forksafe() - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) + # Use the provided channel + stub = gRPCTestServiceStub(channel) - with start_transaction() as transaction: - metadata = ( - ( - "baggage", - "sentry-trace_id={trace_id},sentry-environment=test," - "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format( - trace_id=transaction.trace_id - ), + with start_transaction() as transaction: + metadata = ( + ( + "baggage", + "sentry-trace_id={trace_id},sentry-environment=test," + "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format( + trace_id=transaction.trace_id ), - ( - "sentry-trace", - "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=transaction.trace_id, - parent_span_id=transaction.span_id, - sampled=1, - ), + ), + ( + "sentry-trace", + "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=transaction.span_id, + sampled=1, ), - ) - stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata) + ), + ) + stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata) _tear_down(server=server) @@ -148,13 +157,13 @@ def test_grpc_client_starts_span(sentry_init, capture_events_forksafe): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) events = capture_events_forksafe() - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) + # Use the provided channel + stub = gRPCTestServiceStub(channel) - with start_transaction(): - stub.TestServe(gRPCTestMessage(text="test")) + with start_transaction(): + stub.TestServe(gRPCTestMessage(text="test")) _tear_down(server=server) @@ -183,13 +192,13 @@ def test_grpc_client_unary_stream_starts_span(sentry_init, capture_events_forksa sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) events = capture_events_forksafe() - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) + # Use the provided channel + stub = gRPCTestServiceStub(channel) - with start_transaction(): - [el for el in stub.TestUnaryStream(gRPCTestMessage(text="test"))] + with start_transaction(): + [el for el in stub.TestUnaryStream(gRPCTestMessage(text="test"))] _tear_down(server=server) @@ -227,14 +236,14 @@ def test_grpc_client_other_interceptor(sentry_init, capture_events_forksafe): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) events = capture_events_forksafe() - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - channel = grpc.intercept_channel(channel, MockClientInterceptor()) - stub = gRPCTestServiceStub(channel) + # Intercept the channel + channel = grpc.intercept_channel(channel, MockClientInterceptor()) + stub = gRPCTestServiceStub(channel) - with start_transaction(): - stub.TestServe(gRPCTestMessage(text="test")) + with start_transaction(): + stub.TestServe(gRPCTestMessage(text="test")) _tear_down(server=server) @@ -267,13 +276,13 @@ def test_grpc_client_and_servers_interceptors_integration( sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) events = capture_events_forksafe() - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) + # Use the provided channel + stub = gRPCTestServiceStub(channel) - with start_transaction(): - stub.TestServe(gRPCTestMessage(text="test")) + with start_transaction(): + stub.TestServe(gRPCTestMessage(text="test")) _tear_down(server=server) @@ -290,13 +299,13 @@ def test_grpc_client_and_servers_interceptors_integration( @pytest.mark.forked def test_stream_stream(sentry_init): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) - response_iterator = stub.TestStreamStream(iter((gRPCTestMessage(text="test"),))) - for response in response_iterator: - assert response.text == "test" + # Use the provided channel + stub = gRPCTestServiceStub(channel) + response_iterator = stub.TestStreamStream(iter((gRPCTestMessage(text="test"),))) + for response in response_iterator: + assert response.text == "test" _tear_down(server=server) @@ -308,12 +317,12 @@ def test_stream_unary(sentry_init): Tracing not supported for it yet. """ sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) - response = stub.TestStreamUnary(iter((gRPCTestMessage(text="test"),))) - assert response.text == "test" + # Use the provided channel + stub = gRPCTestServiceStub(channel) + response = stub.TestStreamUnary(iter((gRPCTestMessage(text="test"),))) + assert response.text == "test" _tear_down(server=server) @@ -323,13 +332,13 @@ def test_span_origin(sentry_init, capture_events_forksafe): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) events = capture_events_forksafe() - server = _set_up() + server, channel = _set_up() - with grpc.insecure_channel("localhost:{}".format(PORT)) as channel: - stub = gRPCTestServiceStub(channel) + # Use the provided channel + stub = gRPCTestServiceStub(channel) - with start_transaction(name="custom_transaction"): - stub.TestServe(gRPCTestMessage(text="test")) + with start_transaction(name="custom_transaction"): + stub.TestServe(gRPCTestMessage(text="test")) _tear_down(server=server) diff --git a/tests/integrations/grpc/test_grpc_aio.py b/tests/integrations/grpc/test_grpc_aio.py index 9ce9aef6a5..96e9a4dba8 100644 --- a/tests/integrations/grpc/test_grpc_aio.py +++ b/tests/integrations/grpc/test_grpc_aio.py @@ -1,5 +1,4 @@ import asyncio -import os import grpc import pytest @@ -17,37 +16,52 @@ gRPCTestServiceStub, ) -AIO_PORT = 50052 -AIO_PORT += os.getpid() % 100 # avoid port conflicts when running tests in parallel - @pytest_asyncio.fixture(scope="function") -async def grpc_server(sentry_init): +async def grpc_server_and_channel(sentry_init): + """ + Creates an async gRPC server and a channel connected to it. + Returns both for use in tests, and cleans up afterward. + """ sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) + + # Create server server = grpc.aio.server() - server.add_insecure_port("[::]:{}".format(AIO_PORT)) + + # Let gRPC choose a free port instead of hardcoding it + port = server.add_insecure_port("[::]:0") + + # Add service implementation add_gRPCTestServiceServicer_to_server(TestService, server) + # Start the server await asyncio.create_task(server.start()) + # Create channel connected to our server + channel = grpc.aio.insecure_channel(f"localhost:{port}") # noqa: E231 + try: - yield server + yield server, channel finally: + # Clean up resources + await channel.close() await server.stop(None) @pytest.mark.asyncio async def test_noop_for_unimplemented_method(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) - server = grpc.aio.server() - server.add_insecure_port("[::]:{}".format(AIO_PORT)) + # Create empty server with no services + server = grpc.aio.server() + port = server.add_insecure_port("[::]:0") # Let gRPC choose a free port await asyncio.create_task(server.start()) events = capture_events() + try: async with grpc.aio.insecure_channel( - "localhost:{}".format(AIO_PORT) + f"localhost:{port}" # noqa: E231 ) as channel: stub = gRPCTestServiceStub(channel) with pytest.raises(grpc.RpcError) as exc: @@ -60,12 +74,13 @@ async def test_noop_for_unimplemented_method(sentry_init, capture_events): @pytest.mark.asyncio -async def test_grpc_server_starts_transaction(grpc_server, capture_events): +async def test_grpc_server_starts_transaction(grpc_server_and_channel, capture_events): + _, channel = grpc_server_and_channel events = capture_events() - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - await stub.TestServe(gRPCTestMessage(text="test")) + # Use the provided channel + stub = gRPCTestServiceStub(channel) + await stub.TestServe(gRPCTestMessage(text="test")) (event,) = events span = event["spans"][0] @@ -79,32 +94,35 @@ async def test_grpc_server_starts_transaction(grpc_server, capture_events): @pytest.mark.asyncio -async def test_grpc_server_continues_transaction(grpc_server, capture_events): +async def test_grpc_server_continues_transaction( + grpc_server_and_channel, capture_events +): + _, channel = grpc_server_and_channel events = capture_events() - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - - with sentry_sdk.start_transaction() as transaction: - metadata = ( - ( - "baggage", - "sentry-trace_id={trace_id},sentry-environment=test," - "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format( - trace_id=transaction.trace_id - ), + # Use the provided channel + stub = gRPCTestServiceStub(channel) + + with sentry_sdk.start_transaction() as transaction: + metadata = ( + ( + "baggage", + "sentry-trace_id={trace_id},sentry-environment=test," + "sentry-transaction=test-transaction,sentry-sample_rate=1.0".format( + trace_id=transaction.trace_id ), - ( - "sentry-trace", - "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=transaction.trace_id, - parent_span_id=transaction.span_id, - sampled=1, - ), + ), + ( + "sentry-trace", + "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=transaction.span_id, + sampled=1, ), - ) + ), + ) - await stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata) + await stub.TestServe(gRPCTestMessage(text="test"), metadata=metadata) (event, _) = events span = event["spans"][0] @@ -119,16 +137,17 @@ async def test_grpc_server_continues_transaction(grpc_server, capture_events): @pytest.mark.asyncio -async def test_grpc_server_exception(grpc_server, capture_events): +async def test_grpc_server_exception(grpc_server_and_channel, capture_events): + _, channel = grpc_server_and_channel events = capture_events() - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - try: - await stub.TestServe(gRPCTestMessage(text="exception")) - raise AssertionError() - except Exception: - pass + # Use the provided channel + stub = gRPCTestServiceStub(channel) + try: + await stub.TestServe(gRPCTestMessage(text="exception")) + raise AssertionError() + except Exception: + pass (event, _) = events @@ -139,28 +158,35 @@ async def test_grpc_server_exception(grpc_server, capture_events): @pytest.mark.asyncio -async def test_grpc_server_abort(grpc_server, capture_events): +async def test_grpc_server_abort(grpc_server_and_channel, capture_events): + _, channel = grpc_server_and_channel 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 + # Use the provided channel + stub = gRPCTestServiceStub(channel) + try: + await stub.TestServe(gRPCTestMessage(text="abort")) + raise AssertionError() + except Exception: + pass + + # Add a small delay to allow events to be collected + await asyncio.sleep(0.1) assert len(events) == 1 @pytest.mark.asyncio -async def test_grpc_client_starts_span(grpc_server, capture_events_forksafe): +async def test_grpc_client_starts_span( + grpc_server_and_channel, capture_events_forksafe +): + _, channel = grpc_server_and_channel events = capture_events_forksafe() - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - with start_transaction(): - await stub.TestServe(gRPCTestMessage(text="test")) + # Use the provided channel + stub = gRPCTestServiceStub(channel) + with start_transaction(): + await stub.TestServe(gRPCTestMessage(text="test")) events.write_file.close() events.read_event() @@ -184,15 +210,16 @@ async def test_grpc_client_starts_span(grpc_server, capture_events_forksafe): @pytest.mark.asyncio async def test_grpc_client_unary_stream_starts_span( - grpc_server, capture_events_forksafe + grpc_server_and_channel, capture_events_forksafe ): + _, channel = grpc_server_and_channel events = capture_events_forksafe() - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - with start_transaction(): - response = stub.TestUnaryStream(gRPCTestMessage(text="test")) - [_ async for _ in response] + # Use the provided channel + stub = gRPCTestServiceStub(channel) + with start_transaction(): + response = stub.TestUnaryStream(gRPCTestMessage(text="test")) + [_ async for _ in response] events.write_file.close() local_transaction = events.read_event() @@ -213,38 +240,43 @@ async def test_grpc_client_unary_stream_starts_span( @pytest.mark.asyncio -async def test_stream_stream(grpc_server): +async def test_stream_stream(grpc_server_and_channel): """ Test to verify stream-stream works. Tracing not supported for it yet. """ - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - response = stub.TestStreamStream((gRPCTestMessage(text="test"),)) - async for r in response: - assert r.text == "test" + _, channel = grpc_server_and_channel + + # Use the provided channel + stub = gRPCTestServiceStub(channel) + response = stub.TestStreamStream((gRPCTestMessage(text="test"),)) + async for r in response: + assert r.text == "test" @pytest.mark.asyncio -async def test_stream_unary(grpc_server): +async def test_stream_unary(grpc_server_and_channel): """ Test to verify stream-stream works. Tracing not supported for it yet. """ - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - response = await stub.TestStreamUnary((gRPCTestMessage(text="test"),)) - assert response.text == "test" + _, channel = grpc_server_and_channel + + # Use the provided channel + stub = gRPCTestServiceStub(channel) + response = await stub.TestStreamUnary((gRPCTestMessage(text="test"),)) + assert response.text == "test" @pytest.mark.asyncio -async def test_span_origin(grpc_server, capture_events_forksafe): +async def test_span_origin(grpc_server_and_channel, capture_events_forksafe): + _, channel = grpc_server_and_channel events = capture_events_forksafe() - async with grpc.aio.insecure_channel("localhost:{}".format(AIO_PORT)) as channel: - stub = gRPCTestServiceStub(channel) - with start_transaction(name="custom_transaction"): - await stub.TestServe(gRPCTestMessage(text="test")) + # Use the provided channel + stub = gRPCTestServiceStub(channel) + with start_transaction(name="custom_transaction"): + await stub.TestServe(gRPCTestMessage(text="test")) events.write_file.close() @@ -283,7 +315,7 @@ async def TestServe(cls, request, context): # noqa: N802 raise cls.TestException() if request.text == "abort": - await context.abort(grpc.StatusCode.ABORTED) + await context.abort(grpc.StatusCode.ABORTED, "Aborted!") return gRPCTestMessage(text=request.text) From 8016aab4c5c31702473b492e49cf233baa8961c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 14:17:56 +0000 Subject: [PATCH 06/24] build(deps): bump actions/create-github-app-token from 1.12.0 to 2.0.2 (#4248) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed8b3e4094..a0e39a5784 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@3ff1caaa28b64c9cc276ce0a02e2ff584f3900c5 # v2.0.2 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From 2ba4ed096166bc6f797ffdccc1c8c5e8e3205c12 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 9 Apr 2025 08:54:25 +0200 Subject: [PATCH 07/24] toxgen: Retry & fail if we fail to fetch PyPI data (#4251) - try to refetch data if PyPI returns an error - if we fail after 3 tries, fail the whole script (it doesn't make sense to run it without access to up-to-date PyPI data) --- scripts/populate_tox/populate_tox.py | 56 +++++++++++++++++++--------- tox.ini | 18 ++++----- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index df45e30ed9..c405a2bc23 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -36,6 +36,8 @@ lstrip_blocks=True, ) +PYPI_COOLDOWN = 0.15 # seconds to wait between requests to PyPI + PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json" PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json" CLASSIFIER_PREFIX = "Programming Language :: Python :: " @@ -88,27 +90,34 @@ } -@functools.cache -def fetch_package(package: str) -> dict: - """Fetch package metadata from PyPI.""" - url = PYPI_PROJECT_URL.format(project=package) - pypi_data = requests.get(url) +def fetch_url(url: str) -> Optional[dict]: + for attempt in range(3): + pypi_data = requests.get(url) - if pypi_data.status_code != 200: - print(f"{package} not found") + if pypi_data.status_code == 200: + return pypi_data.json() - return pypi_data.json() + backoff = PYPI_COOLDOWN * 2**attempt + print( + f"{url} returned an error: {pypi_data.status_code}. Attempt {attempt + 1}/3. Waiting {backoff}s" + ) + time.sleep(backoff) + + return None @functools.cache -def fetch_release(package: str, version: Version) -> dict: - url = PYPI_VERSION_URL.format(project=package, version=version) - pypi_data = requests.get(url) +def fetch_package(package: str) -> Optional[dict]: + """Fetch package metadata from PyPI.""" + url = PYPI_PROJECT_URL.format(project=package) + return fetch_url(url) - if pypi_data.status_code != 200: - print(f"{package} not found") - return pypi_data.json() +@functools.cache +def fetch_release(package: str, version: Version) -> Optional[dict]: + """Fetch release metadata from PyPI.""" + url = PYPI_VERSION_URL.format(project=package, version=version) + return fetch_url(url) def _prefilter_releases( @@ -229,8 +238,14 @@ def get_supported_releases( expected_python_versions = SpecifierSet(f">={MIN_PYTHON_VERSION}") def _supports_lowest(release: Version) -> bool: - time.sleep(0.1) # don't DoS PYPI - py_versions = determine_python_versions(fetch_release(package, release)) + time.sleep(PYPI_COOLDOWN) # don't DoS PYPI + + pypi_data = fetch_release(package, release) + if pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) + + py_versions = determine_python_versions(pypi_data) target_python_versions = TEST_SUITE_CONFIG[integration].get("python") if target_python_versions: target_python_versions = SpecifierSet(target_python_versions) @@ -499,7 +514,11 @@ def _add_python_versions_to_release( integration: str, package: str, release: Version ) -> None: release_pypi_data = fetch_release(package, release) - time.sleep(0.1) # give PYPI some breathing room + if release_pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) + + time.sleep(PYPI_COOLDOWN) # give PYPI some breathing room target_python_versions = TEST_SUITE_CONFIG[integration].get("python") if target_python_versions: @@ -592,6 +611,9 @@ def main(fail_on_changes: bool = False) -> None: # Fetch data for the main package pypi_data = fetch_package(package) + if pypi_data is None: + print("Failed to fetch necessary data from PyPI. Aborting.") + sys.exit(1) # Get the list of all supported releases diff --git a/tox.ini b/tox.ini index 1854b0f711..c04691e2ac 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-04-03T11:46:44.595900+00:00 +# Last generated: 2025-04-08T10:33:11.499210+00:00 [tox] requires = @@ -179,7 +179,7 @@ envlist = {py3.7,py3.12,py3.13}-statsig-v0.55.3 {py3.7,py3.12,py3.13}-statsig-v0.56.0 - {py3.7,py3.12,py3.13}-statsig-v0.57.1 + {py3.7,py3.12,py3.13}-statsig-v0.57.2 {py3.8,py3.12,py3.13}-unleash-v6.0.1 {py3.8,py3.12,py3.13}-unleash-v6.1.0 @@ -202,7 +202,7 @@ envlist = {py3.8,py3.10,py3.11}-strawberry-v0.209.8 {py3.8,py3.11,py3.12}-strawberry-v0.227.7 {py3.8,py3.11,py3.12}-strawberry-v0.245.0 - {py3.9,py3.12,py3.13}-strawberry-v0.263.0 + {py3.9,py3.12,py3.13}-strawberry-v0.263.2 # ~~~ Network ~~~ @@ -215,7 +215,7 @@ envlist = # ~~~ Tasks ~~~ {py3.6,py3.7,py3.8}-celery-v4.4.7 {py3.6,py3.7,py3.8}-celery-v5.0.5 - {py3.8,py3.12,py3.13}-celery-v5.5.0 + {py3.8,py3.12,py3.13}-celery-v5.5.1 {py3.6,py3.7}-dramatiq-v1.9.0 {py3.6,py3.8,py3.9}-dramatiq-v1.12.3 @@ -260,7 +260,7 @@ envlist = {py3.8,py3.10,py3.11}-litestar-v2.0.1 {py3.8,py3.11,py3.12}-litestar-v2.5.5 {py3.8,py3.11,py3.12}-litestar-v2.10.0 - {py3.8,py3.12,py3.13}-litestar-v2.15.1 + {py3.8,py3.12,py3.13}-litestar-v2.15.2 {py3.6}-pyramid-v1.8.6 {py3.6,py3.8,py3.9}-pyramid-v1.10.8 @@ -542,7 +542,7 @@ deps = statsig-v0.55.3: statsig==0.55.3 statsig-v0.56.0: statsig==0.56.0 - statsig-v0.57.1: statsig==0.57.1 + statsig-v0.57.2: statsig==0.57.2 statsig: typing_extensions unleash-v6.0.1: UnleashClient==6.0.1 @@ -574,7 +574,7 @@ deps = strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8 strawberry-v0.227.7: strawberry-graphql[fastapi,flask]==0.227.7 strawberry-v0.245.0: strawberry-graphql[fastapi,flask]==0.245.0 - strawberry-v0.263.0: strawberry-graphql[fastapi,flask]==0.263.0 + strawberry-v0.263.2: strawberry-graphql[fastapi,flask]==0.263.2 strawberry: httpx strawberry-v0.209.8: pydantic<2.11 strawberry-v0.227.7: pydantic<2.11 @@ -595,7 +595,7 @@ deps = # ~~~ Tasks ~~~ celery-v4.4.7: celery==4.4.7 celery-v5.0.5: celery==5.0.5 - celery-v5.5.0: celery==5.5.0 + celery-v5.5.1: celery==5.5.1 celery: newrelic celery: redis py3.7-celery: importlib-metadata<5.0 @@ -683,7 +683,7 @@ deps = litestar-v2.0.1: litestar==2.0.1 litestar-v2.5.5: litestar==2.5.5 litestar-v2.10.0: litestar==2.10.0 - litestar-v2.15.1: litestar==2.15.1 + litestar-v2.15.2: litestar==2.15.2 litestar: pytest-asyncio litestar: python-multipart litestar: requests From 7cb0451865f82f3b6382c574ef57014a68f77c4f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 9 Apr 2025 09:47:59 +0200 Subject: [PATCH 08/24] feat(tests): Add optional cutoff to toxgen (#4243) This will be useful to identify old versions of packages when we're doing a deprecation round. --- scripts/populate_tox/populate_tox.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index c405a2bc23..58dbed0308 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -9,7 +9,7 @@ import time from bisect import bisect_left from collections import defaultdict -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone # noqa: F401 from importlib.metadata import metadata from packaging.specifiers import SpecifierSet from packaging.version import Version @@ -29,6 +29,10 @@ from split_tox_gh_actions.split_tox_gh_actions import GROUPS +# Set CUTOFF this to a datetime to ignore packages older than CUTOFF +CUTOFF = None +# CUTOFF = datetime.now(tz=timezone.utc) - timedelta(days=365 * 5) + TOX_FILE = Path(__file__).resolve().parent.parent.parent / "tox.ini" ENV = Environment( loader=FileSystemLoader(Path(__file__).resolve().parent), @@ -162,9 +166,13 @@ def _prefilter_releases( if meta["yanked"]: continue - if older_than is not None: - if datetime.fromisoformat(meta["upload_time_iso_8601"]) > older_than: - continue + uploaded = datetime.fromisoformat(meta["upload_time_iso_8601"]) + + if older_than is not None and uploaded > older_than: + continue + + if CUTOFF is not None and uploaded < CUTOFF: + continue version = Version(release) From 6a1364d4bb27b4d15f829f36dabbb18cb8f32cdf Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 9 Apr 2025 10:25:43 +0200 Subject: [PATCH 09/24] feat(logs): Add sentry.origin attribute for log handler (#4250) resolves https://linear.app/getsentry/issue/LOGS-13 Docs: https://develop-docs-git-abhi-logs-sdk-developer-documentation.sentry.dev/sdk/telemetry/logs/#default-attributes > If a log is generated by an SDK integration, the SDK should also set the sentry.origin attribute, as per the [Trace Origin](https://develop-docs-git-abhi-logs-sdk-developer-documentation.sentry.dev/sdk/telemetry/logs/traces/trace-origin/) documentation. It is assumed that logs without a sentry.origin attribute are manually created by the user. --- sentry_sdk/integrations/logging.py | 4 +++- tests/test_logs.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index ba6e6581b7..1fbecb2e08 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -358,7 +358,9 @@ def _capture_log_from_record(client, record): # type: (BaseClient, LogRecord) -> None scope = sentry_sdk.get_current_scope() otel_severity_number, otel_severity_text = _python_level_to_otel(record.levelno) - attrs = {} # type: dict[str, str | bool | float | int] + attrs = { + "sentry.origin": "auto.logger.log", + } # type: dict[str, str | bool | float | int] if isinstance(record.msg, str): attrs["sentry.message.template"] = record.msg if record.args is not None: diff --git a/tests/test_logs.py b/tests/test_logs.py index 1305f243de..fb824760a8 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -283,6 +283,7 @@ def test_logger_integration_warning(sentry_init, capture_envelopes): assert attrs["sentry.environment"] == "production" assert attrs["sentry.message.parameters.0"] == "1" assert attrs["sentry.message.parameters.1"] == "2" + assert attrs["sentry.origin"] == "auto.logger.log" assert logs[0]["severity_number"] == 13 assert logs[0]["severity_text"] == "warn" From e05ed0aa62cfe2c992b26b07c64c3148f837a609 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 9 Apr 2025 10:57:50 +0200 Subject: [PATCH 10/24] chore: Deprecate `same_process_as_parent` (#4244) Preparing to remove this in https://github.com/getsentry/sentry-python/pull/4201 --- sentry_sdk/tracing.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 13d9f63d5e..ab1a7a8fdf 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -323,6 +323,13 @@ def __init__( self.scope = self.scope or hub.scope + if same_process_as_parent is not None: + warnings.warn( + "The `same_process_as_parent` parameter is deprecated.", + DeprecationWarning, + stacklevel=2, + ) + if start_timestamp is None: start_timestamp = datetime.now(timezone.utc) elif isinstance(start_timestamp, float): From acf508cb38c633cbf95561343684e964876dd32c Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 9 Apr 2025 15:43:48 +0200 Subject: [PATCH 11/24] feat(logs): Add server.address to logs (#4257) Docs: https://develop-docs-git-abhi-logs-sdk-developer-documentation.sentry.dev/sdk/telemetry/logs/#default-attributes > [BACKEND SDKS ONLY] `server.address`: The address of the server that sent the log. Equivalent to server_name we attach to errors and transactions. `server.address` convention docs: https://getsentry.github.io/sentry-conventions/generated/attributes/server.html#serveraddress resolves https://linear.app/getsentry/issue/LOGS-33 --- sentry_sdk/client.py | 5 +++++ tests/test_logs.py | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 4dfccb3132..102392c61d 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -27,6 +27,7 @@ from sentry_sdk.tracing import trace from sentry_sdk.transport import BaseHttpTransport, make_transport from sentry_sdk.consts import ( + SPANDATA, DEFAULT_MAX_VALUE_LENGTH, DEFAULT_OPTIONS, INSTRUMENTER, @@ -894,6 +895,10 @@ def _capture_experimental_log(self, current_scope, log): return isolation_scope = current_scope.get_isolation_scope() + server_name = self.options.get("server_name") + if server_name is not None and SPANDATA.SERVER_ADDRESS not in log["attributes"]: + log["attributes"][SPANDATA.SERVER_ADDRESS] = server_name + environment = self.options.get("environment") if environment is not None and "sentry.environment" not in log["attributes"]: log["attributes"]["sentry.environment"] = environment diff --git a/tests/test_logs.py b/tests/test_logs.py index fb824760a8..d58aa9acdd 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -11,6 +11,7 @@ from sentry_sdk.envelope import Envelope from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.types import Log +from sentry_sdk.consts import SPANDATA minimum_python_37 = pytest.mark.skipif( sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7" @@ -161,7 +162,7 @@ def test_logs_attributes(sentry_init, capture_envelopes): """ Passing arbitrary attributes to log messages. """ - sentry_init(_experiments={"enable_logs": True}) + sentry_init(_experiments={"enable_logs": True}, server_name="test-server") envelopes = capture_envelopes() attrs = { @@ -184,6 +185,7 @@ def test_logs_attributes(sentry_init, capture_envelopes): assert logs[0]["attributes"]["sentry.environment"] == "production" assert "sentry.release" in logs[0]["attributes"] assert logs[0]["attributes"]["sentry.message.parameters.my_var"] == "some value" + assert logs[0]["attributes"][SPANDATA.SERVER_ADDRESS] == "test-server" @minimum_python_37 From 97c435a82c4ddca2706794ed90b74f6527f8162f Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 9 Apr 2025 16:00:16 +0200 Subject: [PATCH 12/24] feat(logs): Add sdk name and version as log attributes (#4262) Docs: https://develop-docs-git-abhi-logs-sdk-developer-documentation.sentry.dev/sdk/telemetry/logs/#default-attributes > sentry.sdk.name: The name of the SDK that sent the log > sentry.sdk.version: The version of the SDK that sent the log convention docs: - `sentry.sdk.name`: https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentrysdkname - `sentry.sdk.version`: https://getsentry.github.io/sentry-conventions/generated/attributes/sentry.html#sentrysdkversion resolves https://linear.app/getsentry/issue/PY-1/ --- sentry_sdk/client.py | 3 +++ tests/test_logs.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 102392c61d..f06166bcc8 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -895,6 +895,9 @@ def _capture_experimental_log(self, current_scope, log): return isolation_scope = current_scope.get_isolation_scope() + log["attributes"]["sentry.sdk.name"] = SDK_INFO["name"] + log["attributes"]["sentry.sdk.version"] = SDK_INFO["version"] + server_name = self.options.get("server_name") if server_name is not None and SPANDATA.SERVER_ADDRESS not in log["attributes"]: log["attributes"][SPANDATA.SERVER_ADDRESS] = server_name diff --git a/tests/test_logs.py b/tests/test_logs.py index d58aa9acdd..1c34d52b20 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -11,7 +11,7 @@ from sentry_sdk.envelope import Envelope from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.types import Log -from sentry_sdk.consts import SPANDATA +from sentry_sdk.consts import SPANDATA, VERSION minimum_python_37 = pytest.mark.skipif( sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7" @@ -186,6 +186,8 @@ def test_logs_attributes(sentry_init, capture_envelopes): assert "sentry.release" in logs[0]["attributes"] assert logs[0]["attributes"]["sentry.message.parameters.my_var"] == "some value" assert logs[0]["attributes"][SPANDATA.SERVER_ADDRESS] == "test-server" + assert logs[0]["attributes"]["sentry.sdk.name"] == "sentry.python" + assert logs[0]["attributes"]["sentry.sdk.version"] == VERSION @minimum_python_37 From fb6d3745c8d7aef20142dbca708c884f63f7f821 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 10 Apr 2025 10:49:17 +0200 Subject: [PATCH 13/24] meta: Change CODEOWNERS back to Python SDK owners (#4269) Don't spam the whole backend SDK team on each PR. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e5d24f170c..1dc1a4882f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @getsentry/team-web-sdk-backend +* @getsentry/owners-python-sdk From 6000f87d2d3ec77fc4a1ec391d357ff3969a873b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 10 Apr 2025 11:44:10 +0200 Subject: [PATCH 14/24] feat(transport): Add a timeout (#4252) For some reason, we don't define any timeouts in our default transport(s). With this change: - We add a 30s total timeout for the whole connect+read cycle in the default HTTP transport - In the experimental HTTP/2 httpcore-based transport there is no way to set a single timeout, so we set 15s each for getting a connection from the pool, connecting, writing, and reading Backend SDKs in general set wildly different timeouts, from 30s in Go to <5s in Ruby or PHP. I went for the higher end of the range here since this is mainly meant to prevent the SDK preventing process shutdown like described in https://github.com/getsentry/sentry-python/issues/4247 -- we don't want to cut off legitimate requests that are just taking a long time. (I was considering going even higher, maybe to 60s -- but I think 30s is a good first shot at this and we can always change it later.) --- sentry_sdk/transport.py | 13 +++++++++++++ tests/test_transport.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index efc955ca7b..f9a5262903 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -196,6 +196,8 @@ def _parse_rate_limits(header, now=None): class BaseHttpTransport(Transport): """The base HTTP transport.""" + TIMEOUT = 30 # seconds + def __init__(self, options): # type: (Self, Dict[str, Any]) -> None from sentry_sdk.consts import VERSION @@ -621,6 +623,7 @@ def _get_pool_options(self): options = { "num_pools": 2 if num_pools is None else int(num_pools), "cert_reqs": "CERT_REQUIRED", + "timeout": urllib3.Timeout(total=self.TIMEOUT), } socket_options = None # type: Optional[List[Tuple[int, int, int | bytes]]] @@ -736,6 +739,8 @@ def __init__(self, options): class Http2Transport(BaseHttpTransport): # type: ignore """The HTTP2 transport based on httpcore.""" + TIMEOUT = 15 + if TYPE_CHECKING: _pool: Union[ httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool @@ -765,6 +770,14 @@ def _request( self._auth.get_api_url(endpoint_type), content=body, headers=headers, # type: ignore + extensions={ + "timeout": { + "pool": self.TIMEOUT, + "connect": self.TIMEOUT, + "write": self.TIMEOUT, + "read": self.TIMEOUT, + } + }, ) return response diff --git a/tests/test_transport.py b/tests/test_transport.py index d24bea0491..6eb7cdf829 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -14,6 +14,11 @@ from pytest_localserver.http import WSGIServer from werkzeug.wrappers import Request, Response +try: + import httpcore +except (ImportError, ModuleNotFoundError): + httpcore = None + try: import gevent except ImportError: @@ -274,6 +279,37 @@ def test_keep_alive_on_by_default(make_client): assert "socket_options" not in options +def test_default_timeout(make_client): + client = make_client() + + options = client.transport._get_pool_options() + assert "timeout" in options + assert options["timeout"].total == client.transport.TIMEOUT + + +@pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+") +def test_default_timeout_http2(make_client): + client = make_client(_experiments={"transport_http2": True}) + + with mock.patch( + "sentry_sdk.transport.httpcore.ConnectionPool.request", + return_value=httpcore.Response(200), + ) as request_mock: + sentry_sdk.get_global_scope().set_client(client) + capture_message("hi") + client.flush() + + request_mock.assert_called_once() + assert request_mock.call_args.kwargs["extensions"] == { + "timeout": { + "pool": client.transport.TIMEOUT, + "connect": client.transport.TIMEOUT, + "write": client.transport.TIMEOUT, + "read": client.transport.TIMEOUT, + } + } + + @pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+") def test_http2_with_https_dsn(make_client): client = make_client(_experiments={"transport_http2": True}) From be229121608feba3033dbe84ef1884b6ba6ad3ee Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:16:38 +0200 Subject: [PATCH 15/24] test(tracing): Simplify static/classmethod tracing tests (#4278) These tests were causing flakes where the mock method was being called more than once. The tests were also difficult to understand. This change removes the need for mocking (hopefully increasing test stability) and also should hopefully make it easier to understand what these tests are meant to be checking --- tests/test_basics.py | 119 +++++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 33 deletions(-) diff --git a/tests/test_basics.py b/tests/test_basics.py index e16956979a..94ced5013a 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -9,7 +9,6 @@ import pytest from sentry_sdk.client import Client from sentry_sdk.utils import datetime_from_isoformat -from tests.conftest import patch_start_tracing_child import sentry_sdk import sentry_sdk.scope @@ -935,46 +934,100 @@ def class_(cls, arg): return cls, arg -def test_staticmethod_tracing(sentry_init): - test_staticmethod_name = "tests.test_basics.TracingTestClass.static" +# We need to fork here because the test modifies tests.test_basics.TracingTestClass +@pytest.mark.forked +def test_staticmethod_class_tracing(sentry_init, capture_events): + sentry_init( + debug=True, + traces_sample_rate=1.0, + functions_to_trace=[ + {"qualified_name": "tests.test_basics.TracingTestClass.static"} + ], + ) - assert ( - ".".join( - [ - TracingTestClass.static.__module__, - TracingTestClass.static.__qualname__, - ] - ) - == test_staticmethod_name - ), "The test static method was moved or renamed. Please update the name accordingly" + events = capture_events() - sentry_init(functions_to_trace=[{"qualified_name": test_staticmethod_name}]) + with sentry_sdk.start_transaction(name="test"): + assert TracingTestClass.static(1) == 1 - for instance_or_class in (TracingTestClass, TracingTestClass()): - with patch_start_tracing_child() as fake_start_child: - assert instance_or_class.static(1) == 1 - assert fake_start_child.call_count == 1 + (event,) = events + assert event["type"] == "transaction" + assert event["transaction"] == "test" + (span,) = event["spans"] + assert span["description"] == "tests.test_basics.TracingTestClass.static" -def test_classmethod_tracing(sentry_init): - test_classmethod_name = "tests.test_basics.TracingTestClass.class_" - assert ( - ".".join( - [ - TracingTestClass.class_.__module__, - TracingTestClass.class_.__qualname__, - ] - ) - == test_classmethod_name - ), "The test class method was moved or renamed. Please update the name accordingly" +# We need to fork here because the test modifies tests.test_basics.TracingTestClass +@pytest.mark.forked +def test_staticmethod_instance_tracing(sentry_init, capture_events): + sentry_init( + debug=True, + traces_sample_rate=1.0, + functions_to_trace=[ + {"qualified_name": "tests.test_basics.TracingTestClass.static"} + ], + ) + + events = capture_events() + + with sentry_sdk.start_transaction(name="test"): + assert TracingTestClass().static(1) == 1 + + (event,) = events + assert event["type"] == "transaction" + assert event["transaction"] == "test" - sentry_init(functions_to_trace=[{"qualified_name": test_classmethod_name}]) + (span,) = event["spans"] + assert span["description"] == "tests.test_basics.TracingTestClass.static" + + +# We need to fork here because the test modifies tests.test_basics.TracingTestClass +@pytest.mark.forked +def test_classmethod_class_tracing(sentry_init, capture_events): + sentry_init( + debug=True, + traces_sample_rate=1.0, + functions_to_trace=[ + {"qualified_name": "tests.test_basics.TracingTestClass.class_"} + ], + ) + + events = capture_events() + + with sentry_sdk.start_transaction(name="test"): + assert TracingTestClass.class_(1) == (TracingTestClass, 1) + + (event,) = events + assert event["type"] == "transaction" + assert event["transaction"] == "test" + + (span,) = event["spans"] + assert span["description"] == "tests.test_basics.TracingTestClass.class_" + + +# We need to fork here because the test modifies tests.test_basics.TracingTestClass +@pytest.mark.forked +def test_classmethod_instance_tracing(sentry_init, capture_events): + sentry_init( + debug=True, + traces_sample_rate=1.0, + functions_to_trace=[ + {"qualified_name": "tests.test_basics.TracingTestClass.class_"} + ], + ) + + events = capture_events() + + with sentry_sdk.start_transaction(name="test"): + assert TracingTestClass().class_(1) == (TracingTestClass, 1) + + (event,) = events + assert event["type"] == "transaction" + assert event["transaction"] == "test" - for instance_or_class in (TracingTestClass, TracingTestClass()): - with patch_start_tracing_child() as fake_start_child: - assert instance_or_class.class_(1) == (TracingTestClass, 1) - assert fake_start_child.call_count == 1 + (span,) = event["spans"] + assert span["description"] == "tests.test_basics.TracingTestClass.class_" def test_last_event_id(sentry_init): From 5689bc09fd223f80f65290e2ccb685b8acb9a5f2 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:41:46 +0200 Subject: [PATCH 16/24] fix(debug): Do not consider parent loggers for debug logging (#4286) This reverts commit 37930840dcefba96e7708b19e461013a919e83a5, which made the SDK consider parent loggers when determining if the Sentry SDK should log debug messages. However, we should not consider parent loggers, since we only want the SDK to log debug messages when configured to do so via `debug=True` (in `sentry_sdk.init`), the `SENTRY_DEBUG` environment variable, or via a specific logger configuration for `sentry_sdk.errors`. With 37930840dcefba96e7708b19e461013a919e83a5, a custom root logger configuration would also cause SDK logs to be emitted. The issue 37930840dcefba96e7708b19e461013a919e83a5 was meant to fix (#3944) will require a different fix. Fixes #4266 --- sentry_sdk/debug.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/debug.py b/sentry_sdk/debug.py index f740d92dec..e4c686a3e8 100644 --- a/sentry_sdk/debug.py +++ b/sentry_sdk/debug.py @@ -19,7 +19,7 @@ def filter(self, record): def init_debug_support(): # type: () -> None - if not logger.hasHandlers(): + if not logger.handlers: configure_logger() From 54d2c7e37b0f31ffcbd43e1f904ee9e2d8f4b650 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 14 Apr 2025 13:45:15 +0000 Subject: [PATCH 17/24] release: 2.26.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9294eaec1..5327b323a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 2.26.0 + +### Various fixes & improvements + +- fix(debug): Do not consider parent loggers for debug logging (#4286) by @szokeasaurusrex +- test(tracing): Simplify static/classmethod tracing tests (#4278) by @szokeasaurusrex +- feat(transport): Add a timeout (#4252) by @sentrivana +- meta: Change CODEOWNERS back to Python SDK owners (#4269) by @sentrivana +- feat(logs): Add sdk name and version as log attributes (#4262) by @AbhiPrasad +- feat(logs): Add server.address to logs (#4257) by @AbhiPrasad +- chore: Deprecate `same_process_as_parent` (#4244) by @sentrivana +- feat(logs): Add sentry.origin attribute for log handler (#4250) by @AbhiPrasad +- feat(tests): Add optional cutoff to toxgen (#4243) by @sentrivana +- toxgen: Retry & fail if we fail to fetch PyPI data (#4251) by @sentrivana +- build(deps): bump actions/create-github-app-token from 1.12.0 to 2.0.2 (#4248) by @dependabot +- Trying to prevent the grpc setup from being flaky (#4233) by @antonpirker +- feat(breadcrumbs): add `_meta` information for truncation of breadcrumbs (#4007) by @shellmayr +- tests: Move django under toxgen (#4238) by @sentrivana +- fix: Handle JSONDecodeError gracefully in StarletteRequestExtractor (#4226) by @moodix +- fix(asyncio): Remove shutdown handler (#4237) by @sentrivana + ## 2.25.1 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 2f575d3097..9c137d70a9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.25.1" +release = "2.26.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index c0f6ff66c6..19d39acdc0 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -966,4 +966,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.25.1" +VERSION = "2.26.0" diff --git a/setup.py b/setup.py index 6de160dcfb..6c33887cf5 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.25.1", + version="2.26.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From e71ccbf19f644fe7928db37f6e4a09e1febbc4e2 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Mon, 14 Apr 2025 17:56:14 +0200 Subject: [PATCH 18/24] fix(logging): Send raw logging parameters This reverts commit 4c9731bbe68b6523cccec73fb764e04e61e441cb, adding tests to ensure the correct behavior going forward. That commit caused a regression when `record.args` contains a dictionary. Because we iterate over `record.args`, that change caused us to only send the dictionary's keys, not the values. A more robust fix for #3660 will be to send the formatted message in the [`formatted` field](https://develop.sentry.dev/sdk/data-model/event-payloads/message/) (which we have not been doing yet). I will open a follow-up PR to do this. Fixes #4267 --- sentry_sdk/integrations/logging.py | 6 +---- tests/integrations/logging/test_logging.py | 30 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 1fbecb2e08..26ee957b27 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -265,11 +265,7 @@ def _emit(self, record): else: event["logentry"] = { "message": to_string(record.msg), - "params": ( - tuple(str(arg) if arg is None else arg for arg in record.args) - if record.args - else () - ), + "params": record.args, } event["extra"] = self._extra_from_record(record) diff --git a/tests/integrations/logging/test_logging.py b/tests/integrations/logging/test_logging.py index 8c325bc86c..5b48540bb0 100644 --- a/tests/integrations/logging/test_logging.py +++ b/tests/integrations/logging/test_logging.py @@ -234,3 +234,33 @@ def test_ignore_logger_wildcard(sentry_init, capture_events): (event,) = events assert event["logentry"]["message"] == "hi" + + +def test_logging_dictionary_interpolation(sentry_init, capture_events): + """Here we test an entire dictionary being interpolated into the log message.""" + sentry_init(integrations=[LoggingIntegration()], default_integrations=False) + events = capture_events() + + logger.error("this is a log with a dictionary %s", {"foo": "bar"}) + + (event,) = events + assert event["logentry"]["message"] == "this is a log with a dictionary %s" + assert event["logentry"]["params"] == {"foo": "bar"} + + +def test_logging_dictionary_args(sentry_init, capture_events): + """Here we test items from a dictionary being interpolated into the log message.""" + sentry_init(integrations=[LoggingIntegration()], default_integrations=False) + events = capture_events() + + logger.error( + "the value of foo is %(foo)s, and the value of bar is %(bar)s", + {"foo": "bar", "bar": "baz"}, + ) + + (event,) = events + assert ( + event["logentry"]["message"] + == "the value of foo is %(foo)s, and the value of bar is %(bar)s" + ) + assert event["logentry"]["params"] == {"foo": "bar", "bar": "baz"} From 296e288e437b3e690bb7485f1d062f7f33ac373b Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Mon, 14 Apr 2025 18:23:06 +0200 Subject: [PATCH 19/24] feat(logging): Add formatted message to log events Send the formatted log event to Sentry in the [`formatted` field](https://develop.sentry.dev/sdk/data-model/event-payloads/message/). This builds on #4291, providing a more robust fix for #3660. --- sentry_sdk/integrations/logging.py | 2 ++ tests/integrations/logging/test_logging.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 26ee957b27..ec13c86c6e 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -259,11 +259,13 @@ def _emit(self, record): event["logentry"] = { "message": msg, + "formatted": record.getMessage(), "params": (), } else: event["logentry"] = { + "formatted": record.getMessage(), "message": to_string(record.msg), "params": record.args, } diff --git a/tests/integrations/logging/test_logging.py b/tests/integrations/logging/test_logging.py index 5b48540bb0..c08e960c00 100644 --- a/tests/integrations/logging/test_logging.py +++ b/tests/integrations/logging/test_logging.py @@ -26,6 +26,7 @@ def test_logging_works_with_many_loggers(sentry_init, capture_events, logger): assert event["level"] == "fatal" assert not event["logentry"]["params"] assert event["logentry"]["message"] == "LOL" + assert event["logentry"]["formatted"] == "LOL" assert any(crumb["message"] == "bread" for crumb in event["breadcrumbs"]["values"]) @@ -112,6 +113,7 @@ def test_logging_level(sentry_init, capture_events): (event,) = events assert event["level"] == "error" assert event["logentry"]["message"] == "hi" + assert event["logentry"]["formatted"] == "hi" del events[:] @@ -152,6 +154,7 @@ def test_custom_log_level_names(sentry_init, capture_events): assert events assert events[0]["level"] == sentry_level assert events[0]["logentry"]["message"] == "Trying level %s" + assert events[0]["logentry"]["formatted"] == f"Trying level {logging_level}" assert events[0]["logentry"]["params"] == [logging_level] del events[:] @@ -177,6 +180,7 @@ def filter(self, record): (event,) = events assert event["logentry"]["message"] == "hi" + assert event["logentry"]["formatted"] == "hi" def test_logging_captured_warnings(sentry_init, capture_events, recwarn): @@ -198,10 +202,16 @@ def test_logging_captured_warnings(sentry_init, capture_events, recwarn): assert events[0]["level"] == "warning" # Captured warnings start with the path where the warning was raised assert "UserWarning: first" in events[0]["logentry"]["message"] + assert "UserWarning: first" in events[0]["logentry"]["formatted"] + # For warnings, the message and formatted message are the same + assert events[0]["logentry"]["message"] == events[0]["logentry"]["formatted"] assert events[0]["logentry"]["params"] == [] assert events[1]["level"] == "warning" assert "UserWarning: second" in events[1]["logentry"]["message"] + assert "UserWarning: second" in events[1]["logentry"]["formatted"] + # For warnings, the message and formatted message are the same + assert events[1]["logentry"]["message"] == events[1]["logentry"]["formatted"] assert events[1]["logentry"]["params"] == [] # Using recwarn suppresses the "third" warning in the test output @@ -234,6 +244,7 @@ def test_ignore_logger_wildcard(sentry_init, capture_events): (event,) = events assert event["logentry"]["message"] == "hi" + assert event["logentry"]["formatted"] == "hi" def test_logging_dictionary_interpolation(sentry_init, capture_events): @@ -245,6 +256,10 @@ def test_logging_dictionary_interpolation(sentry_init, capture_events): (event,) = events assert event["logentry"]["message"] == "this is a log with a dictionary %s" + assert ( + event["logentry"]["formatted"] + == "this is a log with a dictionary {'foo': 'bar'}" + ) assert event["logentry"]["params"] == {"foo": "bar"} @@ -263,4 +278,8 @@ def test_logging_dictionary_args(sentry_init, capture_events): event["logentry"]["message"] == "the value of foo is %(foo)s, and the value of bar is %(bar)s" ) + assert ( + event["logentry"]["formatted"] + == "the value of foo is bar, and the value of bar is baz" + ) assert event["logentry"]["params"] == {"foo": "bar", "bar": "baz"} From 706d2d29e68848a3cb085f043287d908255344b5 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 15 Apr 2025 12:14:49 +0200 Subject: [PATCH 20/24] Revert "chore: Deprecate `same_process_as_parent` (#4244)" (#4290) This reverts commit e05ed0aa62cfe2c992b26b07c64c3148f837a609. `same_process_as_parent` is `True` by default, so we actually don't have a way of detecting whether this was set explicitly by the user or not. Removing the deprecation altogether -- no one's using this. Closes https://github.com/getsentry/sentry-python/issues/4289 --- sentry_sdk/tracing.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index ab1a7a8fdf..13d9f63d5e 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -323,13 +323,6 @@ def __init__( self.scope = self.scope or hub.scope - if same_process_as_parent is not None: - warnings.warn( - "The `same_process_as_parent` parameter is deprecated.", - DeprecationWarning, - stacklevel=2, - ) - if start_timestamp is None: start_timestamp = datetime.now(timezone.utc) elif isinstance(start_timestamp, float): From 2d392af3ea6da91ddbdde55d18e15c24dce6b59b Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Tue, 15 Apr 2025 12:30:05 +0200 Subject: [PATCH 21/24] fix: Data leak in ThreadingIntegration between threads (#4281) It is possible to leak data from started threads into the main thread via the scopes. (Because the same scope object from the main thread could be changed in the started thread.) This change always makes a fork (copy) of the scopes of the main thread before it propagates those scopes into the started thread. --- sentry_sdk/integrations/threading.py | 33 +++++- tests/integrations/django/asgi/test_asgi.py | 22 +++- .../integrations/threading/test_threading.py | 101 ++++++++++++++++++ 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/threading.py b/sentry_sdk/integrations/threading.py index 5de736e23b..9c99a8e896 100644 --- a/sentry_sdk/integrations/threading.py +++ b/sentry_sdk/integrations/threading.py @@ -1,4 +1,5 @@ import sys +import warnings from functools import wraps from threading import Thread, current_thread @@ -49,6 +50,15 @@ def setup_once(): # type: () -> None old_start = Thread.start + try: + from django import VERSION as django_version # noqa: N811 + import channels # type: ignore[import-not-found] + + channels_version = channels.__version__ + except ImportError: + django_version = None + channels_version = None + @wraps(old_start) def sentry_start(self, *a, **kw): # type: (Thread, *Any, **Any) -> Any @@ -57,8 +67,27 @@ def sentry_start(self, *a, **kw): return old_start(self, *a, **kw) if integration.propagate_scope: - isolation_scope = sentry_sdk.get_isolation_scope() - current_scope = sentry_sdk.get_current_scope() + if ( + sys.version_info < (3, 9) + and channels_version is not None + and channels_version < "4.0.0" + and django_version is not None + and django_version >= (3, 0) + and django_version < (4, 0) + ): + warnings.warn( + "There is a known issue with Django channels 2.x and 3.x when using Python 3.8 or older. " + "(Async support is emulated using threads and some Sentry data may be leaked between those threads.) " + "Please either upgrade to Django channels 4.0+, use Django's async features " + "available in Django 3.1+ instead of Django channels, or upgrade to Python 3.9+.", + stacklevel=2, + ) + isolation_scope = sentry_sdk.get_isolation_scope() + current_scope = sentry_sdk.get_current_scope() + + else: + isolation_scope = sentry_sdk.get_isolation_scope().fork() + current_scope = sentry_sdk.get_current_scope().fork() else: isolation_scope = None current_scope = None diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index 063aed63ad..82eae30b1d 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -38,9 +38,25 @@ async def test_basic(sentry_init, capture_events, application): events = capture_events() - comm = HttpCommunicator(application, "GET", "/view-exc?test=query") - response = await comm.get_response() - await comm.wait() + import channels # type: ignore[import-not-found] + + if ( + sys.version_info < (3, 9) + and channels.__version__ < "4.0.0" + and django.VERSION >= (3, 0) + and django.VERSION < (4, 0) + ): + # We emit a UserWarning for channels 2.x and 3.x on Python 3.8 and older + # because the async support was not really good back then and there is a known issue. + # See the TreadingIntegration for details. + with pytest.warns(UserWarning): + comm = HttpCommunicator(application, "GET", "/view-exc?test=query") + response = await comm.get_response() + await comm.wait() + else: + comm = HttpCommunicator(application, "GET", "/view-exc?test=query") + response = await comm.get_response() + await comm.wait() assert response["status"] == 500 diff --git a/tests/integrations/threading/test_threading.py b/tests/integrations/threading/test_threading.py index 0d14fae352..4395891d62 100644 --- a/tests/integrations/threading/test_threading.py +++ b/tests/integrations/threading/test_threading.py @@ -1,5 +1,6 @@ import gc from concurrent import futures +from textwrap import dedent from threading import Thread import pytest @@ -172,3 +173,103 @@ def target(): assert Thread.run.__qualname__ == original_run.__qualname__ assert t.run.__name__ == "run" assert t.run.__qualname__ == original_run.__qualname__ + + +@pytest.mark.parametrize( + "propagate_scope", + (True, False), + ids=["propagate_scope=True", "propagate_scope=False"], +) +def test_scope_data_not_leaked_in_threads(sentry_init, propagate_scope): + sentry_init( + integrations=[ThreadingIntegration(propagate_scope=propagate_scope)], + ) + + sentry_sdk.set_tag("initial_tag", "initial_value") + initial_iso_scope = sentry_sdk.get_isolation_scope() + + def do_some_work(): + # check if we have the initial scope data propagated into the thread + if propagate_scope: + assert sentry_sdk.get_isolation_scope()._tags == { + "initial_tag": "initial_value" + } + else: + assert sentry_sdk.get_isolation_scope()._tags == {} + + # change data in isolation scope in thread + sentry_sdk.set_tag("thread_tag", "thread_value") + + t = Thread(target=do_some_work) + t.start() + t.join() + + # check if the initial scope data is not modified by the started thread + assert initial_iso_scope._tags == { + "initial_tag": "initial_value" + }, "The isolation scope in the main thread should not be modified by the started thread." + + +@pytest.mark.parametrize( + "propagate_scope", + (True, False), + ids=["propagate_scope=True", "propagate_scope=False"], +) +def test_spans_from_multiple_threads( + sentry_init, capture_events, render_span_tree, propagate_scope +): + sentry_init( + traces_sample_rate=1.0, + integrations=[ThreadingIntegration(propagate_scope=propagate_scope)], + ) + events = capture_events() + + def do_some_work(number): + with sentry_sdk.start_span( + op=f"inner-run-{number}", name=f"Thread: child-{number}" + ): + pass + + threads = [] + + with sentry_sdk.start_transaction(op="outer-trx"): + for number in range(5): + with sentry_sdk.start_span( + op=f"outer-submit-{number}", name="Thread: main" + ): + t = Thread(target=do_some_work, args=(number,)) + t.start() + threads.append(t) + + for t in threads: + t.join() + + (event,) = events + if propagate_scope: + assert render_span_tree(event) == dedent( + """\ + - op="outer-trx": description=null + - op="outer-submit-0": description="Thread: main" + - op="inner-run-0": description="Thread: child-0" + - op="outer-submit-1": description="Thread: main" + - op="inner-run-1": description="Thread: child-1" + - op="outer-submit-2": description="Thread: main" + - op="inner-run-2": description="Thread: child-2" + - op="outer-submit-3": description="Thread: main" + - op="inner-run-3": description="Thread: child-3" + - op="outer-submit-4": description="Thread: main" + - op="inner-run-4": description="Thread: child-4"\ +""" + ) + + elif not propagate_scope: + assert render_span_tree(event) == dedent( + """\ + - op="outer-trx": description=null + - op="outer-submit-0": description="Thread: main" + - op="outer-submit-1": description="Thread: main" + - op="outer-submit-2": description="Thread: main" + - op="outer-submit-3": description="Thread: main" + - op="outer-submit-4": description="Thread: main"\ +""" + ) From b2693f4b3e1442330e991caaf5d0c1c08f634069 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:42:58 +0200 Subject: [PATCH 22/24] ref(logging): Clarify separate warnings case is for Python <3.11 (#4296) The way the code was written before this change made it look like log records from the `warnings` module were always being handled by a separate code path. In fact, this separate path is only used for Python 3.10 and below. This change makes it clear that the branch is version specific. That way, when we eventually stop supporting 3.10, it is clear that we can delete this separate block. Depends on: - #4292 - #4291 --- sentry_sdk/integrations/logging.py | 39 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index ec13c86c6e..bf538ac7c7 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -1,4 +1,5 @@ import logging +import sys from datetime import datetime, timezone from fnmatch import fnmatch @@ -248,27 +249,25 @@ def _emit(self, record): event["level"] = level # type: ignore[typeddict-item] event["logger"] = record.name - # Log records from `warnings` module as separate issues - record_captured_from_warnings_module = ( - record.name == "py.warnings" and record.msg == "%s" - ) - if record_captured_from_warnings_module: - # use the actual message and not "%s" as the message - # this prevents grouping all warnings under one "%s" issue - msg = record.args[0] # type: ignore - - event["logentry"] = { - "message": msg, - "formatted": record.getMessage(), - "params": (), - } - + if ( + sys.version_info < (3, 11) + and record.name == "py.warnings" + and record.msg == "%s" + ): + # warnings module on Python 3.10 and below sets record.msg to "%s" + # and record.args[0] to the actual warning message. + # This was fixed in https://github.com/python/cpython/pull/30975. + message = record.args[0] + params = () else: - event["logentry"] = { - "formatted": record.getMessage(), - "message": to_string(record.msg), - "params": record.args, - } + message = record.msg + params = record.args + + event["logentry"] = { + "message": to_string(message), + "formatted": record.getMessage(), + "params": params, + } event["extra"] = self._extra_from_record(record) From d552808330c873958b9d0803349a0e662e27d959 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 15 Apr 2025 11:13:44 +0000 Subject: [PATCH 23/24] release: 2.26.1 --- CHANGELOG.md | 10 ++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5327b323a2..97343dc0fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 2.26.1 + +### Various fixes & improvements + +- ref(logging): Clarify separate warnings case is for Python <3.11 (#4296) by @szokeasaurusrex +- fix: Data leak in ThreadingIntegration between threads (#4281) by @antonpirker +- Revert "chore: Deprecate `same_process_as_parent` (#4244)" (#4290) by @sentrivana +- feat(logging): Add formatted message to log events (#4292) by @szokeasaurusrex +- fix(logging): Send raw logging parameters (#4291) by @szokeasaurusrex + ## 2.26.0 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 9c137d70a9..629b5b9eaa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.26.0" +release = "2.26.1" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 19d39acdc0..3802980b82 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -966,4 +966,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.26.0" +VERSION = "2.26.1" diff --git a/setup.py b/setup.py index 6c33887cf5..62f4867b35 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.26.0", + version="2.26.1", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From ec050c0de436b9d4afb495df79f5d6ae72bec16f Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Tue, 15 Apr 2025 13:16:01 +0200 Subject: [PATCH 24/24] Updated changelog --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97343dc0fc..bb49ed54ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ ### Various fixes & improvements -- ref(logging): Clarify separate warnings case is for Python <3.11 (#4296) by @szokeasaurusrex -- fix: Data leak in ThreadingIntegration between threads (#4281) by @antonpirker -- Revert "chore: Deprecate `same_process_as_parent` (#4244)" (#4290) by @sentrivana -- feat(logging): Add formatted message to log events (#4292) by @szokeasaurusrex +- fix(threading): Data leak in ThreadingIntegration between threads (#4281) by @antonpirker +- fix(logging): Clarify separate warnings case is for Python <3.11 (#4296) by @szokeasaurusrex +- fix(logging): Add formatted message to log events (#4292) by @szokeasaurusrex - fix(logging): Send raw logging parameters (#4291) by @szokeasaurusrex +- fix: Revert "chore: Deprecate `same_process_as_parent` (#4244)" (#4290) by @sentrivana ## 2.26.0