From 26479b22d51cc9544e4c1bf515fc8590f83589bc Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 6 Dec 2024 10:04:31 +0100 Subject: [PATCH 01/30] Use stdlib pathlib in ready-yet script (#3863) --- scripts/ready_yet/requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/ready_yet/requirements.txt b/scripts/ready_yet/requirements.txt index e0590b89c6..69f9472fa5 100644 --- a/scripts/ready_yet/requirements.txt +++ b/scripts/ready_yet/requirements.txt @@ -1,3 +1,2 @@ requests -pathlib -tox \ No newline at end of file +tox From 6448c709b840f37ca40b297fd64a99467f05d39b Mon Sep 17 00:00:00 2001 From: Jeffrey Hung <17494876+Jeffreyhung@users.noreply.github.com> Date: Wed, 11 Dec 2024 04:05:57 -0800 Subject: [PATCH 02/30] Replace release bot with GH app (#3868) --- .github/workflows/release.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 268f62c4cc..2cd3dfb2ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,14 +18,20 @@ jobs: runs-on: ubuntu-latest name: "Release a new version" steps: + - name: Get auth token + id: token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + with: + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - uses: actions/checkout@v4.2.2 with: - token: ${{ secrets.GH_RELEASE_PAT }} + token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Prepare release uses: getsentry/action-prepare-release@v1 env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} + GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: version: ${{ github.event.inputs.version }} force: ${{ github.event.inputs.force }} From 1239499b5d6274f997a890650a516f6c5538a188 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 13 Dec 2024 11:26:43 +0000 Subject: [PATCH 03/30] fix(spotlight): Make Django middleware init even more defensive (#3870) I just got faced with a situation where even trying to do `settings.DEBUG` may trigger a Django exception if the settings are not loaded yet, hence widening the `capture_internal_exceptions()` scope for this. --- sentry_sdk/spotlight.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index a94c691723..1555afb829 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -210,13 +210,13 @@ def setup_spotlight(options): if not isinstance(url, str): return None - if ( - settings is not None - and settings.DEBUG - and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1")) - and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1")) - ): - with capture_internal_exceptions(): + with capture_internal_exceptions(): + if ( + settings is not None + and settings.DEBUG + and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1")) + and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1")) + ): middleware = settings.MIDDLEWARE if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware: settings.MIDDLEWARE = type(middleware)( From 81b806321fed9715d0c7ff227bdf22c9f1178ce9 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 14 Dec 2024 00:55:25 +0000 Subject: [PATCH 04/30] fix(spotlight): Use the spotlight_url passed into the SDK when loading Spotlight (#3871) When we inject spotlight, we don't set the correct sidecar URL. This is an issue when a user defines a custom sidecar URL where we are able to load Spotlight UI from the correct URL but don't tell it the correct sidecar URL, making it non-functional. --------- Co-authored-by: Ivana Kellyer --- sentry_sdk/spotlight.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index 1555afb829..a783b155a1 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -66,7 +66,8 @@ def capture_envelope(self, envelope): SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js" SPOTLIGHT_JS_SNIPPET_PATTERN = ( - '' + "\n" + '\n' ) SPOTLIGHT_ERROR_PAGE_SNIPPET = ( '\n' @@ -113,7 +114,8 @@ def spotlight_script(self): ) urllib.request.urlopen(req) self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format( - spotlight_js_url + spotlight_url=self._spotlight_url, + spotlight_js_url=spotlight_js_url, ) except urllib.error.URLError as err: sentry_logger.debug( From 2666022f490dfe3f94db80059535818b37e76839 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 17 Dec 2024 15:33:04 +0100 Subject: [PATCH 05/30] Fix CI (#3878) --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index d3bd83cb03..9ccc4dc0eb 100644 --- a/tox.ini +++ b/tox.ini @@ -603,6 +603,7 @@ deps = quart-v0.16: quart~=0.16.0 quart-v0.19: Werkzeug>=3.0.0 quart-v0.19: quart~=0.19.0 + {py3.8}-quart: taskgroup==0.0.0a4 quart-latest: quart # Ray From 4e69cb7f56880ba5f1a0041c80cdf2b773ed7deb Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Wed, 18 Dec 2024 10:52:05 +0000 Subject: [PATCH 06/30] =?UTF-8?q?=E2=9C=A8=20Add=20Typer=20integration=20(?= =?UTF-8?q?#3869)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: Ivana Kellyer --- .github/workflows/test-integrations-misc.yml | 10 +++- requirements-linting.txt | 1 + .../split-tox-gh-actions.py | 1 + sentry_sdk/integrations/typer.py | 60 +++++++++++++++++++ tests/integrations/typer/__init__.py | 3 + tests/integrations/typer/test_typer.py | 52 ++++++++++++++++ tox.ini | 9 +++ 7 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 sentry_sdk/integrations/typer.py create mode 100644 tests/integrations/typer/__init__.py create mode 100644 tests/integrations/typer/test_typer.py diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index fb76a854fb..b88b256384 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -27,7 +27,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6","3.8","3.12","3.13"] + python-version: ["3.6","3.7","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 @@ -73,6 +73,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-trytond-latest" + - name: Test typer latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-typer-latest" - name: Generate coverage XML (Python 3.6) if: ${{ !cancelled() && matrix.python-version == '3.6' }} run: | @@ -153,6 +157,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-trytond" + - name: Test typer pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-typer" - name: Generate coverage XML (Python 3.6) if: ${{ !cancelled() && matrix.python-version == '3.6' }} run: | diff --git a/requirements-linting.txt b/requirements-linting.txt index c9d4bd7f5c..c3f39ecd1f 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -17,3 +17,4 @@ pre-commit # local linting httpcore openfeature-sdk launchdarkly-server-sdk +typer diff --git a/scripts/split-tox-gh-actions/split-tox-gh-actions.py b/scripts/split-tox-gh-actions/split-tox-gh-actions.py index c4b8f3e5e5..26d13390c2 100755 --- a/scripts/split-tox-gh-actions/split-tox-gh-actions.py +++ b/scripts/split-tox-gh-actions/split-tox-gh-actions.py @@ -132,6 +132,7 @@ "potel", "pure_eval", "trytond", + "typer", ], } diff --git a/sentry_sdk/integrations/typer.py b/sentry_sdk/integrations/typer.py new file mode 100644 index 0000000000..8879d6d0d0 --- /dev/null +++ b/sentry_sdk/integrations/typer.py @@ -0,0 +1,60 @@ +import sentry_sdk +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, +) +from sentry_sdk.integrations import Integration, DidNotEnable + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + from typing import Any + from typing import Type + from typing import Optional + + from types import TracebackType + + Excepthook = Callable[ + [Type[BaseException], BaseException, Optional[TracebackType]], + Any, + ] + +try: + import typer +except ImportError: + raise DidNotEnable("Typer not installed") + + +class TyperIntegration(Integration): + identifier = "typer" + + @staticmethod + def setup_once(): + # type: () -> None + typer.main.except_hook = _make_excepthook(typer.main.except_hook) # type: ignore + + +def _make_excepthook(old_excepthook): + # type: (Excepthook) -> Excepthook + def sentry_sdk_excepthook(type_, value, traceback): + # type: (Type[BaseException], BaseException, Optional[TracebackType]) -> None + integration = sentry_sdk.get_client().get_integration(TyperIntegration) + + # Note: If we replace this with ensure_integration_enabled then + # we break the exceptiongroup backport; + # See: https://github.com/getsentry/sentry-python/issues/3097 + if integration is None: + return old_excepthook(type_, value, traceback) + + with capture_internal_exceptions(): + event, hint = event_from_exception( + (type_, value, traceback), + client_options=sentry_sdk.get_client().options, + mechanism={"type": "typer", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + return old_excepthook(type_, value, traceback) + + return sentry_sdk_excepthook diff --git a/tests/integrations/typer/__init__.py b/tests/integrations/typer/__init__.py new file mode 100644 index 0000000000..3b7c8011ea --- /dev/null +++ b/tests/integrations/typer/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("typer") diff --git a/tests/integrations/typer/test_typer.py b/tests/integrations/typer/test_typer.py new file mode 100644 index 0000000000..34ac0a7c8c --- /dev/null +++ b/tests/integrations/typer/test_typer.py @@ -0,0 +1,52 @@ +import subprocess +import sys +from textwrap import dedent +import pytest + +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_catch_exceptions(tmpdir): + app = tmpdir.join("app.py") + + app.write( + dedent( + """ + import typer + from unittest import mock + + from sentry_sdk import init, transport + from sentry_sdk.integrations.typer import TyperIntegration + + def capture_envelope(self, envelope): + print("capture_envelope was called") + event = envelope.get_event() + if event is not None: + print(event) + + transport.HttpTransport.capture_envelope = capture_envelope + + init("http://foobar@localhost/123", integrations=[TyperIntegration()]) + + app = typer.Typer() + + @app.command() + def test(): + print("test called") + raise Exception("pollo") + + app() + """ + ) + ) + + with pytest.raises(subprocess.CalledProcessError) as excinfo: + subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT) + + output = excinfo.value.output + + assert b"capture_envelope was called" in output + assert b"test called" in output + assert b"pollo" in output diff --git a/tox.ini b/tox.ini index 9ccc4dc0eb..717ea62141 100644 --- a/tox.ini +++ b/tox.ini @@ -287,6 +287,10 @@ envlist = {py3.8,py3.11,py3.12}-trytond-v{7} {py3.8,py3.12,py3.13}-trytond-latest + # Typer + {py3.7,py3.12,py3.13}-typer-v{0.15} + {py3.7,py3.12,py3.13}-typer-latest + [testenv] deps = # if you change requirements-testing.txt and your change is not being reflected @@ -724,6 +728,10 @@ deps = trytond-v7: trytond~=7.0 trytond-latest: trytond + # Typer + typer-v0.15: typer~=0.15.0 + typer-latest: typer + setenv = PYTHONDONTWRITEBYTECODE=1 OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES @@ -786,6 +794,7 @@ setenv = strawberry: TESTPATH=tests/integrations/strawberry tornado: TESTPATH=tests/integrations/tornado trytond: TESTPATH=tests/integrations/trytond + typer: TESTPATH=tests/integrations/typer socket: TESTPATH=tests/integrations/socket passenv = From 50222ca2a6c680bb0e712b3bc8a1813d83fa55a0 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Wed, 18 Dec 2024 20:33:36 -0800 Subject: [PATCH 07/30] feat(flags): Add integration for custom tracking of flag evaluations (#3860) * Add new integration and unit tests * Test flag values for LD and OF threaded/asyncio, not just flag names * update ffIntegration test to be e2e, and fix LRU copy bug * make a helper fixture and test error processor in original thread * Move api to top-level, rename to add_flag * Add docstrs * Rename to add_feature_flag * Rm extra import in test_lru_cache * Revert lru comment * Type annotate * Review comments * Update launchdarkly and openfeature tests to be e2e * Update docstrs * Skip threading test for <3.7 * Skip ffs asyncio test if 3.6 * undo 'skip threading test' * Try commenting out asyncio * Use importorskip * Import order --------- Co-authored-by: Anton Pirker --- sentry_sdk/integrations/featureflags.py | 44 ++++++ tests/conftest.py | 11 ++ tests/integrations/featureflags/__init__.py | 0 .../featureflags/test_featureflags.py | 133 ++++++++++++++++++ .../launchdarkly/test_launchdarkly.py | 119 +++++++++++++--- .../openfeature/test_openfeature.py | 113 ++++++++++++--- 6 files changed, 377 insertions(+), 43 deletions(-) create mode 100644 sentry_sdk/integrations/featureflags.py create mode 100644 tests/integrations/featureflags/__init__.py create mode 100644 tests/integrations/featureflags/test_featureflags.py diff --git a/sentry_sdk/integrations/featureflags.py b/sentry_sdk/integrations/featureflags.py new file mode 100644 index 0000000000..46947eec72 --- /dev/null +++ b/sentry_sdk/integrations/featureflags.py @@ -0,0 +1,44 @@ +from sentry_sdk.flag_utils import flag_error_processor + +import sentry_sdk +from sentry_sdk.integrations import Integration + + +class FeatureFlagsIntegration(Integration): + """ + Sentry integration for capturing feature flags on error events. To manually buffer flag data, + call `integrations.featureflags.add_feature_flag`. We recommend you do this on each flag + evaluation. + + See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) + for more information. + + @example + ``` + import sentry_sdk + from sentry_sdk.integrations.featureflags import FeatureFlagsIntegration, add_feature_flag + + sentry_sdk.init(dsn="my_dsn", integrations=[FeatureFlagsIntegration()]); + + add_feature_flag('my-flag', true); + sentry_sdk.capture_exception(Exception('broke')); // 'my-flag' should be captured on this Sentry event. + ``` + """ + + identifier = "featureflags" + + @staticmethod + def setup_once(): + # type: () -> None + scope = sentry_sdk.get_current_scope() + scope.add_error_processor(flag_error_processor) + + +def add_feature_flag(flag, result): + # type: (str, bool) -> None + """ + Records a flag and its value to be sent on subsequent error events by FeatureFlagsIntegration. + We recommend you do this on flag evaluations. Flags are buffered per Sentry scope. + """ + flags = sentry_sdk.get_current_scope().flags + flags.set(flag, result) diff --git a/tests/conftest.py b/tests/conftest.py index 64527c1e36..c0383d94b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -184,6 +184,17 @@ def reset_integrations(): _installed_integrations.clear() +@pytest.fixture +def uninstall_integration(): + """Use to force the next call to sentry_init to re-install/setup an integration.""" + + def inner(identifier): + _processed_integrations.discard(identifier) + _installed_integrations.discard(identifier) + + return inner + + @pytest.fixture def sentry_init(request): def inner(*a, **kw): diff --git a/tests/integrations/featureflags/__init__.py b/tests/integrations/featureflags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/featureflags/test_featureflags.py b/tests/integrations/featureflags/test_featureflags.py new file mode 100644 index 0000000000..539e910607 --- /dev/null +++ b/tests/integrations/featureflags/test_featureflags.py @@ -0,0 +1,133 @@ +import concurrent.futures as cf +import sys + +import pytest + +import sentry_sdk +from sentry_sdk.integrations.featureflags import ( + FeatureFlagsIntegration, + add_feature_flag, +) + + +def test_featureflags_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(FeatureFlagsIntegration.identifier) + sentry_init(integrations=[FeatureFlagsIntegration()]) + + add_feature_flag("hello", False) + add_feature_flag("world", True) + add_feature_flag("other", False) + + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "world", "result": True}, + {"flag": "other", "result": False}, + ] + } + + +def test_featureflags_integration_threaded( + sentry_init, capture_events, uninstall_integration +): + uninstall_integration(FeatureFlagsIntegration.identifier) + sentry_init(integrations=[FeatureFlagsIntegration()]) + events = capture_events() + + # Capture an eval before we split isolation scopes. + add_feature_flag("hello", False) + + def task(flag_key): + # Creates a new isolation scope for the thread. + # This means the evaluations in each task are captured separately. + with sentry_sdk.isolation_scope(): + add_feature_flag(flag_key, False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + # Run tasks in separate threads + with cf.ThreadPoolExecutor(max_workers=2) as pool: + pool.map(task, ["world", "other"]) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "world", "result": False}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_featureflags_integration_asyncio( + sentry_init, capture_events, uninstall_integration +): + asyncio = pytest.importorskip("asyncio") + + uninstall_integration(FeatureFlagsIntegration.identifier) + sentry_init(integrations=[FeatureFlagsIntegration()]) + events = capture_events() + + # Capture an eval before we split isolation scopes. + add_feature_flag("hello", False) + + async def task(flag_key): + # Creates a new isolation scope for the thread. + # This means the evaluations in each task are captured separately. + with sentry_sdk.isolation_scope(): + add_feature_flag(flag_key, False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + async def runner(): + return asyncio.gather(task("world"), task("other")) + + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "world", "result": False}, + ] + } diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index acbe764104..f66a4219ec 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -1,9 +1,7 @@ -import asyncio import concurrent.futures as cf +import sys import ldclient - -import sentry_sdk import pytest from ldclient import LDClient @@ -11,6 +9,7 @@ from ldclient.context import Context from ldclient.integrations.test_data import TestData +import sentry_sdk from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration @@ -19,9 +18,13 @@ "use_global_client", (False, True), ) -def test_launchdarkly_integration(sentry_init, use_global_client): +def test_launchdarkly_integration( + sentry_init, use_global_client, capture_events, uninstall_integration +): td = TestData.data_source() config = Config("sdk-key", update_processor_class=td) + + uninstall_integration(LaunchDarklyIntegration.identifier) if use_global_client: ldclient.set_config(config) sentry_init(integrations=[LaunchDarklyIntegration()]) @@ -39,25 +42,38 @@ def test_launchdarkly_integration(sentry_init, use_global_client): client.variation("world", Context.create("user1", "user"), False) client.variation("other", Context.create("user2", "user"), False) - assert sentry_sdk.get_current_scope().flags.get() == [ - {"flag": "hello", "result": True}, - {"flag": "world", "result": True}, - {"flag": "other", "result": False}, - ] + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": True}, + {"flag": "other", "result": False}, + ] + } -def test_launchdarkly_integration_threaded(sentry_init): + +def test_launchdarkly_integration_threaded( + sentry_init, capture_events, uninstall_integration +): td = TestData.data_source() client = LDClient(config=Config("sdk-key", update_processor_class=td)) - sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) context = Context.create("user1") + uninstall_integration(LaunchDarklyIntegration.identifier) + sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) + events = capture_events() + def task(flag_key): # Creates a new isolation scope for the thread. # This means the evaluations in each task are captured separately. with sentry_sdk.isolation_scope(): client.variation(flag_key, context, False) - return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()] + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) td.update(td.flag("hello").variation_for_all(True)) td.update(td.flag("world").variation_for_all(False)) @@ -65,34 +81,91 @@ def task(flag_key): client.variation("hello", context, False) with cf.ThreadPoolExecutor(max_workers=2) as pool: - results = list(pool.map(task, ["world", "other"])) - - assert results[0] == ["hello", "world"] - assert results[1] == ["hello", "other"] + pool.map(task, ["world", "other"]) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_launchdarkly_integration_asyncio( + sentry_init, capture_events, uninstall_integration +): + """Assert concurrently evaluated flags do not pollute one another.""" + asyncio = pytest.importorskip("asyncio") -def test_launchdarkly_integration_asyncio(sentry_init): - """Assert concurrently evaluated flags do not pollute one another.""" td = TestData.data_source() client = LDClient(config=Config("sdk-key", update_processor_class=td)) - sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) context = Context.create("user1") + uninstall_integration(LaunchDarklyIntegration.identifier) + sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) + events = capture_events() + async def task(flag_key): with sentry_sdk.isolation_scope(): client.variation(flag_key, context, False) - return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()] + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) async def runner(): return asyncio.gather(task("world"), task("other")) td.update(td.flag("hello").variation_for_all(True)) td.update(td.flag("world").variation_for_all(False)) + # Capture an eval before we split isolation scopes. client.variation("hello", context, False) - results = asyncio.run(runner()).result() - assert results[0] == ["hello", "world"] - assert results[1] == ["hello", "other"] + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } def test_launchdarkly_integration_did_not_enable(monkeypatch): diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py index 24e7857f9a..c180211c3f 100644 --- a/tests/integrations/openfeature/test_openfeature.py +++ b/tests/integrations/openfeature/test_openfeature.py @@ -1,13 +1,17 @@ -import asyncio import concurrent.futures as cf -import sentry_sdk +import sys + +import pytest from openfeature import api from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider + +import sentry_sdk from sentry_sdk.integrations.openfeature import OpenFeatureIntegration -def test_openfeature_integration(sentry_init): +def test_openfeature_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) flags = { @@ -21,15 +25,25 @@ def test_openfeature_integration(sentry_init): client.get_boolean_value("world", default_value=False) client.get_boolean_value("other", default_value=True) - assert sentry_sdk.get_current_scope().flags.get() == [ - {"flag": "hello", "result": True}, - {"flag": "world", "result": False}, - {"flag": "other", "result": True}, - ] + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + {"flag": "other", "result": True}, + ] + } -def test_openfeature_integration_threaded(sentry_init): +def test_openfeature_integration_threaded( + sentry_init, capture_events, uninstall_integration +): + uninstall_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) + events = capture_events() flags = { "hello": InMemoryFlag("on", {"on": True, "off": False}), @@ -37,6 +51,7 @@ def test_openfeature_integration_threaded(sentry_init): } api.set_provider(InMemoryProvider(flags)) + # Capture an eval before we split isolation scopes. client = api.get_client() client.get_boolean_value("hello", default_value=False) @@ -44,37 +59,95 @@ def task(flag): # Create a new isolation scope for the thread. This means the flags with sentry_sdk.isolation_scope(): client.get_boolean_value(flag, default_value=False) - return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()] + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag) + sentry_sdk.capture_exception(Exception("something wrong!")) + # Run tasks in separate threads with cf.ThreadPoolExecutor(max_workers=2) as pool: - results = list(pool.map(task, ["world", "other"])) + pool.map(task, ["world", "other"]) - assert results[0] == ["hello", "world"] - assert results[1] == ["hello", "other"] + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } -def test_openfeature_integration_asyncio(sentry_init): +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_openfeature_integration_asyncio( + sentry_init, capture_events, uninstall_integration +): """Assert concurrently evaluated flags do not pollute one another.""" + asyncio = pytest.importorskip("asyncio") + + uninstall_integration(OpenFeatureIntegration.identifier) + sentry_init(integrations=[OpenFeatureIntegration()]) + events = capture_events() + async def task(flag): with sentry_sdk.isolation_scope(): client.get_boolean_value(flag, default_value=False) - return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()] + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag) + sentry_sdk.capture_exception(Exception("something wrong!")) async def runner(): return asyncio.gather(task("world"), task("other")) - sentry_init(integrations=[OpenFeatureIntegration()]) - flags = { "hello": InMemoryFlag("on", {"on": True, "off": False}), "world": InMemoryFlag("off", {"on": True, "off": False}), } api.set_provider(InMemoryProvider(flags)) + # Capture an eval before we split isolation scopes. client = api.get_client() client.get_boolean_value("hello", default_value=False) - results = asyncio.run(runner()).result() - assert results[0] == ["hello", "world"] - assert results[1] == ["hello", "other"] + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } From fe4b88b8505376ace7c6f8750f83fd2af383190f Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 19 Dec 2024 14:00:09 +0100 Subject: [PATCH 08/30] Add github workflow to comment on issues when a fix was released (#3866) --- .github/workflows/release-comment-issues.yml | 31 ++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/release-comment-issues.yml diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml new file mode 100644 index 0000000000..d31c61dced --- /dev/null +++ b/.github/workflows/release-comment-issues.yml @@ -0,0 +1,31 @@ +name: "Automation: Notify issues for release" +on: + release: + types: + - published + workflow_dispatch: + inputs: + version: + description: Which version to notify issues for + required: false + +# This workflow is triggered when a release is published +jobs: + release-comment-issues: + runs-on: ubuntu-20.04 + name: Notify issues + steps: + - name: Get version + id: get_version + run: echo "version=${{ github.event.inputs.version || github.event.release.tag_name }}" >> $GITHUB_OUTPUT + + - name: Comment on linked issues that are mentioned in release + if: | + steps.get_version.outputs.version != '' + && !contains(steps.get_version.outputs.version, 'a') + && !contains(steps.get_version.outputs.version, 'b') + && !contains(steps.get_version.outputs.version, 'rc') + uses: getsentry/release-comment-issues-gh-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.get_version.outputs.version }} \ No newline at end of file From 54aede36f9d3942c1069b47b20b88f01cb461fb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Dec 2024 15:34:49 +0100 Subject: [PATCH 09/30] build(deps): bump codecov/codecov-action from 5.0.7 to 5.1.1 (#3867) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.0.7 to 5.1.1. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.0.7...v5.1.1) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Anton Pirker --- .github/workflows/test-integrations-ai.yml | 4 ++-- .github/workflows/test-integrations-aws.yml | 2 +- .github/workflows/test-integrations-cloud.yml | 4 ++-- .github/workflows/test-integrations-common.yml | 2 +- .github/workflows/test-integrations-dbs.yml | 4 ++-- .github/workflows/test-integrations-graphql.yml | 4 ++-- .github/workflows/test-integrations-misc.yml | 4 ++-- .github/workflows/test-integrations-network.yml | 4 ++-- .github/workflows/test-integrations-tasks.yml | 4 ++-- .github/workflows/test-integrations-web-1.yml | 4 ++-- .github/workflows/test-integrations-web-2.yml | 4 ++-- scripts/split-tox-gh-actions/templates/test_group.jinja | 2 +- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 5d1b05add8..8be64736c1 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -78,7 +78,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -150,7 +150,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-aws.yml b/.github/workflows/test-integrations-aws.yml index d2ce22f326..6eed3a3ab1 100644 --- a/.github/workflows/test-integrations-aws.yml +++ b/.github/workflows/test-integrations-aws.yml @@ -97,7 +97,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml index 8fdd4a0649..677385e405 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 8294b9480e..9c476553f5 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -62,7 +62,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index 0d9a7bbd7d..cbaa2c32d2 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -101,7 +101,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -196,7 +196,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index 30480efe2e..d582717fff 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index b88b256384..00b1286362 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -90,7 +90,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -174,7 +174,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-network.yml b/.github/workflows/test-integrations-network.yml index 0a51866164..8f6bd9fd61 100644 --- a/.github/workflows/test-integrations-network.yml +++ b/.github/workflows/test-integrations-network.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index 695c338721..74c868d9b9 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -92,7 +92,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -178,7 +178,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml index 6e172182b3..5be067a36b 100644 --- a/.github/workflows/test-integrations-web-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -92,7 +92,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -178,7 +178,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index f9f2651cb8..7ce0399a13 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -98,7 +98,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -190,7 +190,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split-tox-gh-actions/templates/test_group.jinja index 522be6dc5c..7225bbbfe5 100644 --- a/scripts/split-tox-gh-actions/templates/test_group.jinja +++ b/scripts/split-tox-gh-actions/templates/test_group.jinja @@ -92,7 +92,7 @@ - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v5.0.7 + uses: codecov/codecov-action@v5.1.1 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml From 6e4cc36fbb66a09f4272176fc8972368e1028ae8 Mon Sep 17 00:00:00 2001 From: seyoon-lim Date: Fri, 20 Dec 2024 16:43:19 +0900 Subject: [PATCH 10/30] Support SparkIntegration activation after SparkContext created (#3411) --------- Co-authored-by: Anton Pirker --- sentry_sdk/integrations/spark/spark_driver.py | 121 +++++++---- tests/integrations/asgi/test_asgi.py | 1 - tests/integrations/spark/test_spark.py | 202 ++++++++++-------- 3 files changed, 189 insertions(+), 135 deletions(-) diff --git a/sentry_sdk/integrations/spark/spark_driver.py b/sentry_sdk/integrations/spark/spark_driver.py index c6470f2302..a86f16344d 100644 --- a/sentry_sdk/integrations/spark/spark_driver.py +++ b/sentry_sdk/integrations/spark/spark_driver.py @@ -9,6 +9,7 @@ from typing import Optional from sentry_sdk._types import Event, Hint + from pyspark import SparkContext class SparkIntegration(Integration): @@ -17,7 +18,7 @@ class SparkIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - patch_spark_context_init() + _setup_sentry_tracing() def _set_app_properties(): @@ -37,7 +38,7 @@ def _set_app_properties(): def _start_sentry_listener(sc): - # type: (Any) -> None + # type: (SparkContext) -> None """ Start java gateway server to add custom `SparkListener` """ @@ -49,7 +50,51 @@ def _start_sentry_listener(sc): sc._jsc.sc().addSparkListener(listener) -def patch_spark_context_init(): +def _add_event_processor(sc): + # type: (SparkContext) -> None + scope = sentry_sdk.get_isolation_scope() + + @scope.add_event_processor + def process_event(event, hint): + # type: (Event, Hint) -> Optional[Event] + with capture_internal_exceptions(): + if sentry_sdk.get_client().get_integration(SparkIntegration) is None: + return event + + if sc._active_spark_context is None: + return event + + event.setdefault("user", {}).setdefault("id", sc.sparkUser()) + + event.setdefault("tags", {}).setdefault( + "executor.id", sc._conf.get("spark.executor.id") + ) + event["tags"].setdefault( + "spark-submit.deployMode", + sc._conf.get("spark.submit.deployMode"), + ) + event["tags"].setdefault("driver.host", sc._conf.get("spark.driver.host")) + event["tags"].setdefault("driver.port", sc._conf.get("spark.driver.port")) + event["tags"].setdefault("spark_version", sc.version) + event["tags"].setdefault("app_name", sc.appName) + event["tags"].setdefault("application_id", sc.applicationId) + event["tags"].setdefault("master", sc.master) + event["tags"].setdefault("spark_home", sc.sparkHome) + + event.setdefault("extra", {}).setdefault("web_url", sc.uiWebUrl) + + return event + + +def _activate_integration(sc): + # type: (SparkContext) -> None + + _start_sentry_listener(sc) + _set_app_properties() + _add_event_processor(sc) + + +def _patch_spark_context_init(): # type: () -> None from pyspark import SparkContext @@ -59,51 +104,22 @@ def patch_spark_context_init(): def _sentry_patched_spark_context_init(self, *args, **kwargs): # type: (SparkContext, *Any, **Any) -> Optional[Any] rv = spark_context_init(self, *args, **kwargs) - _start_sentry_listener(self) - _set_app_properties() - - scope = sentry_sdk.get_isolation_scope() - - @scope.add_event_processor - def process_event(event, hint): - # type: (Event, Hint) -> Optional[Event] - with capture_internal_exceptions(): - if sentry_sdk.get_client().get_integration(SparkIntegration) is None: - return event - - if self._active_spark_context is None: - return event - - event.setdefault("user", {}).setdefault("id", self.sparkUser()) - - event.setdefault("tags", {}).setdefault( - "executor.id", self._conf.get("spark.executor.id") - ) - event["tags"].setdefault( - "spark-submit.deployMode", - self._conf.get("spark.submit.deployMode"), - ) - event["tags"].setdefault( - "driver.host", self._conf.get("spark.driver.host") - ) - event["tags"].setdefault( - "driver.port", self._conf.get("spark.driver.port") - ) - event["tags"].setdefault("spark_version", self.version) - event["tags"].setdefault("app_name", self.appName) - event["tags"].setdefault("application_id", self.applicationId) - event["tags"].setdefault("master", self.master) - event["tags"].setdefault("spark_home", self.sparkHome) - - event.setdefault("extra", {}).setdefault("web_url", self.uiWebUrl) - - return event - + _activate_integration(self) return rv SparkContext._do_init = _sentry_patched_spark_context_init +def _setup_sentry_tracing(): + # type: () -> None + from pyspark import SparkContext + + if SparkContext._active_spark_context is not None: + _activate_integration(SparkContext._active_spark_context) + return + _patch_spark_context_init() + + class SparkListener: def onApplicationEnd(self, applicationEnd): # noqa: N802,N803 # type: (Any) -> None @@ -208,10 +224,21 @@ class Java: class SentryListener(SparkListener): + def _add_breadcrumb( + self, + level, # type: str + message, # type: str + data=None, # type: Optional[dict[str, Any]] + ): + # type: (...) -> None + sentry_sdk.get_global_scope().add_breadcrumb( + level=level, message=message, data=data + ) + def onJobStart(self, jobStart): # noqa: N802,N803 # type: (Any) -> None message = "Job {} Started".format(jobStart.jobId()) - sentry_sdk.add_breadcrumb(level="info", message=message) + self._add_breadcrumb(level="info", message=message) _set_app_properties() def onJobEnd(self, jobEnd): # noqa: N802,N803 @@ -227,14 +254,14 @@ def onJobEnd(self, jobEnd): # noqa: N802,N803 level = "warning" message = "Job {} Failed".format(jobEnd.jobId()) - sentry_sdk.add_breadcrumb(level=level, message=message, data=data) + self._add_breadcrumb(level=level, message=message, data=data) def onStageSubmitted(self, stageSubmitted): # noqa: N802,N803 # type: (Any) -> None stage_info = stageSubmitted.stageInfo() message = "Stage {} Submitted".format(stage_info.stageId()) data = {"attemptId": stage_info.attemptId(), "name": stage_info.name()} - sentry_sdk.add_breadcrumb(level="info", message=message, data=data) + self._add_breadcrumb(level="info", message=message, data=data) _set_app_properties() def onStageCompleted(self, stageCompleted): # noqa: N802,N803 @@ -255,4 +282,4 @@ def onStageCompleted(self, stageCompleted): # noqa: N802,N803 message = "Stage {} Completed".format(stage_info.stageId()) level = "info" - sentry_sdk.add_breadcrumb(level=level, message=message, data=data) + self._add_breadcrumb(level=level, message=message, data=data) diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index e0a3900a38..f3bc7147bf 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -128,7 +128,6 @@ async def app(scope, receive, send): @pytest.fixture def asgi3_custom_transaction_app(): - async def app(scope, receive, send): sentry_sdk.get_current_scope().set_transaction_name("foobar", source="custom") await send( diff --git a/tests/integrations/spark/test_spark.py b/tests/integrations/spark/test_spark.py index 58c8862ee2..44ba9f8728 100644 --- a/tests/integrations/spark/test_spark.py +++ b/tests/integrations/spark/test_spark.py @@ -1,6 +1,7 @@ import pytest import sys from unittest.mock import patch + from sentry_sdk.integrations.spark.spark_driver import ( _set_app_properties, _start_sentry_listener, @@ -18,8 +19,22 @@ ################ -def test_set_app_properties(): - spark_context = SparkContext(appName="Testing123") +@pytest.fixture(scope="function") +def sentry_init_with_reset(sentry_init): + from sentry_sdk.integrations import _processed_integrations + + yield lambda: sentry_init(integrations=[SparkIntegration()]) + _processed_integrations.remove("spark") + + +@pytest.fixture(scope="function") +def create_spark_context(): + yield lambda: SparkContext(appName="Testing123") + SparkContext._active_spark_context.stop() + + +def test_set_app_properties(create_spark_context): + spark_context = create_spark_context() _set_app_properties() assert spark_context.getLocalProperty("sentry_app_name") == "Testing123" @@ -30,9 +45,8 @@ def test_set_app_properties(): ) -def test_start_sentry_listener(): - spark_context = SparkContext.getOrCreate() - +def test_start_sentry_listener(create_spark_context): + spark_context = create_spark_context() gateway = spark_context._gateway assert gateway._callback_server is None @@ -41,9 +55,28 @@ def test_start_sentry_listener(): assert gateway._callback_server is not None -def test_initialize_spark_integration(sentry_init): - sentry_init(integrations=[SparkIntegration()]) - SparkContext.getOrCreate() +@patch("sentry_sdk.integrations.spark.spark_driver._patch_spark_context_init") +def test_initialize_spark_integration_before_spark_context_init( + mock_patch_spark_context_init, + sentry_init_with_reset, + create_spark_context, +): + sentry_init_with_reset() + create_spark_context() + + mock_patch_spark_context_init.assert_called_once() + + +@patch("sentry_sdk.integrations.spark.spark_driver._activate_integration") +def test_initialize_spark_integration_after_spark_context_init( + mock_activate_integration, + create_spark_context, + sentry_init_with_reset, +): + create_spark_context() + sentry_init_with_reset() + + mock_activate_integration.assert_called_once() @pytest.fixture @@ -54,88 +87,83 @@ def sentry_listener(): return listener -@pytest.fixture -def mock_add_breadcrumb(): - with patch("sentry_sdk.add_breadcrumb") as mock: - yield mock - - -def test_sentry_listener_on_job_start(sentry_listener, mock_add_breadcrumb): +def test_sentry_listener_on_job_start(sentry_listener): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: - class MockJobStart: - def jobId(self): # noqa: N802 - return "sample-job-id-start" + class MockJobStart: + def jobId(self): # noqa: N802 + return "sample-job-id-start" - mock_job_start = MockJobStart() - listener.onJobStart(mock_job_start) + mock_job_start = MockJobStart() + listener.onJobStart(mock_job_start) - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == "info" - assert "sample-job-id-start" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["level"] == "info" + assert "sample-job-id-start" in mock_hub.kwargs["message"] @pytest.mark.parametrize( "job_result, level", [("JobSucceeded", "info"), ("JobFailed", "warning")] ) -def test_sentry_listener_on_job_end( - sentry_listener, mock_add_breadcrumb, job_result, level -): +def test_sentry_listener_on_job_end(sentry_listener, job_result, level): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: - class MockJobResult: - def toString(self): # noqa: N802 - return job_result + class MockJobResult: + def toString(self): # noqa: N802 + return job_result - class MockJobEnd: - def jobId(self): # noqa: N802 - return "sample-job-id-end" + class MockJobEnd: + def jobId(self): # noqa: N802 + return "sample-job-id-end" - def jobResult(self): # noqa: N802 - result = MockJobResult() - return result + def jobResult(self): # noqa: N802 + result = MockJobResult() + return result - mock_job_end = MockJobEnd() - listener.onJobEnd(mock_job_end) + mock_job_end = MockJobEnd() + listener.onJobEnd(mock_job_end) - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == level - assert mock_hub.kwargs["data"]["result"] == job_result - assert "sample-job-id-end" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["level"] == level + assert mock_hub.kwargs["data"]["result"] == job_result + assert "sample-job-id-end" in mock_hub.kwargs["message"] -def test_sentry_listener_on_stage_submitted(sentry_listener, mock_add_breadcrumb): +def test_sentry_listener_on_stage_submitted(sentry_listener): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: - class StageInfo: - def stageId(self): # noqa: N802 - return "sample-stage-id-submit" + class StageInfo: + def stageId(self): # noqa: N802 + return "sample-stage-id-submit" - def name(self): - return "run-job" + def name(self): + return "run-job" - def attemptId(self): # noqa: N802 - return 14 + def attemptId(self): # noqa: N802 + return 14 - class MockStageSubmitted: - def stageInfo(self): # noqa: N802 - stageinf = StageInfo() - return stageinf + class MockStageSubmitted: + def stageInfo(self): # noqa: N802 + stageinf = StageInfo() + return stageinf - mock_stage_submitted = MockStageSubmitted() - listener.onStageSubmitted(mock_stage_submitted) + mock_stage_submitted = MockStageSubmitted() + listener.onStageSubmitted(mock_stage_submitted) - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == "info" - assert "sample-stage-id-submit" in mock_hub.kwargs["message"] - assert mock_hub.kwargs["data"]["attemptId"] == 14 - assert mock_hub.kwargs["data"]["name"] == "run-job" + assert mock_hub.kwargs["level"] == "info" + assert "sample-stage-id-submit" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["data"]["attemptId"] == 14 + assert mock_hub.kwargs["data"]["name"] == "run-job" @pytest.fixture @@ -175,39 +203,39 @@ def stageInfo(self): # noqa: N802 def test_sentry_listener_on_stage_completed_success( - sentry_listener, mock_add_breadcrumb, get_mock_stage_completed + sentry_listener, get_mock_stage_completed ): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: + mock_stage_completed = get_mock_stage_completed(failure_reason=False) + listener.onStageCompleted(mock_stage_completed) - mock_stage_completed = get_mock_stage_completed(failure_reason=False) - listener.onStageCompleted(mock_stage_completed) - - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == "info" - assert "sample-stage-id-submit" in mock_hub.kwargs["message"] - assert mock_hub.kwargs["data"]["attemptId"] == 14 - assert mock_hub.kwargs["data"]["name"] == "run-job" - assert "reason" not in mock_hub.kwargs["data"] + assert mock_hub.kwargs["level"] == "info" + assert "sample-stage-id-submit" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["data"]["attemptId"] == 14 + assert mock_hub.kwargs["data"]["name"] == "run-job" + assert "reason" not in mock_hub.kwargs["data"] def test_sentry_listener_on_stage_completed_failure( - sentry_listener, mock_add_breadcrumb, get_mock_stage_completed + sentry_listener, get_mock_stage_completed ): listener = sentry_listener - - mock_stage_completed = get_mock_stage_completed(failure_reason=True) - listener.onStageCompleted(mock_stage_completed) - - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args - - assert mock_hub.kwargs["level"] == "warning" - assert "sample-stage-id-submit" in mock_hub.kwargs["message"] - assert mock_hub.kwargs["data"]["attemptId"] == 14 - assert mock_hub.kwargs["data"]["name"] == "run-job" - assert mock_hub.kwargs["data"]["reason"] == "failure-reason" + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: + mock_stage_completed = get_mock_stage_completed(failure_reason=True) + listener.onStageCompleted(mock_stage_completed) + + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args + + assert mock_hub.kwargs["level"] == "warning" + assert "sample-stage-id-submit" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["data"]["attemptId"] == 14 + assert mock_hub.kwargs["data"]["name"] == "run-job" + assert mock_hub.kwargs["data"]["reason"] == "failure-reason" ################ From 8ced6609e6fcc95855f43cf9fc1d94b59836b57f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 20 Dec 2024 10:15:48 +0100 Subject: [PATCH 11/30] Rename scripts (#3885) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/test-integrations-ai.yml | 6 ++++-- .github/workflows/test-integrations-aws.yml | 6 ++++-- .github/workflows/test-integrations-cloud.yml | 6 ++++-- .github/workflows/test-integrations-common.yml | 6 ++++-- .github/workflows/test-integrations-dbs.yml | 6 ++++-- .github/workflows/test-integrations-graphql.yml | 6 ++++-- .github/workflows/test-integrations-misc.yml | 6 ++++-- .github/workflows/test-integrations-network.yml | 6 ++++-- .github/workflows/test-integrations-tasks.yml | 6 ++++-- .github/workflows/test-integrations-web-1.yml | 6 ++++-- .github/workflows/test-integrations-web-2.yml | 6 ++++-- ...er-versions.sh => aws-delete-lambda-layer-versions.sh} | 0 scripts/split_tox_gh_actions/__init__.py | 0 scripts/split_tox_gh_actions/requirements.txt | 1 + .../split_tox_gh_actions.py} | 8 ++++---- .../templates/base.jinja | 6 ++++-- .../templates/check_permissions.jinja | 0 .../templates/check_required.jinja | 0 .../templates/test_group.jinja | 0 20 files changed, 55 insertions(+), 30 deletions(-) rename scripts/{aws-delete-lamba-layer-versions.sh => aws-delete-lambda-layer-versions.sh} (100%) create mode 100644 scripts/split_tox_gh_actions/__init__.py create mode 100644 scripts/split_tox_gh_actions/requirements.txt rename scripts/{split-tox-gh-actions/split-tox-gh-actions.py => split_tox_gh_actions/split_tox_gh_actions.py} (96%) rename scripts/{split-tox-gh-actions => split_tox_gh_actions}/templates/base.jinja (87%) rename scripts/{split-tox-gh-actions => split_tox_gh_actions}/templates/check_permissions.jinja (100%) rename scripts/{split-tox-gh-actions => split_tox_gh_actions}/templates/check_required.jinja (100%) rename scripts/{split-tox-gh-actions => split_tox_gh_actions}/templates/test_group.jinja (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed035b4ab0..7ef6604e39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,8 @@ jobs: python-version: 3.12 - run: | - pip install jinja2 - python scripts/split-tox-gh-actions/split-tox-gh-actions.py --fail-on-changes + pip install -r scripts/split_tox_gh_actions/requirements.txt + python scripts/split_tox_gh_actions/split_tox_gh_actions.py --fail-on-changes build_lambda_layer: name: Build Package diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 8be64736c1..c5e1f6b87e 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test AI on: push: diff --git a/.github/workflows/test-integrations-aws.yml b/.github/workflows/test-integrations-aws.yml index 6eed3a3ab1..54610f1abd 100644 --- a/.github/workflows/test-integrations-aws.yml +++ b/.github/workflows/test-integrations-aws.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test AWS on: push: diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml index 677385e405..f72fec9f9f 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test Cloud on: push: diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 9c476553f5..0837c60c30 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test Common on: push: diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index cbaa2c32d2..a4aefa6a51 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test DBs on: push: diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index d582717fff..ab7e81dcd6 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test GraphQL on: push: diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 00b1286362..1a4e910383 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test Misc on: push: diff --git a/.github/workflows/test-integrations-network.yml b/.github/workflows/test-integrations-network.yml index 8f6bd9fd61..f41fd86b29 100644 --- a/.github/workflows/test-integrations-network.yml +++ b/.github/workflows/test-integrations-network.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test Network on: push: diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index 74c868d9b9..9910b75568 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test Tasks on: push: diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml index 5be067a36b..fb7a9247d5 100644 --- a/.github/workflows/test-integrations-web-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test Web 1 on: push: diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index 7ce0399a13..1910d5999e 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja name: Test Web 2 on: push: diff --git a/scripts/aws-delete-lamba-layer-versions.sh b/scripts/aws-delete-lambda-layer-versions.sh similarity index 100% rename from scripts/aws-delete-lamba-layer-versions.sh rename to scripts/aws-delete-lambda-layer-versions.sh diff --git a/scripts/split_tox_gh_actions/__init__.py b/scripts/split_tox_gh_actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/split_tox_gh_actions/requirements.txt b/scripts/split_tox_gh_actions/requirements.txt new file mode 100644 index 0000000000..7f7afbf3bf --- /dev/null +++ b/scripts/split_tox_gh_actions/requirements.txt @@ -0,0 +1 @@ +jinja2 diff --git a/scripts/split-tox-gh-actions/split-tox-gh-actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py similarity index 96% rename from scripts/split-tox-gh-actions/split-tox-gh-actions.py rename to scripts/split_tox_gh_actions/split_tox_gh_actions.py index 26d13390c2..1b53093c5e 100755 --- a/scripts/split-tox-gh-actions/split-tox-gh-actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -8,7 +8,7 @@ Whenever tox.ini is changed, this script needs to be run. Usage: - python split-tox-gh-actions.py [--fail-on-changes] + python split_tox_gh_actions.py [--fail-on-changes] If the parameter `--fail-on-changes` is set, the script will raise a RuntimeError in case the yaml files have been changed by the scripts execution. This is used in CI to check if the yaml files @@ -158,7 +158,7 @@ def main(fail_on_changes): if missing_frameworks: raise RuntimeError( "Please add the following frameworks to the corresponding group " - "in `GROUPS` in `scripts/split-tox-gh-actions/split-tox-gh-actions.py: " + "in `GROUPS` in `scripts/split_tox_gh_actions/split_tox_gh_actions.py: " + ", ".join(missing_frameworks) ) @@ -176,9 +176,9 @@ def main(fail_on_changes): if old_hash != new_hash: raise RuntimeError( "The yaml configuration files have changed. This means that either `tox.ini` " - "or one of the constants in `split-tox-gh-actions.py` has changed " + "or one of the constants in `split_tox_gh_actions.py` has changed " "but the changes have not been propagated to the GitHub actions config files. " - "Please run `python scripts/split-tox-gh-actions/split-tox-gh-actions.py` " + "Please run `python scripts/split_tox_gh_actions/split_tox_gh_actions.py` " "locally and commit the changes of the yaml configuration files to continue. " ) diff --git a/scripts/split-tox-gh-actions/templates/base.jinja b/scripts/split_tox_gh_actions/templates/base.jinja similarity index 87% rename from scripts/split-tox-gh-actions/templates/base.jinja rename to scripts/split_tox_gh_actions/templates/base.jinja index 23f051de42..16dbc04a76 100644 --- a/scripts/split-tox-gh-actions/templates/base.jinja +++ b/scripts/split_tox_gh_actions/templates/base.jinja @@ -1,5 +1,7 @@ -# Do not edit this file. This file is generated automatically by executing -# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja {% with lowercase_group=group | replace(" ", "_") | lower %} name: Test {{ group }} diff --git a/scripts/split-tox-gh-actions/templates/check_permissions.jinja b/scripts/split_tox_gh_actions/templates/check_permissions.jinja similarity index 100% rename from scripts/split-tox-gh-actions/templates/check_permissions.jinja rename to scripts/split_tox_gh_actions/templates/check_permissions.jinja diff --git a/scripts/split-tox-gh-actions/templates/check_required.jinja b/scripts/split_tox_gh_actions/templates/check_required.jinja similarity index 100% rename from scripts/split-tox-gh-actions/templates/check_required.jinja rename to scripts/split_tox_gh_actions/templates/check_required.jinja diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split_tox_gh_actions/templates/test_group.jinja similarity index 100% rename from scripts/split-tox-gh-actions/templates/test_group.jinja rename to scripts/split_tox_gh_actions/templates/test_group.jinja From f6281f557fe62c847a0aca95eb666129e893cf32 Mon Sep 17 00:00:00 2001 From: ffelixg <142172984+ffelixg@users.noreply.github.com> Date: Fri, 20 Dec 2024 12:34:12 +0100 Subject: [PATCH 12/30] Fix lru cache copying (#3883) A simpler and better LRU Cache implementation that prevents data leaking between copied caches. Fixes #3852 --------- Co-authored-by: Anton Pirker --- sentry_sdk/_lru_cache.py | 195 +++++++-------------------------------- tests/test_lru_cache.py | 37 +++++++- tests/test_scope.py | 22 +++++ 3 files changed, 93 insertions(+), 161 deletions(-) diff --git a/sentry_sdk/_lru_cache.py b/sentry_sdk/_lru_cache.py index 825c773529..09eae27df2 100644 --- a/sentry_sdk/_lru_cache.py +++ b/sentry_sdk/_lru_cache.py @@ -1,181 +1,56 @@ -""" -A fork of Python 3.6's stdlib lru_cache (found in Python's 'cpython/Lib/functools.py') -adapted into a data structure for single threaded uses. +from typing import TYPE_CHECKING -https://github.com/python/cpython/blob/v3.6.12/Lib/functools.py +if TYPE_CHECKING: + from typing import Any -Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; - -All Rights Reserved - - -PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 --------------------------------------------- - -1. This LICENSE AGREEMENT is between the Python Software Foundation -("PSF"), and the Individual or Organization ("Licensee") accessing and -otherwise using this software ("Python") in source or binary form and -its associated documentation. - -2. Subject to the terms and conditions of this License Agreement, PSF hereby -grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, -analyze, test, perform and/or display publicly, prepare derivative works, -distribute, and otherwise use Python alone or in any derivative version, -provided, however, that PSF's License Agreement and PSF's notice of copyright, -i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, -2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; -All Rights Reserved" are retained in Python alone or in any derivative version -prepared by Licensee. - -3. In the event Licensee prepares a derivative work that is based on -or incorporates Python or any part thereof, and wants to make -the derivative work available to others as provided herein, then -Licensee hereby agrees to include in any such work a brief summary of -the changes made to Python. - -4. PSF is making Python available to Licensee on an "AS IS" -basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR -IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND -DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS -FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT -INFRINGE ANY THIRD PARTY RIGHTS. - -5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON -FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS -A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, -OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. - -6. This License Agreement will automatically terminate upon a material -breach of its terms and conditions. - -7. Nothing in this License Agreement shall be deemed to create any -relationship of agency, partnership, or joint venture between PSF and -Licensee. This License Agreement does not grant permission to use PSF -trademarks or trade name in a trademark sense to endorse or promote -products or services of Licensee, or any third party. - -8. By copying, installing or otherwise using Python, Licensee -agrees to be bound by the terms and conditions of this License -Agreement. - -""" - -from copy import copy, deepcopy - -SENTINEL = object() - - -# aliases to the entries in a node -PREV = 0 -NEXT = 1 -KEY = 2 -VALUE = 3 +_SENTINEL = object() class LRUCache: def __init__(self, max_size): - assert max_size > 0 - + # type: (int) -> None + if max_size <= 0: + raise AssertionError(f"invalid max_size: {max_size}") self.max_size = max_size - self.full = False - - self.cache = {} - - # root of the circularly linked list to keep track of - # the least recently used key - self.root = [] # type: ignore - # the node looks like [PREV, NEXT, KEY, VALUE] - self.root[:] = [self.root, self.root, None, None] - + self._data = {} # type: dict[Any, Any] self.hits = self.misses = 0 + self.full = False def __copy__(self): - cache = LRUCache(self.max_size) - cache.full = self.full - cache.cache = copy(self.cache) - cache.root = deepcopy(self.root) - return cache + # type: () -> LRUCache + new = LRUCache(max_size=self.max_size) + new.hits = self.hits + new.misses = self.misses + new.full = self.full + new._data = self._data.copy() + return new def set(self, key, value): - link = self.cache.get(key, SENTINEL) - - if link is not SENTINEL: - # have to move the node to the front of the linked list - link_prev, link_next, _key, _value = link - - # first remove the node from the lsnked list - link_prev[NEXT] = link_next - link_next[PREV] = link_prev - - # insert the node between the root and the last - last = self.root[PREV] - last[NEXT] = self.root[PREV] = link - link[PREV] = last - link[NEXT] = self.root - - # update the value - link[VALUE] = value - + # type: (Any, Any) -> None + current = self._data.pop(key, _SENTINEL) + if current is not _SENTINEL: + self._data[key] = value elif self.full: - # reuse the root node, so update its key/value - old_root = self.root - old_root[KEY] = key - old_root[VALUE] = value - - self.root = old_root[NEXT] - old_key = self.root[KEY] - - self.root[KEY] = self.root[VALUE] = None - - del self.cache[old_key] - - self.cache[key] = old_root - + self._data.pop(next(iter(self._data))) + self._data[key] = value else: - # insert new node after last - last = self.root[PREV] - link = [last, self.root, key, value] - last[NEXT] = self.root[PREV] = self.cache[key] = link - self.full = len(self.cache) >= self.max_size + self._data[key] = value + self.full = len(self._data) >= self.max_size def get(self, key, default=None): - link = self.cache.get(key, SENTINEL) - - if link is SENTINEL: + # type: (Any, Any) -> Any + try: + ret = self._data.pop(key) + except KeyError: self.misses += 1 - return default - - # have to move the node to the front of the linked list - link_prev, link_next, _key, _value = link - - # first remove the node from the lsnked list - link_prev[NEXT] = link_next - link_next[PREV] = link_prev - - # insert the node between the root and the last - last = self.root[PREV] - last[NEXT] = self.root[PREV] = link - link[PREV] = last - link[NEXT] = self.root - - self.hits += 1 + ret = default + else: + self.hits += 1 + self._data[key] = ret - return link[VALUE] + return ret def get_all(self): - nodes = [] - node = self.root[NEXT] - - # To ensure the loop always terminates we iterate to the maximum - # size of the LRU cache. - for _ in range(self.max_size): - # The cache may not be full. We exit early if we've wrapped - # around to the head. - if node is self.root: - break - nodes.append((node[KEY], node[VALUE])) - node = node[NEXT] - - return nodes + # type: () -> list[tuple[Any, Any]] + return list(self._data.items()) diff --git a/tests/test_lru_cache.py b/tests/test_lru_cache.py index cab9bbc7eb..1a54ed83d3 100644 --- a/tests/test_lru_cache.py +++ b/tests/test_lru_cache.py @@ -1,5 +1,5 @@ import pytest -from copy import copy +from copy import copy, deepcopy from sentry_sdk._lru_cache import LRUCache @@ -76,3 +76,38 @@ def test_cache_copy(): cache.get(1) assert copied.get_all() == [(1, 1), (2, 2), (3, 3)] assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] + + +def test_cache_deepcopy(): + cache = LRUCache(3) + cache.set(0, 0) + cache.set(1, 1) + + copied = deepcopy(cache) + cache.set(2, 2) + cache.set(3, 3) + assert copied.get_all() == [(0, 0), (1, 1)] + assert cache.get_all() == [(1, 1), (2, 2), (3, 3)] + + copied = deepcopy(cache) + cache.get(1) + assert copied.get_all() == [(1, 1), (2, 2), (3, 3)] + assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] + + +def test_cache_pollution(): + cache1 = LRUCache(max_size=2) + cache1.set(1, True) + cache2 = copy(cache1) + cache2.set(1, False) + assert cache1.get(1) is True + assert cache2.get(1) is False + + +def test_cache_pollution_deepcopy(): + cache1 = LRUCache(max_size=2) + cache1.set(1, True) + cache2 = deepcopy(cache1) + cache2.set(1, False) + assert cache1.get(1) is True + assert cache2.get(1) is False diff --git a/tests/test_scope.py b/tests/test_scope.py index a03eb07a99..9b16dc4344 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -43,6 +43,28 @@ def test_all_slots_copied(): assert getattr(scope_copy, attr) == getattr(scope, attr) +def test_scope_flags_copy(): + # Assert forking creates a deepcopy of the flag buffer. The new + # scope is free to mutate without consequence to the old scope. The + # old scope is free to mutate without consequence to the new scope. + old_scope = Scope() + old_scope.flags.set("a", True) + + new_scope = old_scope.fork() + new_scope.flags.set("a", False) + old_scope.flags.set("b", True) + new_scope.flags.set("c", True) + + assert old_scope.flags.get() == [ + {"flag": "a", "result": True}, + {"flag": "b", "result": True}, + ] + assert new_scope.flags.get() == [ + {"flag": "a", "result": False}, + {"flag": "c", "result": True}, + ] + + def test_merging(sentry_init, capture_events): sentry_init() From 00c5961cadd23ded77982b085d36ce526ca8ece3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 09:43:02 +0100 Subject: [PATCH 13/30] build(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 (#3892) * build(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.1 to 5.1.2. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v5.1.1...v5.1.2) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Updated template * Update linting config to work with new mypy version --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Anton Pirker --- .github/workflows/test-integrations-ai.yml | 4 ++-- .github/workflows/test-integrations-aws.yml | 2 +- .github/workflows/test-integrations-cloud.yml | 4 ++-- .github/workflows/test-integrations-common.yml | 2 +- .github/workflows/test-integrations-dbs.yml | 4 ++-- .github/workflows/test-integrations-graphql.yml | 4 ++-- .github/workflows/test-integrations-misc.yml | 4 ++-- .github/workflows/test-integrations-network.yml | 4 ++-- .github/workflows/test-integrations-tasks.yml | 4 ++-- .github/workflows/test-integrations-web-1.yml | 4 ++-- .github/workflows/test-integrations-web-2.yml | 4 ++-- .../split_tox_gh_actions/templates/test_group.jinja | 2 +- sentry_sdk/client.py | 6 +++--- sentry_sdk/integrations/rust_tracing.py | 10 +++++----- 14 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index c5e1f6b87e..2fd6995a5f 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -80,7 +80,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -152,7 +152,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-aws.yml b/.github/workflows/test-integrations-aws.yml index 54610f1abd..f83e3379f6 100644 --- a/.github/workflows/test-integrations-aws.yml +++ b/.github/workflows/test-integrations-aws.yml @@ -99,7 +99,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml index f72fec9f9f..9e34dc6b2b 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -76,7 +76,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -144,7 +144,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 0837c60c30..f1806597af 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -64,7 +64,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index a4aefa6a51..d9bea0611b 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -103,7 +103,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -198,7 +198,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index ab7e81dcd6..7138204e16 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -76,7 +76,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -144,7 +144,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 1a4e910383..79b7ba020d 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -92,7 +92,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -176,7 +176,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-network.yml b/.github/workflows/test-integrations-network.yml index f41fd86b29..1b9ee3c529 100644 --- a/.github/workflows/test-integrations-network.yml +++ b/.github/workflows/test-integrations-network.yml @@ -76,7 +76,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -144,7 +144,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index 9910b75568..0f97146d6d 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -94,7 +94,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -180,7 +180,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml index fb7a9247d5..53206f764f 100644 --- a/.github/workflows/test-integrations-web-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -94,7 +94,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -180,7 +180,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index 1910d5999e..f1fbec6c67 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -100,7 +100,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -192,7 +192,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/scripts/split_tox_gh_actions/templates/test_group.jinja b/scripts/split_tox_gh_actions/templates/test_group.jinja index 7225bbbfe5..186d70c9fd 100644 --- a/scripts/split_tox_gh_actions/templates/test_group.jinja +++ b/scripts/split_tox_gh_actions/templates/test_group.jinja @@ -92,7 +92,7 @@ - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.1.2 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index db2cc19110..cf345c41f9 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -532,7 +532,7 @@ def _prepare_event( for key in "release", "environment", "server_name", "dist": if event.get(key) is None and self.options[key] is not None: - event[key] = str(self.options[key]).strip() # type: ignore[literal-required] + event[key] = str(self.options[key]).strip() if event.get("sdk") is None: sdk_info = dict(SDK_INFO) sdk_info["integrations"] = sorted(self.integrations.keys()) @@ -581,7 +581,7 @@ def _prepare_event( self.transport.record_lost_event( "before_send", data_category="error" ) - event = new_event # type: ignore + event = new_event before_send_transaction = self.options["before_send_transaction"] if ( @@ -611,7 +611,7 @@ def _prepare_event( reason="before_send", data_category="span", quantity=spans_delta ) - event = new_event # type: ignore + event = new_event return event diff --git a/sentry_sdk/integrations/rust_tracing.py b/sentry_sdk/integrations/rust_tracing.py index ae52c850c3..e4c211814f 100644 --- a/sentry_sdk/integrations/rust_tracing.py +++ b/sentry_sdk/integrations/rust_tracing.py @@ -44,11 +44,11 @@ class RustTracingLevel(Enum): - Trace: str = "TRACE" - Debug: str = "DEBUG" - Info: str = "INFO" - Warn: str = "WARN" - Error: str = "ERROR" + Trace = "TRACE" + Debug = "DEBUG" + Info = "INFO" + Warn = "WARN" + Error = "ERROR" class EventTypeMapping(Enum): From 60fb6fc4eacb3b4e8fffd81a0a6079e0ea31bfcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 08:56:54 +0000 Subject: [PATCH 14/30] build(deps): bump actions/create-github-app-token from 1.11.0 to 1.11.1 (#3893) Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 1.11.0 to 1.11.1. - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Commits](https://github.com/actions/create-github-app-token/compare/5d869da34e18e7287c1daad50e0b8ea0f506ce69...c1a285145b9d317df6ced56c09f525b5c2b6f755) --- updated-dependencies: - dependency-name: actions/create-github-app-token dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 2cd3dfb2ac..6450150138 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@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From c3516db643af20396ea981393431646f1a3ef123 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 23 Dec 2024 02:02:20 -0800 Subject: [PATCH 15/30] ref(flags): register LD hook in setup instead of init, and don't check for initialization (#3890) --------- Co-authored-by: Anton Pirker --- sentry_sdk/integrations/launchdarkly.py | 14 ++++++------- .../launchdarkly/test_launchdarkly.py | 21 +++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/sentry_sdk/integrations/launchdarkly.py b/sentry_sdk/integrations/launchdarkly.py index a9eef9e1a9..066464cc22 100644 --- a/sentry_sdk/integrations/launchdarkly.py +++ b/sentry_sdk/integrations/launchdarkly.py @@ -20,6 +20,7 @@ class LaunchDarklyIntegration(Integration): identifier = "launchdarkly" + _ld_client = None # type: LDClient | None def __init__(self, ld_client=None): # type: (LDClient | None) -> None @@ -27,20 +28,19 @@ def __init__(self, ld_client=None): :param client: An initialized LDClient instance. If a client is not provided, this integration will attempt to use the shared global instance. """ + self.__class__._ld_client = ld_client + + @staticmethod + def setup_once(): + # type: () -> None try: - client = ld_client or ldclient.get() + client = LaunchDarklyIntegration._ld_client or ldclient.get() except Exception as exc: raise DidNotEnable("Error getting LaunchDarkly client. " + repr(exc)) - if not client.is_initialized(): - raise DidNotEnable("LaunchDarkly client is not initialized.") - # Register the flag collection hook with the LD client. client.add_hook(LaunchDarklyHook()) - @staticmethod - def setup_once(): - # type: () -> None scope = sentry_sdk.get_current_scope() scope.add_error_processor(flag_error_processor) diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index f66a4219ec..e7576bb469 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -168,10 +168,14 @@ async def runner(): } -def test_launchdarkly_integration_did_not_enable(monkeypatch): - # Client is not passed in and set_config wasn't called. - # TODO: Bad practice to access internals like this. We can skip this test, or remove this - # case entirely (force user to pass in a client instance). +def test_launchdarkly_integration_did_not_enable(sentry_init, uninstall_integration): + """ + Setup should fail when using global client and ldclient.set_config wasn't called. + + We're accessing ldclient internals to set up this test, so it might break if launchdarkly's + implementation changes. + """ + ldclient._reset_client() try: ldclient.__lock.lock() @@ -179,11 +183,6 @@ def test_launchdarkly_integration_did_not_enable(monkeypatch): finally: ldclient.__lock.unlock() + uninstall_integration(LaunchDarklyIntegration.identifier) with pytest.raises(DidNotEnable): - LaunchDarklyIntegration() - - # Client not initialized. - client = LDClient(config=Config("sdk-key")) - monkeypatch.setattr(client, "is_initialized", lambda: False) - with pytest.raises(DidNotEnable): - LaunchDarklyIntegration(ld_client=client) + sentry_init(integrations=[LaunchDarklyIntegration()]) From bb85c26a2b877965c5e0a0cd841b7f676ec2533e Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Mon, 23 Dec 2024 04:37:17 -0600 Subject: [PATCH 16/30] Fix cache pollution from mutable reference (#3887) - Removes manual overrides of copy behavior and leaves it up to the caller. - E.g. a future use case may require a non-deepcopy. If we override copy they would have to remove the dunder copy, update every implementation which relies copy, before finally creating their own copy implementation. - Deepcopies the flag buffer. - Though we do not cache mutable references yet we may soon and so this foot gun should be removed from possibility. - Removes "copy" test coverage from `test_lru_cache.py`. We're no longer assuming copy usage and leave it up to the caller. - The existing test in `tests/test_scope.py` covers the cache pollution case [originally mentioned here](https://github.com/getsentry/sentry-python/issues/3852). - The mutable cache pollution case is not covered because we do not currently cache mutable objects. In general a generic class should assume as few implementation details as possible. If we leave the existing copy method someone may assume copy semantics and rely on it in a way that is inappropriate. Closes: https://github.com/getsentry/sentry-python/issues/3886 Co-authored-by: Anton Pirker --- sentry_sdk/_lru_cache.py | 9 ------- sentry_sdk/flag_utils.py | 7 ------ sentry_sdk/scope.py | 4 +-- tests/test_lru_cache.py | 53 ---------------------------------------- 4 files changed, 2 insertions(+), 71 deletions(-) diff --git a/sentry_sdk/_lru_cache.py b/sentry_sdk/_lru_cache.py index 09eae27df2..cbadd9723b 100644 --- a/sentry_sdk/_lru_cache.py +++ b/sentry_sdk/_lru_cache.py @@ -17,15 +17,6 @@ def __init__(self, max_size): self.hits = self.misses = 0 self.full = False - def __copy__(self): - # type: () -> LRUCache - new = LRUCache(max_size=self.max_size) - new.hits = self.hits - new.misses = self.misses - new.full = self.full - new._data = self._data.copy() - return new - def set(self, key, value): # type: (Any, Any) -> None current = self._data.pop(key, _SENTINEL) diff --git a/sentry_sdk/flag_utils.py b/sentry_sdk/flag_utils.py index 2b345a7f0b..cf4800e855 100644 --- a/sentry_sdk/flag_utils.py +++ b/sentry_sdk/flag_utils.py @@ -1,4 +1,3 @@ -from copy import copy from typing import TYPE_CHECKING import sentry_sdk @@ -25,12 +24,6 @@ def clear(self): # type: () -> None self.buffer = LRUCache(self.capacity) - def __copy__(self): - # type: () -> FlagBuffer - buffer = FlagBuffer(capacity=self.capacity) - buffer.buffer = copy(self.buffer) - return buffer - def get(self): # type: () -> list[FlagData] return [{"flag": key, "result": value} for key, value in self.buffer.get_all()] diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index bb45143c48..cf72fabdd1 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1,7 +1,7 @@ import os import sys import warnings -from copy import copy +from copy import copy, deepcopy from collections import deque from contextlib import contextmanager from enum import Enum @@ -252,7 +252,7 @@ def __copy__(self): rv._last_event_id = self._last_event_id - rv._flags = copy(self._flags) + rv._flags = deepcopy(self._flags) return rv diff --git a/tests/test_lru_cache.py b/tests/test_lru_cache.py index 1a54ed83d3..3e9c0ac964 100644 --- a/tests/test_lru_cache.py +++ b/tests/test_lru_cache.py @@ -1,5 +1,4 @@ import pytest -from copy import copy, deepcopy from sentry_sdk._lru_cache import LRUCache @@ -59,55 +58,3 @@ def test_cache_get_all(): assert cache.get_all() == [(1, 1), (2, 2), (3, 3)] cache.get(1) assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] - - -def test_cache_copy(): - cache = LRUCache(3) - cache.set(0, 0) - cache.set(1, 1) - - copied = copy(cache) - cache.set(2, 2) - cache.set(3, 3) - assert copied.get_all() == [(0, 0), (1, 1)] - assert cache.get_all() == [(1, 1), (2, 2), (3, 3)] - - copied = copy(cache) - cache.get(1) - assert copied.get_all() == [(1, 1), (2, 2), (3, 3)] - assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] - - -def test_cache_deepcopy(): - cache = LRUCache(3) - cache.set(0, 0) - cache.set(1, 1) - - copied = deepcopy(cache) - cache.set(2, 2) - cache.set(3, 3) - assert copied.get_all() == [(0, 0), (1, 1)] - assert cache.get_all() == [(1, 1), (2, 2), (3, 3)] - - copied = deepcopy(cache) - cache.get(1) - assert copied.get_all() == [(1, 1), (2, 2), (3, 3)] - assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] - - -def test_cache_pollution(): - cache1 = LRUCache(max_size=2) - cache1.set(1, True) - cache2 = copy(cache1) - cache2.set(1, False) - assert cache1.get(1) is True - assert cache2.get(1) is False - - -def test_cache_pollution_deepcopy(): - cache1 = LRUCache(max_size=2) - cache1.set(1, True) - cache2 = deepcopy(cache1) - cache2.set(1, False) - assert cache1.get(1) is True - assert cache2.get(1) is False From fd224946e084ad6bf6e55d6c4216cb8399e15c7e Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 7 Jan 2025 01:56:08 -0800 Subject: [PATCH 17/30] fix(flags): fix/refactor flaky launchdarkly tests (#3896) Fixes flakes ([example](https://github.com/getsentry/sentry-python/actions/runs/12465223145/job/34790658871?pr=3887)) caused by background processes in `LDClient` trying to connect to a non-existent server (we're mocking the flag data through `TestData`). --- .../launchdarkly/test_launchdarkly.py | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index e7576bb469..9b2bbb6b86 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -22,7 +22,12 @@ def test_launchdarkly_integration( sentry_init, use_global_client, capture_events, uninstall_integration ): td = TestData.data_source() - config = Config("sdk-key", update_processor_class=td) + td.update(td.flag("hello").variation_for_all(True)) + td.update(td.flag("world").variation_for_all(True)) + # Disable background requests as we aren't using a server. + config = Config( + "sdk-key", update_processor_class=td, diagnostic_opt_out=True, send_events=False + ) uninstall_integration(LaunchDarklyIntegration.identifier) if use_global_client: @@ -33,10 +38,6 @@ def test_launchdarkly_integration( client = LDClient(config=config) sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) - # Set test values - td.update(td.flag("hello").variation_for_all(True)) - td.update(td.flag("world").variation_for_all(True)) - # Evaluate client.variation("hello", Context.create("my-org", "organization"), False) client.variation("world", Context.create("user1", "user"), False) @@ -59,7 +60,16 @@ def test_launchdarkly_integration_threaded( sentry_init, capture_events, uninstall_integration ): td = TestData.data_source() - client = LDClient(config=Config("sdk-key", update_processor_class=td)) + td.update(td.flag("hello").variation_for_all(True)) + td.update(td.flag("world").variation_for_all(True)) + client = LDClient( + config=Config( + "sdk-key", + update_processor_class=td, + diagnostic_opt_out=True, # Disable background requests as we aren't using a server. + send_events=False, + ) + ) context = Context.create("user1") uninstall_integration(LaunchDarklyIntegration.identifier) @@ -75,8 +85,6 @@ def task(flag_key): sentry_sdk.set_tag("task_id", flag_key) sentry_sdk.capture_exception(Exception("something wrong!")) - td.update(td.flag("hello").variation_for_all(True)) - td.update(td.flag("world").variation_for_all(False)) # Capture an eval before we split isolation scopes. client.variation("hello", context, False) @@ -104,7 +112,7 @@ def task(flag_key): assert events[2]["contexts"]["flags"] == { "values": [ {"flag": "hello", "result": True}, - {"flag": "world", "result": False}, + {"flag": "world", "result": True}, ] } @@ -118,7 +126,16 @@ def test_launchdarkly_integration_asyncio( asyncio = pytest.importorskip("asyncio") td = TestData.data_source() - client = LDClient(config=Config("sdk-key", update_processor_class=td)) + td.update(td.flag("hello").variation_for_all(True)) + td.update(td.flag("world").variation_for_all(True)) + client = LDClient( + config=Config( + "sdk-key", + update_processor_class=td, + diagnostic_opt_out=True, # Disable background requests as we aren't using a server. + send_events=False, + ) + ) context = Context.create("user1") uninstall_integration(LaunchDarklyIntegration.identifier) @@ -135,8 +152,6 @@ async def task(flag_key): async def runner(): return asyncio.gather(task("world"), task("other")) - td.update(td.flag("hello").variation_for_all(True)) - td.update(td.flag("world").variation_for_all(False)) # Capture an eval before we split isolation scopes. client.variation("hello", context, False) @@ -163,7 +178,7 @@ async def runner(): assert events[2]["contexts"]["flags"] == { "values": [ {"flag": "hello", "result": True}, - {"flag": "world", "result": False}, + {"flag": "world", "result": True}, ] } From 235f5586056acdb1eedf70f73ddea8c962d57301 Mon Sep 17 00:00:00 2001 From: danmr <136265172+danmr@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:21:43 +0300 Subject: [PATCH 18/30] fix: preserve ARQ enqueue_job __kwdefaults__ after patching (#3903) Co-authored-by: Marukhin Daniil --- sentry_sdk/integrations/arq.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index d568714fe2..d61499139b 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -71,6 +71,7 @@ def setup_once(): def patch_enqueue_job(): # type: () -> None old_enqueue_job = ArqRedis.enqueue_job + original_kwdefaults = old_enqueue_job.__kwdefaults__ async def _sentry_enqueue_job(self, function, *args, **kwargs): # type: (ArqRedis, str, *Any, **Any) -> Optional[Job] @@ -83,6 +84,7 @@ async def _sentry_enqueue_job(self, function, *args, **kwargs): ): return await old_enqueue_job(self, function, *args, **kwargs) + _sentry_enqueue_job.__kwdefaults__ = original_kwdefaults ArqRedis.enqueue_job = _sentry_enqueue_job From 7f73c9edcf87b95163437a7aff3a7ed828ec11d9 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Tue, 7 Jan 2025 13:38:12 +0100 Subject: [PATCH 19/30] Update test matrix for Sanic (#3904) Fixes the failing test suite. --- .github/workflows/test-integrations-web-2.yml | 2 +- tox.ini | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index f1fbec6c67..39c1eba535 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.11","3.12","3.13"] + python-version: ["3.6","3.7","3.8","3.9","3.11","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/tox.ini b/tox.ini index 717ea62141..37273b2a35 100644 --- a/tox.ini +++ b/tox.ini @@ -247,9 +247,8 @@ envlist = # Sanic {py3.6,py3.7}-sanic-v{0.8} {py3.6,py3.8}-sanic-v{20} - {py3.7,py3.11}-sanic-v{22} - {py3.7,py3.11}-sanic-v{23} - {py3.8,py3.11,py3.12}-sanic-latest + {py3.8,py3.11,py3.12}-sanic-v{24.6} + {py3.9,py3.12,py3.13}-sanic-latest # Spark {py3.8,py3.10,py3.11}-spark-v{3.1,3.3,3.5} @@ -652,13 +651,12 @@ deps = # Sanic sanic: websockets<11.0 sanic: aiohttp - sanic-v{22,23}: sanic_testing + sanic-v{24.6}: sanic_testing sanic-latest: sanic_testing {py3.6}-sanic: aiocontextvars==0.2.1 sanic-v0.8: sanic~=0.8.0 sanic-v20: sanic~=20.0 - sanic-v22: sanic~=22.0 - sanic-v23: sanic~=23.0 + sanic-v24.6: sanic~=24.6.0 sanic-latest: sanic # Spark From 8fa6d3d814c76faf72098e4f4ba2d2207e87f5b9 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Tue, 7 Jan 2025 07:12:47 -0600 Subject: [PATCH 20/30] =?UTF-8?q?Revert=20"ref(flags):=20register=20LD=20h?= =?UTF-8?q?ook=20in=20setup=20instead=20of=20init,=20and=20don't=20chec?= =?UTF-8?q?=E2=80=A6"=20(#3900)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutating a class attribute on `__init__` violates encapsulation and will lead to strange errors. We need to rethink how we want to implement this before we merge any code. A simple reproduction of the issue: ```python >>> class X: ... y = 0 ... def __init__(self, z): ... self.__class__.y = z ... >>> a = X(1) >>> b = X(2) >>> X.y 2 >>> a.y 2 >>> b.y 2 ``` Reverts getsentry/sentry-python#3890 This reverts commit c3516db643af20396ea981393431646f1a3ef123. Co-authored-by: Anton Pirker --- sentry_sdk/integrations/launchdarkly.py | 14 ++++++------- .../launchdarkly/test_launchdarkly.py | 21 ++++++++++--------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/sentry_sdk/integrations/launchdarkly.py b/sentry_sdk/integrations/launchdarkly.py index 066464cc22..a9eef9e1a9 100644 --- a/sentry_sdk/integrations/launchdarkly.py +++ b/sentry_sdk/integrations/launchdarkly.py @@ -20,7 +20,6 @@ class LaunchDarklyIntegration(Integration): identifier = "launchdarkly" - _ld_client = None # type: LDClient | None def __init__(self, ld_client=None): # type: (LDClient | None) -> None @@ -28,19 +27,20 @@ def __init__(self, ld_client=None): :param client: An initialized LDClient instance. If a client is not provided, this integration will attempt to use the shared global instance. """ - self.__class__._ld_client = ld_client - - @staticmethod - def setup_once(): - # type: () -> None try: - client = LaunchDarklyIntegration._ld_client or ldclient.get() + client = ld_client or ldclient.get() except Exception as exc: raise DidNotEnable("Error getting LaunchDarkly client. " + repr(exc)) + if not client.is_initialized(): + raise DidNotEnable("LaunchDarkly client is not initialized.") + # Register the flag collection hook with the LD client. client.add_hook(LaunchDarklyHook()) + @staticmethod + def setup_once(): + # type: () -> None scope = sentry_sdk.get_current_scope() scope.add_error_processor(flag_error_processor) diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index 9b2bbb6b86..20566ce09a 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -183,14 +183,10 @@ async def runner(): } -def test_launchdarkly_integration_did_not_enable(sentry_init, uninstall_integration): - """ - Setup should fail when using global client and ldclient.set_config wasn't called. - - We're accessing ldclient internals to set up this test, so it might break if launchdarkly's - implementation changes. - """ - +def test_launchdarkly_integration_did_not_enable(monkeypatch): + # Client is not passed in and set_config wasn't called. + # TODO: Bad practice to access internals like this. We can skip this test, or remove this + # case entirely (force user to pass in a client instance). ldclient._reset_client() try: ldclient.__lock.lock() @@ -198,6 +194,11 @@ def test_launchdarkly_integration_did_not_enable(sentry_init, uninstall_integrat finally: ldclient.__lock.unlock() - uninstall_integration(LaunchDarklyIntegration.identifier) with pytest.raises(DidNotEnable): - sentry_init(integrations=[LaunchDarklyIntegration()]) + LaunchDarklyIntegration() + + # Client not initialized. + client = LDClient(config=Config("sdk-key")) + monkeypatch.setattr(client, "is_initialized", lambda: False) + with pytest.raises(DidNotEnable): + LaunchDarklyIntegration(ld_client=client) From bf65ede42172dd9bc6718b69e3ea9a9dd417c93d Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 7 Jan 2025 05:27:08 -0800 Subject: [PATCH 21/30] ref(flags): Beter naming for featureflags module and identifier (#3902) Co-authored-by: Anton Pirker --- sentry_sdk/integrations/{featureflags.py => feature_flags.py} | 4 ++-- .../integrations/{featureflags => feature_flags}/__init__.py | 0 .../test_feature_flags.py} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename sentry_sdk/integrations/{featureflags.py => feature_flags.py} (91%) rename tests/integrations/{featureflags => feature_flags}/__init__.py (100%) rename tests/integrations/{featureflags/test_featureflags.py => feature_flags/test_feature_flags.py} (98%) diff --git a/sentry_sdk/integrations/featureflags.py b/sentry_sdk/integrations/feature_flags.py similarity index 91% rename from sentry_sdk/integrations/featureflags.py rename to sentry_sdk/integrations/feature_flags.py index 46947eec72..2aeabffbfa 100644 --- a/sentry_sdk/integrations/featureflags.py +++ b/sentry_sdk/integrations/feature_flags.py @@ -16,7 +16,7 @@ class FeatureFlagsIntegration(Integration): @example ``` import sentry_sdk - from sentry_sdk.integrations.featureflags import FeatureFlagsIntegration, add_feature_flag + from sentry_sdk.integrations.feature_flags import FeatureFlagsIntegration, add_feature_flag sentry_sdk.init(dsn="my_dsn", integrations=[FeatureFlagsIntegration()]); @@ -25,7 +25,7 @@ class FeatureFlagsIntegration(Integration): ``` """ - identifier = "featureflags" + identifier = "feature_flags" @staticmethod def setup_once(): diff --git a/tests/integrations/featureflags/__init__.py b/tests/integrations/feature_flags/__init__.py similarity index 100% rename from tests/integrations/featureflags/__init__.py rename to tests/integrations/feature_flags/__init__.py diff --git a/tests/integrations/featureflags/test_featureflags.py b/tests/integrations/feature_flags/test_feature_flags.py similarity index 98% rename from tests/integrations/featureflags/test_featureflags.py rename to tests/integrations/feature_flags/test_feature_flags.py index 539e910607..ca6ac16949 100644 --- a/tests/integrations/featureflags/test_featureflags.py +++ b/tests/integrations/feature_flags/test_feature_flags.py @@ -4,7 +4,7 @@ import pytest import sentry_sdk -from sentry_sdk.integrations.featureflags import ( +from sentry_sdk.integrations.feature_flags import ( FeatureFlagsIntegration, add_feature_flag, ) From c6a89d64db965fe0ece6de10df38ab936af8f5e4 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 7 Jan 2025 06:17:03 -0800 Subject: [PATCH 22/30] feat(flags): add Unleash feature flagging integration (#3888) Adds an integration for tracking flag evaluations from [Unleash](https://www.getunleash.io/) customers. Implementation Unleash has no native support for evaluation hooks/listeners, unless the user opts in for each flag. Therefore we decided to patch the `is_enabled` and `get_variant` methods on the `UnleashClient` class. The methods are wrapped and the only side effect is writing to Sentry scope, so users shouldn't see any change in behavior. We patch one `UnleashClient` instance instead of the whole class. The reasons for this are described in - https://github.com/getsentry/sentry-python/pull/3895 It's also safer to not modify the unleash import. References - https://develop.sentry.dev/sdk/expected-features/#feature-flags - https://docs.getunleash.io/reference/sdks/python for methods we're patching/wrapping --------- Co-authored-by: Anton Pirker Co-authored-by: Colton Allen --- .github/workflows/test-integrations-misc.yml | 8 + requirements-linting.txt | 1 + .../split_tox_gh_actions.py | 1 + sentry_sdk/integrations/unleash.py | 55 ++++ setup.py | 1 + tests/conftest.py | 1 + tests/integrations/unleash/__init__.py | 3 + tests/integrations/unleash/test_unleash.py | 308 ++++++++++++++++++ tests/integrations/unleash/testutils.py | 77 +++++ tox.ini | 17 +- 10 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 sentry_sdk/integrations/unleash.py create mode 100644 tests/integrations/unleash/__init__.py create mode 100644 tests/integrations/unleash/test_unleash.py create mode 100644 tests/integrations/unleash/testutils.py diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 79b7ba020d..d524863423 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -79,6 +79,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-typer-latest" + - name: Test unleash latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-unleash-latest" - name: Generate coverage XML (Python 3.6) if: ${{ !cancelled() && matrix.python-version == '3.6' }} run: | @@ -163,6 +167,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-typer" + - name: Test unleash pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-unleash" - name: Generate coverage XML (Python 3.6) if: ${{ !cancelled() && matrix.python-version == '3.6' }} run: | diff --git a/requirements-linting.txt b/requirements-linting.txt index c3f39ecd1f..4227acc26a 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -17,4 +17,5 @@ pre-commit # local linting httpcore openfeature-sdk launchdarkly-server-sdk +UnleashClient typer diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 1b53093c5e..743677daf4 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -133,6 +133,7 @@ "pure_eval", "trytond", "typer", + "unleash", ], } diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py new file mode 100644 index 0000000000..33b0a4b9dc --- /dev/null +++ b/sentry_sdk/integrations/unleash.py @@ -0,0 +1,55 @@ +from functools import wraps +from typing import Any + +import sentry_sdk +from sentry_sdk.flag_utils import flag_error_processor +from sentry_sdk.integrations import Integration, DidNotEnable + +try: + from UnleashClient import UnleashClient +except ImportError: + raise DidNotEnable("UnleashClient is not installed") + + +class UnleashIntegration(Integration): + identifier = "unleash" + + @staticmethod + def setup_once(): + # type: () -> None + # Wrap and patch evaluation methods (instance methods) + old_is_enabled = UnleashClient.is_enabled + old_get_variant = UnleashClient.get_variant + + @wraps(old_is_enabled) + def sentry_is_enabled(self, feature, *args, **kwargs): + # type: (UnleashClient, str, *Any, **Any) -> Any + enabled = old_is_enabled(self, feature, *args, **kwargs) + + # We have no way of knowing what type of unleash feature this is, so we have to treat + # it as a boolean / toggle feature. + flags = sentry_sdk.get_current_scope().flags + flags.set(feature, enabled) + + return enabled + + @wraps(old_get_variant) + def sentry_get_variant(self, feature, *args, **kwargs): + # type: (UnleashClient, str, *Any, **Any) -> Any + variant = old_get_variant(self, feature, *args, **kwargs) + enabled = variant.get("enabled", False) + + # Payloads are not always used as the feature's value for application logic. They + # may be used for metrics or debugging context instead. Therefore, we treat every + # variant as a boolean toggle, using the `enabled` field. + flags = sentry_sdk.get_current_scope().flags + flags.set(feature, enabled) + + return variant + + UnleashClient.is_enabled = sentry_is_enabled # type: ignore + UnleashClient.get_variant = sentry_get_variant # type: ignore + + # Error processor + scope = sentry_sdk.get_current_scope() + scope.add_error_processor(flag_error_processor) diff --git a/setup.py b/setup.py index da3adcab42..9e24d59d21 100644 --- a/setup.py +++ b/setup.py @@ -80,6 +80,7 @@ def get_file_text(file_name): "starlette": ["starlette>=0.19.1"], "starlite": ["starlite>=1.48"], "tornado": ["tornado>=6"], + "unleash": ["UnleashClient>=6.0.1"], }, entry_points={ "opentelemetry_propagator": [ diff --git a/tests/conftest.py b/tests/conftest.py index c0383d94b7..b5ab7aa804 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import pytest import jsonschema + try: import gevent except ImportError: diff --git a/tests/integrations/unleash/__init__.py b/tests/integrations/unleash/__init__.py new file mode 100644 index 0000000000..33cff3e65a --- /dev/null +++ b/tests/integrations/unleash/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("UnleashClient") diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py new file mode 100644 index 0000000000..9a7a3f57bd --- /dev/null +++ b/tests/integrations/unleash/test_unleash.py @@ -0,0 +1,308 @@ +import concurrent.futures as cf +import sys +from random import random +from unittest import mock +from UnleashClient import UnleashClient + +import pytest + +import sentry_sdk +from sentry_sdk.integrations.unleash import UnleashIntegration +from tests.integrations.unleash.testutils import mock_unleash_client + + +def test_is_enabled(sentry_init, capture_events, uninstall_integration): + uninstall_integration(UnleashIntegration.identifier) + + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) + client.is_enabled("hello") + client.is_enabled("world") + client.is_enabled("other") + + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + {"flag": "other", "result": False}, + ] + } + + +def test_get_variant(sentry_init, capture_events, uninstall_integration): + uninstall_integration(UnleashIntegration.identifier) + + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + client.get_variant("no_payload_feature") + client.get_variant("string_feature") + client.get_variant("json_feature") + client.get_variant("csv_feature") + client.get_variant("number_feature") + client.get_variant("unknown_feature") + + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "no_payload_feature", "result": True}, + {"flag": "string_feature", "result": True}, + {"flag": "json_feature", "result": True}, + {"flag": "csv_feature", "result": True}, + {"flag": "number_feature", "result": True}, + {"flag": "unknown_feature", "result": False}, + ] + } + + +def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration): + uninstall_integration(UnleashIntegration.identifier) + + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + events = capture_events() + + def task(flag_key): + # Creates a new isolation scope for the thread. + # This means the evaluations in each task are captured separately. + with sentry_sdk.isolation_scope(): + client.is_enabled(flag_key) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + # Capture an eval before we split isolation scopes. + client.is_enabled("hello") + + with cf.ThreadPoolExecutor(max_workers=2) as pool: + pool.map(task, ["world", "other"]) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } + + +def test_get_variant_threaded(sentry_init, capture_events, uninstall_integration): + uninstall_integration(UnleashIntegration.identifier) + + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + events = capture_events() + + def task(flag_key): + # Creates a new isolation scope for the thread. + # This means the evaluations in each task are captured separately. + with sentry_sdk.isolation_scope(): + client.get_variant(flag_key) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + # Capture an eval before we split isolation scopes. + client.get_variant("hello") + + with cf.ThreadPoolExecutor(max_workers=2) as pool: + pool.map(task, ["no_payload_feature", "other"]) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "no_payload_feature", "result": True}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "other", "result": False}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_is_enabled_asyncio(sentry_init, capture_events, uninstall_integration): + asyncio = pytest.importorskip("asyncio") + uninstall_integration(UnleashIntegration.identifier) + + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + events = capture_events() + + async def task(flag_key): + with sentry_sdk.isolation_scope(): + client.is_enabled(flag_key) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + async def runner(): + return asyncio.gather(task("world"), task("other")) + + # Capture an eval before we split isolation scopes. + client.is_enabled("hello") + + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_get_variant_asyncio(sentry_init, capture_events, uninstall_integration): + asyncio = pytest.importorskip("asyncio") + + uninstall_integration(UnleashIntegration.identifier) + + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + events = capture_events() + + async def task(flag_key): + with sentry_sdk.isolation_scope(): + client.get_variant(flag_key) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + async def runner(): + return asyncio.gather(task("no_payload_feature"), task("other")) + + # Capture an eval before we split isolation scopes. + client.get_variant("hello") + + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "no_payload_feature", "result": True}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "other", "result": False}, + ] + } + + +def test_wraps_original(sentry_init, uninstall_integration): + with mock_unleash_client(): + client = UnleashClient() + + mock_is_enabled = mock.Mock(return_value=random() < 0.5) + mock_get_variant = mock.Mock(return_value={"enabled": random() < 0.5}) + client.is_enabled = mock_is_enabled + client.get_variant = mock_get_variant + + uninstall_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + + res = client.is_enabled("test-flag", "arg", kwarg=1) + assert res == mock_is_enabled.return_value + assert mock_is_enabled.call_args == ( + ("test-flag", "arg"), + {"kwarg": 1}, + ) + + res = client.get_variant("test-flag", "arg", kwarg=1) + assert res == mock_get_variant.return_value + assert mock_get_variant.call_args == ( + ("test-flag", "arg"), + {"kwarg": 1}, + ) + + +def test_wrapper_attributes(sentry_init, uninstall_integration): + with mock_unleash_client(): + client = UnleashClient() # <- Returns a MockUnleashClient + + original_is_enabled = client.is_enabled + original_get_variant = client.get_variant + + uninstall_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + + # Mock clients methods have not lost their qualified names after decoration. + assert client.is_enabled.__name__ == "is_enabled" + assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__ + assert client.get_variant.__name__ == "get_variant" + assert client.get_variant.__qualname__ == original_get_variant.__qualname__ diff --git a/tests/integrations/unleash/testutils.py b/tests/integrations/unleash/testutils.py new file mode 100644 index 0000000000..c424b34c3a --- /dev/null +++ b/tests/integrations/unleash/testutils.py @@ -0,0 +1,77 @@ +from contextlib import contextmanager +from UnleashClient import UnleashClient + + +@contextmanager +def mock_unleash_client(): + """ + Temporarily replaces UnleashClient's methods with mock implementations + for testing. + + This context manager swaps out UnleashClient's __init__, is_enabled, + and get_variant methods with mock versions from MockUnleashClient. + Original methods are restored when exiting the context. + + After mocking the client class the integration can be initialized. + The methods on the mock client class are overridden by the + integration and flag tracking proceeds as expected. + + Example: + with mock_unleash_client(): + client = UnleashClient() # Uses mock implementation + sentry_init(integrations=[UnleashIntegration()]) + """ + old_init = UnleashClient.__init__ + old_is_enabled = UnleashClient.is_enabled + old_get_variant = UnleashClient.get_variant + + UnleashClient.__init__ = MockUnleashClient.__init__ + UnleashClient.is_enabled = MockUnleashClient.is_enabled + UnleashClient.get_variant = MockUnleashClient.get_variant + + yield + + UnleashClient.__init__ = old_init + UnleashClient.is_enabled = old_is_enabled + UnleashClient.get_variant = old_get_variant + + +class MockUnleashClient: + + def __init__(self, *a, **kw): + self.features = { + "hello": True, + "world": False, + } + + self.feature_to_variant = { + "string_feature": { + "name": "variant1", + "enabled": True, + "payload": {"type": "string", "value": "val1"}, + }, + "json_feature": { + "name": "variant1", + "enabled": True, + "payload": {"type": "json", "value": '{"key1": 0.53}'}, + }, + "number_feature": { + "name": "variant1", + "enabled": True, + "payload": {"type": "number", "value": "134.5"}, + }, + "csv_feature": { + "name": "variant1", + "enabled": True, + "payload": {"type": "csv", "value": "abc 123\ncsbq 94"}, + }, + "no_payload_feature": {"name": "variant1", "enabled": True}, + } + + self.disabled_variant = {"name": "disabled", "enabled": False} + + def is_enabled(self, feature, *a, **kw): + return self.features.get(feature, False) + + def get_variant(self, feature, *a, **kw): + return self.feature_to_variant.get(feature, self.disabled_variant) diff --git a/tox.ini b/tox.ini index 37273b2a35..95c09a573e 100644 --- a/tox.ini +++ b/tox.ini @@ -168,6 +168,10 @@ envlist = {py3.9,py3.11,py3.12}-langchain-latest {py3.9,py3.11,py3.12}-langchain-notiktoken + # LaunchDarkly + {py3.8,py3.12,py3.13}-launchdarkly-v9.8.0 + {py3.8,py3.12,py3.13}-launchdarkly-latest + # Litestar {py3.8,py3.11}-litestar-v{2.0} {py3.8,py3.11,py3.12}-litestar-v{2.6} @@ -189,10 +193,6 @@ envlist = {py3.8,py3.12,py3.13}-openfeature-v0.7 {py3.8,py3.12,py3.13}-openfeature-latest - # LaunchDarkly - {py3.8,py3.12,py3.13}-launchdarkly-v9.8.0 - {py3.8,py3.12,py3.13}-launchdarkly-latest - # OpenTelemetry (OTel) {py3.7,py3.9,py3.12,py3.13}-opentelemetry @@ -290,6 +290,10 @@ envlist = {py3.7,py3.12,py3.13}-typer-v{0.15} {py3.7,py3.12,py3.13}-typer-latest + # Unleash + {py3.8,py3.12,py3.13}-unleash-v6.0.1 + {py3.8,py3.12,py3.13}-unleash-latest + [testenv] deps = # if you change requirements-testing.txt and your change is not being reflected @@ -571,6 +575,10 @@ deps = launchdarkly-v9.8.0: launchdarkly-server-sdk~=9.8.0 launchdarkly-latest: launchdarkly-server-sdk + # Unleash + unleash-v6.0.1: UnleashClient~=6.0.1 + unleash-latest: UnleashClient + # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro @@ -793,6 +801,7 @@ setenv = tornado: TESTPATH=tests/integrations/tornado trytond: TESTPATH=tests/integrations/trytond typer: TESTPATH=tests/integrations/typer + unleash: TESTPATH=tests/integrations/unleash socket: TESTPATH=tests/integrations/socket passenv = From 4432e26a45873080d4eaf20e769bc82f026851bb Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 9 Jan 2025 14:28:39 +0100 Subject: [PATCH 23/30] Small contribution docs update (#3909) --- Thank you for contributing to `sentry-python`! Please add tests to validate your changes, and lint your code using `tox -e linters`. Running the test suite on your PR might require maintainer approval. The AWS Lambda tests additionally require a maintainer to add a special label, and they will fail until this label is added. --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f4839f8d7..085dbd6075 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,7 +126,7 @@ pytest -rs tests/integrations/flask/ # Replace "flask" with the specific integr ## Releasing a New Version -_(only relevant for Sentry employees)_ +_(only relevant for Python SDK core team)_ ### Prerequisites From be5327356fdae8efc77a9faa9a2ffb0773e80665 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Jan 2025 15:26:50 +0100 Subject: [PATCH 24/30] Centralize minimum version checking (#3910) For [populating tox automatically](https://github.com/getsentry/sentry-python/issues/3808), we need to store min versions of frameworks/libraries in a programmatically accessible place. The obvious place for this would be in each integration; however, since integrations can't be imported unless the respective framework is installed, this couldn't be used from the script (unless we'd always install all requirements of all integrations prior to running it, which takes a non trivial amount of time). So instead I've opted for a central place within `sentry_sdk/integrations/__init__.py`. Note: the min versions probably need updating. Not sure when this was last done, but some of them look quite ancient and we probably don't support them because we'd already dropped the last Python version they'd be able to run on. --- sentry_sdk/integrations/__init__.py | 42 +++++++++++++++++++- sentry_sdk/integrations/aiohttp.py | 8 +--- sentry_sdk/integrations/anthropic.py | 9 +---- sentry_sdk/integrations/ariadne.py | 9 +---- sentry_sdk/integrations/arq.py | 8 +--- sentry_sdk/integrations/asyncpg.py | 12 +++--- sentry_sdk/integrations/boto3.py | 12 +----- sentry_sdk/integrations/bottle.py | 8 +--- sentry_sdk/integrations/celery/__init__.py | 5 +-- sentry_sdk/integrations/clickhouse_driver.py | 7 ++-- sentry_sdk/integrations/django/__init__.py | 6 +-- sentry_sdk/integrations/falcon.py | 9 +---- sentry_sdk/integrations/flask.py | 9 +---- sentry_sdk/integrations/gql.py | 11 ++--- sentry_sdk/integrations/graphene.py | 9 +---- sentry_sdk/integrations/ray.py | 9 +---- sentry_sdk/integrations/rq.py | 10 +---- sentry_sdk/integrations/sanic.py | 12 ++---- sentry_sdk/integrations/sqlalchemy.py | 12 +----- sentry_sdk/integrations/strawberry.py | 11 +---- sentry_sdk/integrations/tornado.py | 5 +-- 21 files changed, 87 insertions(+), 136 deletions(-) diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 12336a939b..683382bb9a 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -111,7 +111,6 @@ def iter_default_integrations(with_auto_enabling_integrations): "sentry_sdk.integrations.tornado.TornadoIntegration", ] - iter_default_integrations = _generate_default_integrations_iterator( integrations=_DEFAULT_INTEGRATIONS, auto_enabling_integrations=_AUTO_ENABLING_INTEGRATIONS, @@ -120,6 +119,30 @@ def iter_default_integrations(with_auto_enabling_integrations): del _generate_default_integrations_iterator +_MIN_VERSIONS = { + "aiohttp": (3, 4), + "anthropic": (0, 16), + "ariadne": (0, 20), + "arq": (0, 23), + "asyncpg": (0, 23), + "boto3": (1, 12), # this is actually the botocore version + "bottle": (0, 12), + "celery": (4, 4, 7), + "clickhouse_driver": (0, 2, 0), + "django": (1, 8), + "falcon": (1, 4), + "flask": (0, 10), + "gql": (3, 4, 1), + "graphene": (3, 3), + "ray": (2, 7, 0), + "rq": (0, 6), + "sanic": (0, 8), + "sqlalchemy": (1, 2), + "strawberry": (0, 209, 5), + "tornado": (6, 0), +} + + def setup_integrations( integrations, with_defaults=True, @@ -195,6 +218,23 @@ def setup_integrations( return integrations +def _check_minimum_version(integration, version, package=None): + # type: (type[Integration], Optional[tuple[int, ...]], Optional[str]) -> None + package = package or integration.identifier + + if version is None: + raise DidNotEnable(f"Unparsable {package} version.") + + min_version = _MIN_VERSIONS.get(integration.identifier) + if min_version is None: + return + + if version < min_version: + raise DidNotEnable( + f"Integration only supports {package} {'.'.join(map(str, min_version))} or newer." + ) + + class DidNotEnable(Exception): # noqa: N818 """ The integration could not be enabled due to a trivial user error like diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index d0226bc156..47c1272ae1 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -7,6 +7,7 @@ from sentry_sdk.consts import OP, SPANSTATUS, SPANDATA from sentry_sdk.integrations import ( _DEFAULT_FAILED_REQUEST_STATUS_CODES, + _check_minimum_version, Integration, DidNotEnable, ) @@ -91,12 +92,7 @@ def setup_once(): # type: () -> None version = parse_version(AIOHTTP_VERSION) - - if version is None: - raise DidNotEnable("Unparsable AIOHTTP version: {}".format(AIOHTTP_VERSION)) - - if version < (3, 4): - raise DidNotEnable("AIOHTTP 3.4 or newer required.") + _check_minimum_version(AioHttpIntegration, version) if not HAS_REAL_CONTEXTVARS: # We better have contextvars or we're going to leak state between diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 87e69a3113..f06d8a14db 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -4,7 +4,7 @@ import sentry_sdk from sentry_sdk.ai.monitoring import record_token_usage from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, @@ -37,12 +37,7 @@ def __init__(self, include_prompts=True): def setup_once(): # type: () -> None version = package_version("anthropic") - - if version is None: - raise DidNotEnable("Unparsable anthropic version.") - - if version < (0, 16): - raise DidNotEnable("anthropic 0.16 or newer required.") + _check_minimum_version(AnthropicIntegration, version) Messages.create = _wrap_message_create(Messages.create) AsyncMessages.create = _wrap_message_create_async(AsyncMessages.create) diff --git a/sentry_sdk/integrations/ariadne.py b/sentry_sdk/integrations/ariadne.py index 70a3424a48..0336140441 100644 --- a/sentry_sdk/integrations/ariadne.py +++ b/sentry_sdk/integrations/ariadne.py @@ -2,7 +2,7 @@ import sentry_sdk from sentry_sdk import get_client, capture_event -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations._wsgi_common import request_body_within_bounds from sentry_sdk.scope import should_send_default_pii @@ -36,12 +36,7 @@ class AriadneIntegration(Integration): def setup_once(): # type: () -> None version = package_version("ariadne") - - if version is None: - raise DidNotEnable("Unparsable ariadne version.") - - if version < (0, 20): - raise DidNotEnable("ariadne 0.20 or newer required.") + _check_minimum_version(AriadneIntegration, version) ignore_logger("ariadne") diff --git a/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index d61499139b..a2cce8e0ff 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -2,7 +2,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANSTATUS -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_TASK @@ -55,11 +55,7 @@ def setup_once(): except (TypeError, ValueError): version = None - if version is None: - raise DidNotEnable("Unparsable arq version: {}".format(ARQ_VERSION)) - - if version < (0, 23): - raise DidNotEnable("arq 0.23 or newer required.") + _check_minimum_version(ArqIntegration, version) patch_enqueue_job() patch_run_job() diff --git a/sentry_sdk/integrations/asyncpg.py b/sentry_sdk/integrations/asyncpg.py index b05d5615ba..b6b53f4668 100644 --- a/sentry_sdk/integrations/asyncpg.py +++ b/sentry_sdk/integrations/asyncpg.py @@ -4,7 +4,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.tracing import Span from sentry_sdk.tracing_utils import add_query_source, record_sql_queries from sentry_sdk.utils import ( @@ -20,12 +20,6 @@ except ImportError: raise DidNotEnable("asyncpg not installed.") -# asyncpg.__version__ is a string containing the semantic version in the form of ".." -asyncpg_version = parse_version(asyncpg.__version__) - -if asyncpg_version is not None and asyncpg_version < (0, 23, 0): - raise DidNotEnable("asyncpg >= 0.23.0 required") - class AsyncPGIntegration(Integration): identifier = "asyncpg" @@ -37,6 +31,10 @@ def __init__(self, *, record_params: bool = False): @staticmethod def setup_once() -> None: + # asyncpg.__version__ is a string containing the semantic version in the form of ".." + asyncpg_version = parse_version(asyncpg.__version__) + _check_minimum_version(AsyncPGIntegration, asyncpg_version) + asyncpg.Connection.execute = _wrap_execute( asyncpg.Connection.execute, ) diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index c8da56fb14..0207341f1b 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -2,7 +2,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.tracing import Span from sentry_sdk.utils import ( capture_internal_exceptions, @@ -35,16 +35,8 @@ class Boto3Integration(Integration): @staticmethod def setup_once(): # type: () -> None - version = parse_version(BOTOCORE_VERSION) - - if version is None: - raise DidNotEnable( - "Unparsable botocore version: {}".format(BOTOCORE_VERSION) - ) - - if version < (1, 12): - raise DidNotEnable("Botocore 1.12 or newer is required.") + _check_minimum_version(Boto3Integration, version, "botocore") orig_init = BaseClient.__init__ diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index a2d6b51033..148b86852e 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -13,6 +13,7 @@ Integration, DidNotEnable, _DEFAULT_FAILED_REQUEST_STATUS_CODES, + _check_minimum_version, ) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor @@ -72,12 +73,7 @@ def __init__( def setup_once(): # type: () -> None version = parse_version(BOTTLE_VERSION) - - if version is None: - raise DidNotEnable("Unparsable Bottle version: {}".format(BOTTLE_VERSION)) - - if version < (0, 12): - raise DidNotEnable("Bottle 0.12 or newer required.") + _check_minimum_version(BottleIntegration, version) old_app = Bottle.__call__ diff --git a/sentry_sdk/integrations/celery/__init__.py b/sentry_sdk/integrations/celery/__init__.py index 9a984de8c3..dc48aac0e6 100644 --- a/sentry_sdk/integrations/celery/__init__.py +++ b/sentry_sdk/integrations/celery/__init__.py @@ -6,7 +6,7 @@ from sentry_sdk import isolation_scope from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP, SPANSTATUS, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations.celery.beat import ( _patch_beat_apply_entry, _patch_redbeat_maybe_due, @@ -79,8 +79,7 @@ def __init__( @staticmethod def setup_once(): # type: () -> None - if CELERY_VERSION < (4, 4, 7): - raise DidNotEnable("Celery 4.4.7 or newer required.") + _check_minimum_version(CeleryIntegration, CELERY_VERSION) _patch_build_tracer() _patch_task_apply_async() diff --git a/sentry_sdk/integrations/clickhouse_driver.py b/sentry_sdk/integrations/clickhouse_driver.py index daf4c2257c..2561bfad04 100644 --- a/sentry_sdk/integrations/clickhouse_driver.py +++ b/sentry_sdk/integrations/clickhouse_driver.py @@ -1,6 +1,6 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.tracing import Span from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import capture_internal_exceptions, ensure_integration_enabled @@ -34,9 +34,6 @@ def __getitem__(self, _): except ImportError: raise DidNotEnable("clickhouse-driver not installed.") -if clickhouse_driver.VERSION < (0, 2, 0): - raise DidNotEnable("clickhouse-driver >= 0.2.0 required") - class ClickhouseDriverIntegration(Integration): identifier = "clickhouse_driver" @@ -44,6 +41,8 @@ class ClickhouseDriverIntegration(Integration): @staticmethod def setup_once() -> None: + _check_minimum_version(ClickhouseDriverIntegration, clickhouse_driver.VERSION) + # Every query is done using the Connection's `send_query` function clickhouse_driver.connection.Connection.send_query = _wrap_start( clickhouse_driver.connection.Connection.send_query diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index e68f0cacef..54bc25675d 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -22,7 +22,7 @@ transaction_from_function, walk_exception_chain, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import ( @@ -154,9 +154,7 @@ def __init__( @staticmethod def setup_once(): # type: () -> None - - if DJANGO_VERSION < (1, 8): - raise DidNotEnable("Django 1.8 or newer is required.") + _check_minimum_version(DjangoIntegration, DJANGO_VERSION) install_sql_hook() # Patch in our custom middleware. diff --git a/sentry_sdk/integrations/falcon.py b/sentry_sdk/integrations/falcon.py index ce771d16e7..ddedcb10de 100644 --- a/sentry_sdk/integrations/falcon.py +++ b/sentry_sdk/integrations/falcon.py @@ -1,5 +1,5 @@ import sentry_sdk -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import RequestExtractor from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.tracing import SOURCE_FOR_STYLE @@ -135,12 +135,7 @@ def setup_once(): # type: () -> None version = parse_version(FALCON_VERSION) - - if version is None: - raise DidNotEnable("Unparsable Falcon version: {}".format(FALCON_VERSION)) - - if version < (1, 4): - raise DidNotEnable("Falcon 1.4 or newer required.") + _check_minimum_version(FalconIntegration, version) _patch_wsgi_app() _patch_handle_exception() diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 128301ddb4..45b4f0b2b1 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -1,5 +1,5 @@ import sentry_sdk -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.integrations._wsgi_common import ( DEFAULT_HTTP_METHODS_TO_CAPTURE, RequestExtractor, @@ -73,12 +73,7 @@ def __init__( def setup_once(): # type: () -> None version = package_version("flask") - - if version is None: - raise DidNotEnable("Unparsable Flask version.") - - if version < (0, 10): - raise DidNotEnable("Flask 0.10 or newer is required.") + _check_minimum_version(FlaskIntegration, version) before_render_template.connect(_add_sentry_trace) request_started.connect(_request_started) diff --git a/sentry_sdk/integrations/gql.py b/sentry_sdk/integrations/gql.py index 5074442986..d5341d2cf6 100644 --- a/sentry_sdk/integrations/gql.py +++ b/sentry_sdk/integrations/gql.py @@ -5,7 +5,7 @@ parse_version, ) -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii try: @@ -24,8 +24,6 @@ EventDataType = Dict[str, Union[str, Tuple[VariableDefinitionNode, ...]]] -MIN_GQL_VERSION = (3, 4, 1) - class GQLIntegration(Integration): identifier = "gql" @@ -34,11 +32,8 @@ class GQLIntegration(Integration): def setup_once(): # type: () -> None gql_version = parse_version(gql.__version__) - if gql_version is None or gql_version < MIN_GQL_VERSION: - raise DidNotEnable( - "GQLIntegration is only supported for GQL versions %s and above." - % ".".join(str(num) for num in MIN_GQL_VERSION) - ) + _check_minimum_version(GQLIntegration, gql_version) + _patch_execute() diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index 03731dcaaa..198aea50d2 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -2,7 +2,7 @@ import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, @@ -34,12 +34,7 @@ class GrapheneIntegration(Integration): def setup_once(): # type: () -> None version = package_version("graphene") - - if version is None: - raise DidNotEnable("Unparsable graphene version.") - - if version < (3, 3): - raise DidNotEnable("graphene 3.3 or newer required.") + _check_minimum_version(GrapheneIntegration, version) _patch_graphql() diff --git a/sentry_sdk/integrations/ray.py b/sentry_sdk/integrations/ray.py index 2f5086ed92..24a28c307f 100644 --- a/sentry_sdk/integrations/ray.py +++ b/sentry_sdk/integrations/ray.py @@ -3,7 +3,7 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANSTATUS -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.tracing import TRANSACTION_SOURCE_TASK from sentry_sdk.utils import ( event_from_exception, @@ -136,11 +136,6 @@ class RayIntegration(Integration): def setup_once(): # type: () -> None version = package_version("ray") - - if version is None: - raise DidNotEnable("Unparsable ray version: {}".format(version)) - - if version < (2, 7, 0): - raise DidNotEnable("Ray 2.7.0 or newer required") + _check_minimum_version(RayIntegration, version) _patch_ray_remote() diff --git a/sentry_sdk/integrations/rq.py b/sentry_sdk/integrations/rq.py index 462f3ad30a..d4fca6a33b 100644 --- a/sentry_sdk/integrations/rq.py +++ b/sentry_sdk/integrations/rq.py @@ -3,7 +3,7 @@ import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.api import continue_trace -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.tracing import TRANSACTION_SOURCE_TASK from sentry_sdk.utils import ( @@ -41,14 +41,8 @@ class RqIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - version = parse_version(RQ_VERSION) - - if version is None: - raise DidNotEnable("Unparsable RQ version: {}".format(RQ_VERSION)) - - if version < (0, 6): - raise DidNotEnable("RQ 0.6 or newer is required.") + _check_minimum_version(RqIntegration, version) old_perform_job = Worker.perform_job diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 26e29cb78c..dfcc299d42 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -6,7 +6,7 @@ import sentry_sdk from sentry_sdk import continue_trace from sentry_sdk.consts import OP -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL @@ -73,14 +73,8 @@ def __init__(self, unsampled_statuses=frozenset({404})): @staticmethod def setup_once(): # type: () -> None - SanicIntegration.version = parse_version(SANIC_VERSION) - - if SanicIntegration.version is None: - raise DidNotEnable("Unparsable Sanic version: {}".format(SANIC_VERSION)) - - if SanicIntegration.version < (0, 8): - raise DidNotEnable("Sanic 0.8 or newer required.") + _check_minimum_version(SanicIntegration, SanicIntegration.version) if not HAS_REAL_CONTEXTVARS: # We better have contextvars or we're going to leak state between @@ -102,7 +96,7 @@ def setup_once(): # https://github.com/huge-success/sanic/issues/1332 ignore_logger("root") - if SanicIntegration.version < (21, 9): + if SanicIntegration.version is not None and SanicIntegration.version < (21, 9): _setup_legacy_sanic() return diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py index 0a54108e75..068d373053 100644 --- a/sentry_sdk/integrations/sqlalchemy.py +++ b/sentry_sdk/integrations/sqlalchemy.py @@ -1,5 +1,5 @@ from sentry_sdk.consts import SPANSTATUS, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.tracing_utils import add_query_source, record_sql_queries from sentry_sdk.utils import ( capture_internal_exceptions, @@ -31,16 +31,8 @@ class SqlalchemyIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - version = parse_version(SQLALCHEMY_VERSION) - - if version is None: - raise DidNotEnable( - "Unparsable SQLAlchemy version: {}".format(SQLALCHEMY_VERSION) - ) - - if version < (1, 2): - raise DidNotEnable("SQLAlchemy 1.2 or newer required.") + _check_minimum_version(SqlalchemyIntegration, version) listen(Engine, "before_cursor_execute", _before_cursor_execute) listen(Engine, "after_cursor_execute", _after_cursor_execute) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 58860a633b..d27e0eaf1c 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -4,7 +4,7 @@ import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT @@ -75,14 +75,7 @@ def __init__(self, async_execution=None): def setup_once(): # type: () -> None version = package_version("strawberry-graphql") - - if version is None: - raise DidNotEnable( - "Unparsable strawberry-graphql version: {}".format(version) - ) - - if version < (0, 209, 5): - raise DidNotEnable("strawberry-graphql 0.209.5 or newer required.") + _check_minimum_version(StrawberryIntegration, version, "strawberry-graphql") _patch_schema_init() _patch_execute() diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index f1bd196261..b9e465c7c7 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -18,7 +18,7 @@ capture_internal_exceptions, transaction_from_function, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations._wsgi_common import ( RequestExtractor, _filter_headers, @@ -52,8 +52,7 @@ class TornadoIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - if TORNADO_VERSION < (6, 0): - raise DidNotEnable("Tornado 6.0+ required") + _check_minimum_version(TornadoIntegration, TORNADO_VERSION) if not HAS_REAL_CONTEXTVARS: # Tornado is async. We better have contextvars or we're going to leak From fa241c3425e446878f173407fd7358f38d8bd529 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 9 Jan 2025 18:07:32 +0100 Subject: [PATCH 25/30] Treat potel-base as release branch in CI (#3912) ...and remove `sentry-sdk-2.0` from the CI yamls. --- .github/workflows/ci.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/enforce-license-compliance.yml | 2 +- .github/workflows/test-integrations-ai.yml | 2 +- .github/workflows/test-integrations-aws.yml | 2 +- .github/workflows/test-integrations-cloud.yml | 2 +- .github/workflows/test-integrations-common.yml | 2 +- .github/workflows/test-integrations-dbs.yml | 2 +- .github/workflows/test-integrations-graphql.yml | 2 +- .github/workflows/test-integrations-misc.yml | 2 +- .github/workflows/test-integrations-network.yml | 2 +- .github/workflows/test-integrations-tasks.yml | 2 +- .github/workflows/test-integrations-web-1.yml | 2 +- .github/workflows/test-integrations-web-2.yml | 2 +- scripts/split_tox_gh_actions/templates/base.jinja | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ef6604e39..e8931e229e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e362d1e620..d824757ee9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,7 +15,7 @@ on: push: branches: - master - - sentry-sdk-2.0 + - potel-base pull_request: schedule: - cron: '18 18 * * 3' diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index ef79ed112b..5517e5347f 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -6,7 +6,7 @@ on: - master - main - release/* - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 2fd6995a5f..6e06e6067c 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-aws.yml b/.github/workflows/test-integrations-aws.yml index f83e3379f6..eae488776a 100644 --- a/.github/workflows/test-integrations-aws.yml +++ b/.github/workflows/test-integrations-aws.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base # XXX: We are using `pull_request_target` instead of `pull_request` because we want # this to run on forks with access to the secrets necessary to run the test suite. # Prefer to use `pull_request` when possible. diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml index 9e34dc6b2b..af089caede 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index f1806597af..d9e08bbeb8 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index d9bea0611b..f612b8fb14 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index 7138204e16..d239b2ed6c 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index d524863423..5747448442 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-network.yml b/.github/workflows/test-integrations-network.yml index 1b9ee3c529..ab1c5b0658 100644 --- a/.github/workflows/test-integrations-network.yml +++ b/.github/workflows/test-integrations-network.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index 0f97146d6d..8ecc7ab598 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml index 53206f764f..2dc5f361de 100644 --- a/.github/workflows/test-integrations-web-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index 39c1eba535..2b3204ae80 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -8,7 +8,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base pull_request: # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value diff --git a/scripts/split_tox_gh_actions/templates/base.jinja b/scripts/split_tox_gh_actions/templates/base.jinja index 16dbc04a76..e69b6f9134 100644 --- a/scripts/split_tox_gh_actions/templates/base.jinja +++ b/scripts/split_tox_gh_actions/templates/base.jinja @@ -11,7 +11,7 @@ on: branches: - master - release/** - - sentry-sdk-2.0 + - potel-base {% if needs_github_secrets %} # XXX: We are using `pull_request_target` instead of `pull_request` because we want From 9f9ff345c6054e0623a293c1f90e6e590ceb8a9f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 13 Jan 2025 10:13:26 +0100 Subject: [PATCH 26/30] tests: Create a separate group for feature flag suites (#3911) Take feature flag tests out of Misc and into their own new Flags group. Also move Tasks down in the `GROUPS` dict do that it's alphabetized (except for misc which is at the bottom). --- .github/workflows/test-integrations-flags.yml | 163 ++++++++++++++++++ .github/workflows/test-integrations-misc.yml | 24 --- .../split_tox_gh_actions.py | 28 +-- 3 files changed, 178 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/test-integrations-flags.yml diff --git a/.github/workflows/test-integrations-flags.yml b/.github/workflows/test-integrations-flags.yml new file mode 100644 index 0000000000..0460868473 --- /dev/null +++ b/.github/workflows/test-integrations-flags.yml @@ -0,0 +1,163 @@ +# Do not edit this YAML file. This file is generated automatically by executing +# python scripts/split_tox_gh_actions/split_tox_gh_actions.py +# The template responsible for it is in +# scripts/split_tox_gh_actions/templates/base.jinja +name: Test Flags +on: + push: + branches: + - master + - release/** + - potel-base + pull_request: +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true +permissions: + contents: read +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless +jobs: + test-flags-latest: + name: Flags (latest) + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + 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 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-20.04] + steps: + - uses: actions/checkout@v4.2.2 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test launchdarkly latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-launchdarkly-latest" + - name: Test openfeature latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature-latest" + - name: Test unleash latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-unleash-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.1.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + test-flags-pinned: + name: Flags (pinned) + timeout-minutes: 30 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + 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 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-20.04] + steps: + - uses: actions/checkout@v4.2.2 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Setup Test Env + run: | + pip install "coverage[toml]" tox + - name: Erase coverage + run: | + coverage erase + - name: Test launchdarkly pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-launchdarkly" + - name: Test openfeature pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openfeature" + - name: Test unleash pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-unleash" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML + if: ${{ !cancelled() && matrix.python-version != '3.6' }} + run: | + coverage combine .coverage-sentry-* + coverage xml + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@v5.1.2 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true + - name: Upload test results to Codecov + if: ${{ !cancelled() }} + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: .junitxml + verbose: true + check_required_tests: + name: All pinned Flags tests passed + needs: test-flags-pinned + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-20.04 + steps: + - name: Check for failures + if: contains(needs.test-flags-pinned.result, 'failure') || contains(needs.test-flags-pinned.result, 'skipped') + run: | + echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 5747448442..9461ea506c 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -47,18 +47,10 @@ jobs: - name: Erase coverage run: | coverage erase - - name: Test launchdarkly latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-launchdarkly-latest" - name: Test loguru latest run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-loguru-latest" - - name: Test openfeature latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature-latest" - name: Test opentelemetry latest run: | set -x # print commands that are executed @@ -79,10 +71,6 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-typer-latest" - - name: Test unleash latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-unleash-latest" - name: Generate coverage XML (Python 3.6) if: ${{ !cancelled() && matrix.python-version == '3.6' }} run: | @@ -135,18 +123,10 @@ jobs: - name: Erase coverage run: | coverage erase - - name: Test launchdarkly pinned - run: | - set -x # print commands that are executed - ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-launchdarkly" - name: Test loguru pinned run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-loguru" - - name: Test openfeature pinned - run: | - set -x # print commands that are executed - ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openfeature" - name: Test opentelemetry pinned run: | set -x # print commands that are executed @@ -167,10 +147,6 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-typer" - - name: Test unleash pinned - run: | - set -x # print commands that are executed - ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-unleash" - name: Generate coverage XML (Python 3.6) if: ${{ !cancelled() && matrix.python-version == '3.6' }} run: | diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 743677daf4..1537ad8389 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -76,16 +76,6 @@ "cloud_resource_context", "gcp", ], - "Tasks": [ - "arq", - "beam", - "celery", - "dramatiq", - "huey", - "ray", - "rq", - "spark", - ], "DBs": [ "asyncpg", "clickhouse_driver", @@ -94,6 +84,11 @@ "redis_py_cluster_legacy", "sqlalchemy", ], + "Flags": [ + "launchdarkly", + "openfeature", + "unleash", + ], "GraphQL": [ "ariadne", "gql", @@ -106,6 +101,16 @@ "httpx", "requests", ], + "Tasks": [ + "arq", + "beam", + "celery", + "dramatiq", + "huey", + "ray", + "rq", + "spark", + ], "Web 1": [ "django", "flask", @@ -125,15 +130,12 @@ "tornado", ], "Misc": [ - "launchdarkly", "loguru", - "openfeature", "opentelemetry", "potel", "pure_eval", "trytond", "typer", - "unleash", ], } From 288f69a962e4ae9e929ae1116ec683297a0a416a Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Mon, 13 Jan 2025 16:54:29 +0100 Subject: [PATCH 27/30] Moved adding of `flags` context into Scope (#3917) Using an error_processor to read data from the scope to add to the event is an anti-pattern. Moving this into `Scope.apply_to_event()`. This PR: - moves code that adds flags to an event from an error processor into the `Scope` class - moves `add_feature_flag()` function from `sentry_sdk.integrations.feature_flags` into `sentry_sdk.feature_flags` --- .../{flag_utils.py => feature_flags.py} | 20 ++++--- sentry_sdk/integrations/feature_flags.py | 44 --------------- sentry_sdk/integrations/launchdarkly.py | 4 +- sentry_sdk/integrations/openfeature.py | 4 -- sentry_sdk/integrations/unleash.py | 5 -- sentry_sdk/scope.py | 17 +++++- tests/integrations/feature_flags/__init__.py | 0 .../feature_flags => }/test_feature_flags.py | 56 +++++++++++++++---- tests/test_flag_utils.py | 43 -------------- 9 files changed, 74 insertions(+), 119 deletions(-) rename sentry_sdk/{flag_utils.py => feature_flags.py} (67%) delete mode 100644 sentry_sdk/integrations/feature_flags.py delete mode 100644 tests/integrations/feature_flags/__init__.py rename tests/{integrations/feature_flags => }/test_feature_flags.py (75%) delete mode 100644 tests/test_flag_utils.py diff --git a/sentry_sdk/flag_utils.py b/sentry_sdk/feature_flags.py similarity index 67% rename from sentry_sdk/flag_utils.py rename to sentry_sdk/feature_flags.py index cf4800e855..1187c2fa12 100644 --- a/sentry_sdk/flag_utils.py +++ b/sentry_sdk/feature_flags.py @@ -1,11 +1,10 @@ -from typing import TYPE_CHECKING - import sentry_sdk from sentry_sdk._lru_cache import LRUCache +from typing import TYPE_CHECKING + if TYPE_CHECKING: - from typing import TypedDict, Optional - from sentry_sdk._types import Event, ExcInfo + from typing import TypedDict FlagData = TypedDict("FlagData", {"flag": str, "result": bool}) @@ -33,8 +32,11 @@ def set(self, flag, result): self.buffer.set(flag, result) -def flag_error_processor(event, exc_info): - # type: (Event, ExcInfo) -> Optional[Event] - scope = sentry_sdk.get_current_scope() - event["contexts"]["flags"] = {"values": scope.flags.get()} - return event +def add_feature_flag(flag, result): + # type: (str, bool) -> None + """ + Records a flag and its value to be sent on subsequent error events. + We recommend you do this on flag evaluations. Flags are buffered per Sentry scope. + """ + flags = sentry_sdk.get_current_scope().flags + flags.set(flag, result) diff --git a/sentry_sdk/integrations/feature_flags.py b/sentry_sdk/integrations/feature_flags.py deleted file mode 100644 index 2aeabffbfa..0000000000 --- a/sentry_sdk/integrations/feature_flags.py +++ /dev/null @@ -1,44 +0,0 @@ -from sentry_sdk.flag_utils import flag_error_processor - -import sentry_sdk -from sentry_sdk.integrations import Integration - - -class FeatureFlagsIntegration(Integration): - """ - Sentry integration for capturing feature flags on error events. To manually buffer flag data, - call `integrations.featureflags.add_feature_flag`. We recommend you do this on each flag - evaluation. - - See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) - for more information. - - @example - ``` - import sentry_sdk - from sentry_sdk.integrations.feature_flags import FeatureFlagsIntegration, add_feature_flag - - sentry_sdk.init(dsn="my_dsn", integrations=[FeatureFlagsIntegration()]); - - add_feature_flag('my-flag', true); - sentry_sdk.capture_exception(Exception('broke')); // 'my-flag' should be captured on this Sentry event. - ``` - """ - - identifier = "feature_flags" - - @staticmethod - def setup_once(): - # type: () -> None - scope = sentry_sdk.get_current_scope() - scope.add_error_processor(flag_error_processor) - - -def add_feature_flag(flag, result): - # type: (str, bool) -> None - """ - Records a flag and its value to be sent on subsequent error events by FeatureFlagsIntegration. - We recommend you do this on flag evaluations. Flags are buffered per Sentry scope. - """ - flags = sentry_sdk.get_current_scope().flags - flags.set(flag, result) diff --git a/sentry_sdk/integrations/launchdarkly.py b/sentry_sdk/integrations/launchdarkly.py index a9eef9e1a9..cb9e911463 100644 --- a/sentry_sdk/integrations/launchdarkly.py +++ b/sentry_sdk/integrations/launchdarkly.py @@ -2,7 +2,6 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.flag_utils import flag_error_processor try: import ldclient @@ -41,8 +40,7 @@ def __init__(self, ld_client=None): @staticmethod def setup_once(): # type: () -> None - scope = sentry_sdk.get_current_scope() - scope.add_error_processor(flag_error_processor) + pass class LaunchDarklyHook(Hook): diff --git a/sentry_sdk/integrations/openfeature.py b/sentry_sdk/integrations/openfeature.py index 18f968a703..bf66b94e8b 100644 --- a/sentry_sdk/integrations/openfeature.py +++ b/sentry_sdk/integrations/openfeature.py @@ -2,7 +2,6 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.flag_utils import flag_error_processor try: from openfeature import api @@ -21,9 +20,6 @@ class OpenFeatureIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - scope = sentry_sdk.get_current_scope() - scope.add_error_processor(flag_error_processor) - # Register the hook within the global openfeature hooks list. api.add_hooks(hooks=[OpenFeatureHook()]) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 33b0a4b9dc..442ec39d0f 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -2,7 +2,6 @@ from typing import Any import sentry_sdk -from sentry_sdk.flag_utils import flag_error_processor from sentry_sdk.integrations import Integration, DidNotEnable try: @@ -49,7 +48,3 @@ def sentry_get_variant(self, feature, *args, **kwargs): UnleashClient.is_enabled = sentry_is_enabled # type: ignore UnleashClient.get_variant = sentry_get_variant # type: ignore - - # Error processor - scope = sentry_sdk.get_current_scope() - scope.add_error_processor(flag_error_processor) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index cf72fabdd1..ab0f1f4156 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -11,7 +11,7 @@ from sentry_sdk.attachments import Attachment from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER -from sentry_sdk.flag_utils import FlagBuffer, DEFAULT_FLAG_CAPACITY +from sentry_sdk.feature_flags import FlagBuffer, DEFAULT_FLAG_CAPACITY from sentry_sdk.profiler.continuous_profiler import try_autostart_continuous_profiler from sentry_sdk.profiler.transaction_profiler import Profile from sentry_sdk.session import Session @@ -1378,6 +1378,14 @@ def _apply_contexts_to_event(self, event, hint, options): else: contexts["trace"] = self.get_trace_context() + def _apply_flags_to_event(self, event, hint, options): + # type: (Event, Hint, Optional[Dict[str, Any]]) -> None + flags = self.flags.get() + if len(flags) > 0: + event.setdefault("contexts", {}).setdefault("flags", {}).update( + {"values": flags} + ) + def _drop(self, cause, ty): # type: (Any, str) -> Optional[Any] logger.info("%s (%s) dropped event", ty, cause) @@ -1476,6 +1484,7 @@ def apply_to_event( if not is_transaction and not is_check_in: self._apply_breadcrumbs_to_event(event, hint, options) + self._apply_flags_to_event(event, hint, options) event = self.run_error_processors(event, hint) if event is None: @@ -1518,6 +1527,12 @@ def update_from_scope(self, scope): self._propagation_context = scope._propagation_context if scope._session: self._session = scope._session + if scope._flags: + if not self._flags: + self._flags = deepcopy(scope._flags) + else: + for flag in scope._flags.get(): + self._flags.set(flag["flag"], flag["result"]) def update_from_kwargs( self, diff --git a/tests/integrations/feature_flags/__init__.py b/tests/integrations/feature_flags/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/tests/integrations/feature_flags/test_feature_flags.py b/tests/test_feature_flags.py similarity index 75% rename from tests/integrations/feature_flags/test_feature_flags.py rename to tests/test_feature_flags.py index ca6ac16949..14d74cb04b 100644 --- a/tests/integrations/feature_flags/test_feature_flags.py +++ b/tests/test_feature_flags.py @@ -4,15 +4,11 @@ import pytest import sentry_sdk -from sentry_sdk.integrations.feature_flags import ( - FeatureFlagsIntegration, - add_feature_flag, -) +from sentry_sdk.feature_flags import add_feature_flag, FlagBuffer def test_featureflags_integration(sentry_init, capture_events, uninstall_integration): - uninstall_integration(FeatureFlagsIntegration.identifier) - sentry_init(integrations=[FeatureFlagsIntegration()]) + sentry_init() add_feature_flag("hello", False) add_feature_flag("world", True) @@ -34,8 +30,7 @@ def test_featureflags_integration(sentry_init, capture_events, uninstall_integra def test_featureflags_integration_threaded( sentry_init, capture_events, uninstall_integration ): - uninstall_integration(FeatureFlagsIntegration.identifier) - sentry_init(integrations=[FeatureFlagsIntegration()]) + sentry_init() events = capture_events() # Capture an eval before we split isolation scopes. @@ -86,8 +81,7 @@ def test_featureflags_integration_asyncio( ): asyncio = pytest.importorskip("asyncio") - uninstall_integration(FeatureFlagsIntegration.identifier) - sentry_init(integrations=[FeatureFlagsIntegration()]) + sentry_init() events = capture_events() # Capture an eval before we split isolation scopes. @@ -131,3 +125,45 @@ async def runner(): {"flag": "world", "result": False}, ] } + + +def test_flag_tracking(): + """Assert the ring buffer works.""" + buffer = FlagBuffer(capacity=3) + buffer.set("a", True) + flags = buffer.get() + assert len(flags) == 1 + assert flags == [{"flag": "a", "result": True}] + + buffer.set("b", True) + flags = buffer.get() + assert len(flags) == 2 + assert flags == [{"flag": "a", "result": True}, {"flag": "b", "result": True}] + + buffer.set("c", True) + flags = buffer.get() + assert len(flags) == 3 + assert flags == [ + {"flag": "a", "result": True}, + {"flag": "b", "result": True}, + {"flag": "c", "result": True}, + ] + + buffer.set("d", False) + flags = buffer.get() + assert len(flags) == 3 + assert flags == [ + {"flag": "b", "result": True}, + {"flag": "c", "result": True}, + {"flag": "d", "result": False}, + ] + + buffer.set("e", False) + buffer.set("f", False) + flags = buffer.get() + assert len(flags) == 3 + assert flags == [ + {"flag": "d", "result": False}, + {"flag": "e", "result": False}, + {"flag": "f", "result": False}, + ] diff --git a/tests/test_flag_utils.py b/tests/test_flag_utils.py deleted file mode 100644 index 3fa4f3abfe..0000000000 --- a/tests/test_flag_utils.py +++ /dev/null @@ -1,43 +0,0 @@ -from sentry_sdk.flag_utils import FlagBuffer - - -def test_flag_tracking(): - """Assert the ring buffer works.""" - buffer = FlagBuffer(capacity=3) - buffer.set("a", True) - flags = buffer.get() - assert len(flags) == 1 - assert flags == [{"flag": "a", "result": True}] - - buffer.set("b", True) - flags = buffer.get() - assert len(flags) == 2 - assert flags == [{"flag": "a", "result": True}, {"flag": "b", "result": True}] - - buffer.set("c", True) - flags = buffer.get() - assert len(flags) == 3 - assert flags == [ - {"flag": "a", "result": True}, - {"flag": "b", "result": True}, - {"flag": "c", "result": True}, - ] - - buffer.set("d", False) - flags = buffer.get() - assert len(flags) == 3 - assert flags == [ - {"flag": "b", "result": True}, - {"flag": "c", "result": True}, - {"flag": "d", "result": False}, - ] - - buffer.set("e", False) - buffer.set("f", False) - flags = buffer.get() - assert len(flags) == 3 - assert flags == [ - {"flag": "d", "result": False}, - {"flag": "e", "result": False}, - {"flag": "f", "result": False}, - ] From 2ee194c0d4fac809b40ef81d90ae859998962afa Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Tue, 14 Jan 2025 00:00:55 -0800 Subject: [PATCH 28/30] feat(flags): remove Unleash get_variant patching code (#3914) Follow-up to https://github.com/getsentry/sentry-python/pull/3888 The original PR patched 2 methods used for evaluating feature flags, `is_enabled` (simple toggle on/off) and `get_variant` (returns a dict of metadata, see https://docs.getunleash.io/reference/sdks/python#getting-a-variant). We want to remove all `get_variant` code since we only support boolean flag evals atm. It seems like the main usecase for variants is reading payloads (non-bool) for A/B/multivariate testing. This could lead to a lot of extraneous flags, so until it is requested and/or we support non-bool values, let's not patch this method. --- sentry_sdk/integrations/unleash.py | 16 --- tests/integrations/unleash/test_unleash.py | 156 +-------------------- tests/integrations/unleash/testutils.py | 36 +---- 3 files changed, 9 insertions(+), 199 deletions(-) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 442ec39d0f..c7108394d0 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -18,7 +18,6 @@ def setup_once(): # type: () -> None # Wrap and patch evaluation methods (instance methods) old_is_enabled = UnleashClient.is_enabled - old_get_variant = UnleashClient.get_variant @wraps(old_is_enabled) def sentry_is_enabled(self, feature, *args, **kwargs): @@ -32,19 +31,4 @@ def sentry_is_enabled(self, feature, *args, **kwargs): return enabled - @wraps(old_get_variant) - def sentry_get_variant(self, feature, *args, **kwargs): - # type: (UnleashClient, str, *Any, **Any) -> Any - variant = old_get_variant(self, feature, *args, **kwargs) - enabled = variant.get("enabled", False) - - # Payloads are not always used as the feature's value for application logic. They - # may be used for metrics or debugging context instead. Therefore, we treat every - # variant as a boolean toggle, using the `enabled` field. - flags = sentry_sdk.get_current_scope().flags - flags.set(feature, enabled) - - return variant - UnleashClient.is_enabled = sentry_is_enabled # type: ignore - UnleashClient.get_variant = sentry_get_variant # type: ignore diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 9a7a3f57bd..379abba8f6 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -15,7 +15,7 @@ def test_is_enabled(sentry_init, capture_events, uninstall_integration): uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): - client = UnleashClient() + client = UnleashClient() # type: ignore[arg-type] sentry_init(integrations=[UnleashIntegration()]) client.is_enabled("hello") client.is_enabled("world") @@ -34,41 +34,12 @@ def test_is_enabled(sentry_init, capture_events, uninstall_integration): } -def test_get_variant(sentry_init, capture_events, uninstall_integration): - uninstall_integration(UnleashIntegration.identifier) - - with mock_unleash_client(): - client = UnleashClient() - sentry_init(integrations=[UnleashIntegration()]) # type: ignore - client.get_variant("no_payload_feature") - client.get_variant("string_feature") - client.get_variant("json_feature") - client.get_variant("csv_feature") - client.get_variant("number_feature") - client.get_variant("unknown_feature") - - events = capture_events() - sentry_sdk.capture_exception(Exception("something wrong!")) - - assert len(events) == 1 - assert events[0]["contexts"]["flags"] == { - "values": [ - {"flag": "no_payload_feature", "result": True}, - {"flag": "string_feature", "result": True}, - {"flag": "json_feature", "result": True}, - {"flag": "csv_feature", "result": True}, - {"flag": "number_feature", "result": True}, - {"flag": "unknown_feature", "result": False}, - ] - } - - def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration): uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): - client = UnleashClient() - sentry_init(integrations=[UnleashIntegration()]) # type: ignore + client = UnleashClient() # type: ignore[arg-type] + sentry_init(integrations=[UnleashIntegration()]) events = capture_events() def task(flag_key): @@ -112,63 +83,14 @@ def task(flag_key): } -def test_get_variant_threaded(sentry_init, capture_events, uninstall_integration): - uninstall_integration(UnleashIntegration.identifier) - - with mock_unleash_client(): - client = UnleashClient() - sentry_init(integrations=[UnleashIntegration()]) # type: ignore - events = capture_events() - - def task(flag_key): - # Creates a new isolation scope for the thread. - # This means the evaluations in each task are captured separately. - with sentry_sdk.isolation_scope(): - client.get_variant(flag_key) - # use a tag to identify to identify events later on - sentry_sdk.set_tag("task_id", flag_key) - sentry_sdk.capture_exception(Exception("something wrong!")) - - # Capture an eval before we split isolation scopes. - client.get_variant("hello") - - with cf.ThreadPoolExecutor(max_workers=2) as pool: - pool.map(task, ["no_payload_feature", "other"]) - - # Capture error in original scope - sentry_sdk.set_tag("task_id", "0") - sentry_sdk.capture_exception(Exception("something wrong!")) - - assert len(events) == 3 - events.sort(key=lambda e: e["tags"]["task_id"]) - - assert events[0]["contexts"]["flags"] == { - "values": [ - {"flag": "hello", "result": False}, - ] - } - assert events[1]["contexts"]["flags"] == { - "values": [ - {"flag": "hello", "result": False}, - {"flag": "no_payload_feature", "result": True}, - ] - } - assert events[2]["contexts"]["flags"] == { - "values": [ - {"flag": "hello", "result": False}, - {"flag": "other", "result": False}, - ] - } - - @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") def test_is_enabled_asyncio(sentry_init, capture_events, uninstall_integration): asyncio = pytest.importorskip("asyncio") uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): - client = UnleashClient() - sentry_init(integrations=[UnleashIntegration()]) # type: ignore + client = UnleashClient() # type: ignore[arg-type] + sentry_init(integrations=[UnleashIntegration()]) events = capture_events() async def task(flag_key): @@ -212,66 +134,12 @@ async def runner(): } -@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") -def test_get_variant_asyncio(sentry_init, capture_events, uninstall_integration): - asyncio = pytest.importorskip("asyncio") - - uninstall_integration(UnleashIntegration.identifier) - - with mock_unleash_client(): - client = UnleashClient() - sentry_init(integrations=[UnleashIntegration()]) # type: ignore - events = capture_events() - - async def task(flag_key): - with sentry_sdk.isolation_scope(): - client.get_variant(flag_key) - # use a tag to identify to identify events later on - sentry_sdk.set_tag("task_id", flag_key) - sentry_sdk.capture_exception(Exception("something wrong!")) - - async def runner(): - return asyncio.gather(task("no_payload_feature"), task("other")) - - # Capture an eval before we split isolation scopes. - client.get_variant("hello") - - asyncio.run(runner()) - - # Capture error in original scope - sentry_sdk.set_tag("task_id", "0") - sentry_sdk.capture_exception(Exception("something wrong!")) - - assert len(events) == 3 - events.sort(key=lambda e: e["tags"]["task_id"]) - - assert events[0]["contexts"]["flags"] == { - "values": [ - {"flag": "hello", "result": False}, - ] - } - assert events[1]["contexts"]["flags"] == { - "values": [ - {"flag": "hello", "result": False}, - {"flag": "no_payload_feature", "result": True}, - ] - } - assert events[2]["contexts"]["flags"] == { - "values": [ - {"flag": "hello", "result": False}, - {"flag": "other", "result": False}, - ] - } - - def test_wraps_original(sentry_init, uninstall_integration): with mock_unleash_client(): - client = UnleashClient() + client = UnleashClient() # type: ignore[arg-type] mock_is_enabled = mock.Mock(return_value=random() < 0.5) - mock_get_variant = mock.Mock(return_value={"enabled": random() < 0.5}) client.is_enabled = mock_is_enabled - client.get_variant = mock_get_variant uninstall_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) # type: ignore @@ -283,20 +151,12 @@ def test_wraps_original(sentry_init, uninstall_integration): {"kwarg": 1}, ) - res = client.get_variant("test-flag", "arg", kwarg=1) - assert res == mock_get_variant.return_value - assert mock_get_variant.call_args == ( - ("test-flag", "arg"), - {"kwarg": 1}, - ) - def test_wrapper_attributes(sentry_init, uninstall_integration): with mock_unleash_client(): - client = UnleashClient() # <- Returns a MockUnleashClient + client = UnleashClient() # type: ignore[arg-type] original_is_enabled = client.is_enabled - original_get_variant = client.get_variant uninstall_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) # type: ignore @@ -304,5 +164,3 @@ def test_wrapper_attributes(sentry_init, uninstall_integration): # Mock clients methods have not lost their qualified names after decoration. assert client.is_enabled.__name__ == "is_enabled" assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__ - assert client.get_variant.__name__ == "get_variant" - assert client.get_variant.__qualname__ == original_get_variant.__qualname__ diff --git a/tests/integrations/unleash/testutils.py b/tests/integrations/unleash/testutils.py index c424b34c3a..07b065e2f0 100644 --- a/tests/integrations/unleash/testutils.py +++ b/tests/integrations/unleash/testutils.py @@ -8,8 +8,8 @@ def mock_unleash_client(): Temporarily replaces UnleashClient's methods with mock implementations for testing. - This context manager swaps out UnleashClient's __init__, is_enabled, - and get_variant methods with mock versions from MockUnleashClient. + This context manager swaps out UnleashClient's __init__ and is_enabled, + methods with mock versions from MockUnleashClient. Original methods are restored when exiting the context. After mocking the client class the integration can be initialized. @@ -23,17 +23,14 @@ def mock_unleash_client(): """ old_init = UnleashClient.__init__ old_is_enabled = UnleashClient.is_enabled - old_get_variant = UnleashClient.get_variant UnleashClient.__init__ = MockUnleashClient.__init__ UnleashClient.is_enabled = MockUnleashClient.is_enabled - UnleashClient.get_variant = MockUnleashClient.get_variant yield UnleashClient.__init__ = old_init UnleashClient.is_enabled = old_is_enabled - UnleashClient.get_variant = old_get_variant class MockUnleashClient: @@ -44,34 +41,5 @@ def __init__(self, *a, **kw): "world": False, } - self.feature_to_variant = { - "string_feature": { - "name": "variant1", - "enabled": True, - "payload": {"type": "string", "value": "val1"}, - }, - "json_feature": { - "name": "variant1", - "enabled": True, - "payload": {"type": "json", "value": '{"key1": 0.53}'}, - }, - "number_feature": { - "name": "variant1", - "enabled": True, - "payload": {"type": "number", "value": "134.5"}, - }, - "csv_feature": { - "name": "variant1", - "enabled": True, - "payload": {"type": "csv", "value": "abc 123\ncsbq 94"}, - }, - "no_payload_feature": {"name": "variant1", "enabled": True}, - } - - self.disabled_variant = {"name": "disabled", "enabled": False} - def is_enabled(self, feature, *a, **kw): return self.features.get(feature, False) - - def get_variant(self, feature, *a, **kw): - return self.feature_to_variant.get(feature, self.disabled_variant) From ca68a7f3fb8e1cb6e1c58432211422b4c2bc4530 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 14 Jan 2025 08:33:39 +0000 Subject: [PATCH 29/30] release: 2.20.0 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af4eb04fef..57df5a9035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## 2.20.0 + +### Various fixes & improvements + +- feat(flags): remove Unleash get_variant patching code (#3914) by @aliu39 +- Moved adding of `flags` context into Scope (#3917) by @antonpirker +- tests: Create a separate group for feature flag suites (#3911) by @sentrivana +- Treat potel-base as release branch in CI (#3912) by @sentrivana +- Centralize minimum version checking (#3910) by @sentrivana +- Small contribution docs update (#3909) by @antonpirker +- feat(flags): add Unleash feature flagging integration (#3888) by @aliu39 +- ref(flags): Beter naming for featureflags module and identifier (#3902) by @aliu39 +- Revert "ref(flags): register LD hook in setup instead of init, and don't chec…" (#3900) by @cmanallen +- Update test matrix for Sanic (#3904) by @antonpirker +- fix: preserve ARQ enqueue_job __kwdefaults__ after patching (#3903) by @danmr +- fix(flags): fix/refactor flaky launchdarkly tests (#3896) by @aliu39 +- Fix cache pollution from mutable reference (#3887) by @cmanallen +- ref(flags): register LD hook in setup instead of init, and don't check for initialization (#3890) by @aliu39 +- build(deps): bump actions/create-github-app-token from 1.11.0 to 1.11.1 (#3893) by @dependabot +- build(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 (#3892) by @dependabot +- Fix lru cache copying (#3883) by @ffelixg +- Rename scripts (#3885) by @sentrivana +- Support SparkIntegration activation after SparkContext created (#3411) by @seyoon-lim +- build(deps): bump codecov/codecov-action from 5.0.7 to 5.1.1 (#3867) by @dependabot +- Add github workflow to comment on issues when a fix was released (#3866) by @antonpirker +- feat(flags): Add integration for custom tracking of flag evaluations (#3860) by @aliu39 +- ✨ Add Typer integration (#3869) by @patrick91 +- Fix CI (#3878) by @sentrivana + +_Plus 3 more_ + ## 2.19.2 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 3ecdbe2e68..1d58274beb 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.19.2" +release = "2.20.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 0bb71cb98d..23f79ebd63 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -581,4 +581,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.19.2" +VERSION = "2.20.0" diff --git a/setup.py b/setup.py index 9e24d59d21..1bfbb6f7e4 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.19.2", + version="2.20.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From 4e0505ea5c58943f31de35f03d834daa18e7f7ed Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Tue, 14 Jan 2025 10:22:18 +0100 Subject: [PATCH 30/30] Updated changelog --- CHANGELOG.md | 52 +++++++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57df5a9035..abbb5d5627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,34 +2,32 @@ ## 2.20.0 -### Various fixes & improvements +- **New integration:** Add [Typer](https://typer.tiangolo.com/) integration (#3869) by @patrick91 + + For more information, see the documentation for the [TyperIntegration](https://docs.sentry.io/platforms/python/integrations/typer/). + +- **New integration:** Add [Unleash](https://www.getunleash.io/) feature flagging integration (#3888) by @aliu39 + + For more information, see the documentation for the [UnleashIntegration](https://docs.sentry.io/platforms/python/integrations/unleash/). -- feat(flags): remove Unleash get_variant patching code (#3914) by @aliu39 -- Moved adding of `flags` context into Scope (#3917) by @antonpirker -- tests: Create a separate group for feature flag suites (#3911) by @sentrivana -- Treat potel-base as release branch in CI (#3912) by @sentrivana +- Add custom tracking of feature flag evaluations (#3860) by @aliu39 +- Feature Flags: Register LD hook in setup instead of init, and don't check for initialization (#3890) by @aliu39 +- Feature Flags: Moved adding of `flags` context into Scope (#3917) by @antonpirker +- Create a separate group for feature flag test suites (#3911) by @sentrivana +- Fix flaky LaunchDarkly tests (#3896) by @aliu39 +- Fix LRU cache copying (#3883) by @ffelixg +- Fix cache pollution from mutable reference (#3887) by @cmanallen - Centralize minimum version checking (#3910) by @sentrivana -- Small contribution docs update (#3909) by @antonpirker -- feat(flags): add Unleash feature flagging integration (#3888) by @aliu39 -- ref(flags): Beter naming for featureflags module and identifier (#3902) by @aliu39 -- Revert "ref(flags): register LD hook in setup instead of init, and don't chec…" (#3900) by @cmanallen +- Support SparkIntegration activation after SparkContext created (#3411) by @seyoon-lim +- Preserve ARQ enqueue_job __kwdefaults__ after patching (#3903) by @danmr +- Add Github workflow to comment on issues when a fix was released (#3866) by @antonpirker - Update test matrix for Sanic (#3904) by @antonpirker -- fix: preserve ARQ enqueue_job __kwdefaults__ after patching (#3903) by @danmr -- fix(flags): fix/refactor flaky launchdarkly tests (#3896) by @aliu39 -- Fix cache pollution from mutable reference (#3887) by @cmanallen -- ref(flags): register LD hook in setup instead of init, and don't check for initialization (#3890) by @aliu39 -- build(deps): bump actions/create-github-app-token from 1.11.0 to 1.11.1 (#3893) by @dependabot -- build(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 (#3892) by @dependabot -- Fix lru cache copying (#3883) by @ffelixg - Rename scripts (#3885) by @sentrivana -- Support SparkIntegration activation after SparkContext created (#3411) by @seyoon-lim -- build(deps): bump codecov/codecov-action from 5.0.7 to 5.1.1 (#3867) by @dependabot -- Add github workflow to comment on issues when a fix was released (#3866) by @antonpirker -- feat(flags): Add integration for custom tracking of flag evaluations (#3860) by @aliu39 -- ✨ Add Typer integration (#3869) by @patrick91 - Fix CI (#3878) by @sentrivana - -_Plus 3 more_ +- Treat `potel-base` as release branch in CI (#3912) by @sentrivana +- build(deps): bump actions/create-github-app-token from 1.11.0 to 1.11.1 (#3893) by @dependabot +- build(deps): bump codecov/codecov-action from 5.0.7 to 5.1.1 (#3867) by @dependabot +- build(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 (#3892) by @dependabot ## 2.19.2 @@ -86,6 +84,14 @@ _Plus 3 more_ ### Various fixes & improvements +- **New integration:** Add [LaunchDarkly](https://launchdarkly.com/) integration (#3648) by @cmanallen + + For more information, see the documentation for the [LaunchDarklyIntegration](https://docs.sentry.io/platforms/python/integrations/launchdarkly/). + +- **New integration:** Add [OpenFeature](https://openfeature.dev/) feature flagging integration (#3648) by @cmanallen + + For more information, see the documentation for the [OpenFeatureIntegration](https://docs.sentry.io/platforms/python/integrations/opoenfeature/). + - Add LaunchDarkly and OpenFeature integration (#3648) by @cmanallen - Correct typo in a comment (#3726) by @szokeasaurusrex - End `http.client` span on timeout (#3723) by @Zylphrex