From 72f4d991d70b95edb40fb71e506e93cf5a90e1a2 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 11:02:46 +0100 Subject: [PATCH 01/21] ci(tox): Exclude fakeredis 2.26.0 on py3.6 and 3.7 (#3695) `fakeredis` `2.26.0` [broke on Python 3.6 and 3.7](https://github.com/cunla/fakeredis-py/issues/341). A fix should be available when the next version is available. --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index b53cc73d7f..02e2dee388 100644 --- a/tox.ini +++ b/tox.ini @@ -583,6 +583,7 @@ deps = # Redis redis: fakeredis!=1.7.4 redis: pytest<8.0.0 + {py3.6,py3.7}-redis: fakeredis!=2.26.0 # https://github.com/cunla/fakeredis-py/issues/341 {py3.7,py3.8,py3.9,py3.10,py3.11}-redis: pytest-asyncio redis-v3: redis~=3.0 redis-v4: redis~=4.0 @@ -602,7 +603,9 @@ deps = rq-v{0.6}: redis<3.2.2 rq-v{0.13,1.0,1.5,1.10}: fakeredis>=1.0,<1.7.4 rq-v{1.15,1.16}: fakeredis + {py3.6,py3.7}-rq-v{1.15,1.16}: fakeredis!=2.26.0 # https://github.com/cunla/fakeredis-py/issues/341 rq-latest: fakeredis + {py3.6,py3.7}-rq-latest: fakeredis!=2.26.0 # https://github.com/cunla/fakeredis-py/issues/341 rq-v0.6: rq~=0.6.0 rq-v0.13: rq~=0.13.0 rq-v1.0: rq~=1.0.0 From 483a0bdf324cf6dfd1fc6399a15568b9e942f8b1 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:36:16 +0100 Subject: [PATCH 02/21] build: Remove pytest pin in requirements-devenv.txt (#3696) The pytest pin in requirements-devenv.txt appears to be unnecessary. Our tests anyways do not seem to respect this pin; the actual pins are defined for each environment in tox.ini. ref #3035 --- requirements-devenv.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-devenv.txt b/requirements-devenv.txt index 29d3f15ec9..c0fa5cf245 100644 --- a/requirements-devenv.txt +++ b/requirements-devenv.txt @@ -1,5 +1,5 @@ -r requirements-linting.txt -r requirements-testing.txt mockupdb # required by `pymongo` tests that are enabled by `pymongo` from linter requirements -pytest<7.0.0 # https://github.com/pytest-dev/pytest/issues/9621; see tox.ini +pytest pytest-asyncio From 6b8114c3009e40e3663c209255189f90037557f9 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:10:19 +0100 Subject: [PATCH 03/21] ci: Run CodeQL action on all PRs (#3698) This action only is triggered on PRs to `master`, but the action is required. This becomes a problem when a PR is opened against a branch other than `master` (e.g. as part of a PR tree). When the parent branch is merged to `master`, the PR's base automatically changes to `master`, but this action does not get triggered. Instead, it blocks on "Expected" and can only be run by adding commits to the branch. Running the action on PRs against any branch should fix this. Also, add logic to cancel in-progress workflows on pull requests (logic taken from our other actions) --- .github/workflows/codeql-analysis.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 573c49fb01..d95353c652 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,13 +17,15 @@ on: - master - sentry-sdk-2.0 pull_request: - # The branches below must be a subset of the branches above - branches: - - master - - sentry-sdk-2.0 schedule: - cron: '18 18 * * 3' +# 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 From 1ce7c31a41aac2b63be225858747c7ddfc846420 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:12:28 +0100 Subject: [PATCH 04/21] ci: Run license compliance action on all PRs (#3699) This action only is triggered on PRs to master, but the action is required. This becomes a problem when a PR is opened against a branch other than master (e.g. as part of a PR tree). When the parent branch is merged to master, the PR's base automatically changes to master, but this action does not get triggered. Instead, it blocks on "Expected" and can only be run by adding commits to the branch. Running the action on PRs against any branch should fix this. Also, add logic to cancel in-progress workflows on pull requests (logic taken from our other actions) --- .github/workflows/enforce-license-compliance.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 01e02ccb8b..ef79ed112b 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -8,10 +8,11 @@ on: - release/* - sentry-sdk-2.0 pull_request: - branches: - - master - - main - - sentry-sdk-2.0 + +# 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 }} jobs: enforce-license-compliance: From 200be874daa55d5a72b0f0713381370dda9dc414 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:29:29 +0100 Subject: [PATCH 05/21] ci(tox): Unpin `pytest` for Python 3.8+ `common` tests (#3697) This pin appears to be unnecessary on Python 3.8+. ref #3035 --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 02e2dee388..17e36c29bb 100644 --- a/tox.ini +++ b/tox.ini @@ -294,8 +294,8 @@ deps = # See https://github.com/pytest-dev/pytest/issues/9621 # and https://github.com/pytest-dev/pytest-forked/issues/67 # for justification of the upper bound on pytest - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-common: pytest<7.0.0 - py3.13-common: pytest + {py3.6,py3.7}-common: pytest<7.0.0 + {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-common: pytest # === Gevent === {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent: gevent>=22.10.0, <22.11.0 From 7e52235ec6587d4225bf1e5bac0e6e812543d0dd Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 14:31:00 +0100 Subject: [PATCH 06/21] test(tox): Unpin `pytest` on Python 3.8+ `gevent` tests (#3700) The pin appears to be unnecessary in Python 3.8+. ref #3035 --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 17e36c29bb..690fb36558 100644 --- a/tox.ini +++ b/tox.ini @@ -303,7 +303,8 @@ deps = # See https://github.com/pytest-dev/pytest/issues/9621 # and https://github.com/pytest-dev/pytest-forked/issues/67 # for justification of the upper bound on pytest - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-gevent: pytest<7.0.0 + {py3.6,py3.7}-gevent: pytest<7.0.0 + {py3.8,py3.9,py3.10,py3.11,py3.12}-gevent: pytest # === Integrations === From b6482f0a474847b1e65b5ec1a9575b929b7207c6 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:03:21 +0100 Subject: [PATCH 07/21] test(tox): Unpin `pytest` for `celery` tests (#3701) Unpin pytest for Celery tests. This requires adding a placeholder test to workaround a bug with pytest-forked. ref #3035 --- tests/integrations/celery/test_celery.py | 8 ++++++++ tox.ini | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/integrations/celery/test_celery.py b/tests/integrations/celery/test_celery.py index ffd3f0db62..e51341599f 100644 --- a/tests/integrations/celery/test_celery.py +++ b/tests/integrations/celery/test_celery.py @@ -831,3 +831,11 @@ def test_send_task_wrapped( assert span["description"] == "very_creative_task_name" assert span["op"] == "queue.submit.celery" assert span["trace_id"] == kwargs["headers"]["sentry-trace"].split("-")[0] + + +@pytest.mark.skip(reason="placeholder so that forked test does not come last") +def test_placeholder(): + """Forked tests must not come last in the module. + See https://github.com/pytest-dev/pytest-forked/issues/67#issuecomment-1964718720. + """ + pass diff --git a/tox.ini b/tox.ini index 690fb36558..75d74dbb03 100644 --- a/tox.ini +++ b/tox.ini @@ -375,7 +375,6 @@ deps = celery-latest: Celery celery: newrelic - celery: pytest<7 {py3.7}-celery: importlib-metadata<5.0 # Chalice From 4c1367b300811d4f1693b5af206b749f2139a18f Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 28 Oct 2024 17:51:06 +0100 Subject: [PATCH 08/21] test: Disable broken RQ test in newly-released RQ 2.0 (#3708) See #3707 --- tests/integrations/rq/test_rq.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integrations/rq/test_rq.py b/tests/integrations/rq/test_rq.py index e445b588be..0b690ca3dc 100644 --- a/tests/integrations/rq/test_rq.py +++ b/tests/integrations/rq/test_rq.py @@ -35,6 +35,7 @@ def _patch_rq_get_server_version(monkeypatch): def crashing_job(foo): + print("RUNNING CRASHING JOB") 1 / 0 @@ -254,6 +255,11 @@ def test_traces_sampler_gets_correct_values_in_sampling_context( @pytest.mark.skipif( parse_version(rq.__version__) < (1, 5), reason="At least rq-1.5 required" ) +@pytest.mark.skipif( + parse_version(rq.__version__) >= (2,), + reason="Test broke in RQ 2.0. Investigate and fix. " + "See https://github.com/getsentry/sentry-python/issues/3707.", +) def test_job_with_retries(sentry_init, capture_events): sentry_init(integrations=[RqIntegration()]) events = capture_events() From 897333bce69d18a9d356ca7748b3079c02576f45 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 29 Oct 2024 09:45:29 +0100 Subject: [PATCH 09/21] test(rq): Remove accidentally-committed print (#3712) #3708 got auto-merged before I had the chance to remove this print statement. --- tests/integrations/rq/test_rq.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integrations/rq/test_rq.py b/tests/integrations/rq/test_rq.py index 0b690ca3dc..ffd6f458e1 100644 --- a/tests/integrations/rq/test_rq.py +++ b/tests/integrations/rq/test_rq.py @@ -35,7 +35,6 @@ def _patch_rq_get_server_version(monkeypatch): def crashing_job(foo): - print("RUNNING CRASHING JOB") 1 / 0 From d48dc46823d3602ca899ecb2178cfe4b8267f89c Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:13:05 +0100 Subject: [PATCH 10/21] ci: Clarify that only pinned tests are required (#3713) Rename the action that checks that all our pinned-version tests for our integrations are named "All pinned XXX tests passed" rather than just "All XXX tests passed." The old name was confusing because the action only checks that the pinned tests have passed. --- .github/workflows/test-integrations-ai.yml | 2 +- .github/workflows/test-integrations-aws-lambda.yml | 2 +- .github/workflows/test-integrations-cloud-computing.yml | 2 +- .github/workflows/test-integrations-common.yml | 2 +- .github/workflows/test-integrations-data-processing.yml | 2 +- .github/workflows/test-integrations-databases.yml | 2 +- .github/workflows/test-integrations-graphql.yml | 2 +- .github/workflows/test-integrations-miscellaneous.yml | 2 +- .github/workflows/test-integrations-networking.yml | 2 +- .github/workflows/test-integrations-web-frameworks-1.yml | 2 +- .github/workflows/test-integrations-web-frameworks-2.yml | 2 +- scripts/split-tox-gh-actions/templates/check_required.jinja | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 723f9c8412..24ccc77a87 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -165,7 +165,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All AI tests passed + name: All pinned AI tests passed needs: test-ai-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-aws-lambda.yml b/.github/workflows/test-integrations-aws-lambda.yml index 38c838ab33..6f5ea794b8 100644 --- a/.github/workflows/test-integrations-aws-lambda.yml +++ b/.github/workflows/test-integrations-aws-lambda.yml @@ -112,7 +112,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All AWS Lambda tests passed + name: All pinned AWS Lambda tests passed needs: test-aws_lambda-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-cloud-computing.yml b/.github/workflows/test-integrations-cloud-computing.yml index a3b7fc57ab..1f6913ea4a 100644 --- a/.github/workflows/test-integrations-cloud-computing.yml +++ b/.github/workflows/test-integrations-cloud-computing.yml @@ -157,7 +157,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Cloud Computing tests passed + name: All pinned Cloud Computing tests passed needs: test-cloud_computing-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 8116b1b67c..ecffdb6f3e 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -77,7 +77,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Common tests passed + name: All pinned Common tests passed needs: test-common-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-data-processing.yml b/.github/workflows/test-integrations-data-processing.yml index acabcd1748..49d18fc24c 100644 --- a/.github/workflows/test-integrations-data-processing.yml +++ b/.github/workflows/test-integrations-data-processing.yml @@ -193,7 +193,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Data Processing tests passed + name: All pinned Data Processing tests passed needs: test-data_processing-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-databases.yml b/.github/workflows/test-integrations-databases.yml index 741e8fc43e..49d3e923ee 100644 --- a/.github/workflows/test-integrations-databases.yml +++ b/.github/workflows/test-integrations-databases.yml @@ -211,7 +211,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Databases tests passed + name: All pinned Databases tests passed needs: test-databases-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index ba4091215e..2cefb5d191 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -157,7 +157,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All GraphQL tests passed + name: All pinned GraphQL tests passed needs: test-graphql-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-miscellaneous.yml b/.github/workflows/test-integrations-miscellaneous.yml index 064d083335..0b49a27219 100644 --- a/.github/workflows/test-integrations-miscellaneous.yml +++ b/.github/workflows/test-integrations-miscellaneous.yml @@ -165,7 +165,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Miscellaneous tests passed + name: All pinned Miscellaneous tests passed needs: test-miscellaneous-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-networking.yml b/.github/workflows/test-integrations-networking.yml index 192eb1b35b..c24edff174 100644 --- a/.github/workflows/test-integrations-networking.yml +++ b/.github/workflows/test-integrations-networking.yml @@ -157,7 +157,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Networking tests passed + name: All pinned Networking tests passed needs: test-networking-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-web-frameworks-1.yml b/.github/workflows/test-integrations-web-frameworks-1.yml index f2bcb336dd..a655710843 100644 --- a/.github/workflows/test-integrations-web-frameworks-1.yml +++ b/.github/workflows/test-integrations-web-frameworks-1.yml @@ -193,7 +193,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Web Frameworks 1 tests passed + name: All pinned Web Frameworks 1 tests passed needs: test-web_frameworks_1-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-web-frameworks-2.yml b/.github/workflows/test-integrations-web-frameworks-2.yml index 8f6bd543df..d3f1001e2c 100644 --- a/.github/workflows/test-integrations-web-frameworks-2.yml +++ b/.github/workflows/test-integrations-web-frameworks-2.yml @@ -205,7 +205,7 @@ jobs: files: .junitxml verbose: true check_required_tests: - name: All Web Frameworks 2 tests passed + name: All pinned Web Frameworks 2 tests passed needs: test-web_frameworks_2-pinned # Always run this, even if a dependent job failed if: always() diff --git a/scripts/split-tox-gh-actions/templates/check_required.jinja b/scripts/split-tox-gh-actions/templates/check_required.jinja index b9b0f54015..ddb47cddf1 100644 --- a/scripts/split-tox-gh-actions/templates/check_required.jinja +++ b/scripts/split-tox-gh-actions/templates/check_required.jinja @@ -1,5 +1,5 @@ check_required_tests: - name: All {{ group }} tests passed + name: All pinned {{ group }} tests passed {% if "pinned" in categories %} needs: test-{{ group | replace(" ", "_") | lower }}-pinned {% endif %} From c21962e98d8879f550725d6ececb6b6c28f9d32c Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 29 Oct 2024 13:09:04 +0100 Subject: [PATCH 11/21] test(redis): Install `pytest-asyncio` for `redis` tests (Python 3.12-13) (#3706) Although we run the `redis` tests on Python 3.12 and 3.13, we don't install `pytest-asyncio` on these versions. We likely should. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 75d74dbb03..67d6166461 100644 --- a/tox.ini +++ b/tox.ini @@ -584,7 +584,7 @@ deps = redis: fakeredis!=1.7.4 redis: pytest<8.0.0 {py3.6,py3.7}-redis: fakeredis!=2.26.0 # https://github.com/cunla/fakeredis-py/issues/341 - {py3.7,py3.8,py3.9,py3.10,py3.11}-redis: pytest-asyncio + {py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-redis: pytest-asyncio redis-v3: redis~=3.0 redis-v4: redis~=4.0 redis-v5: redis~=5.0 From 000c8e6c4eedf046c601b81d5d8d82f92115eddd Mon Sep 17 00:00:00 2001 From: Ben Beasley Date: Tue, 29 Oct 2024 08:13:56 -0400 Subject: [PATCH 12/21] fix(starlette): Prefer python_multipart import over multipart (#3710) See also releases 0.0.13 through 0.0.16 at https://github.com/Kludex/python-multipart/releases. --------- Co-authored-by: Daniel Szoke --- sentry_sdk/integrations/starlette.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 52c64f6843..d9db8bd6b8 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -65,7 +65,12 @@ try: # Optional dependency of Starlette to parse form data. - import multipart # type: ignore + try: + # python-multipart 0.0.13 and later + import python_multipart as multipart # type: ignore + except ImportError: + # python-multipart 0.0.12 and earlier + import multipart # type: ignore except ImportError: multipart = None From bf400904245c3809bad5f20fd637408f519e7a15 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 29 Oct 2024 13:56:50 +0100 Subject: [PATCH 13/21] test(tornado): Unpin `pytest` for `tornado-latest` tests (#3714) The Pytest version pin is only needed for `tornado-v6.0` and `tornado-v6.2`. The incompatibility with the latest Pytest versions has been fixed in newer Tornado versions. --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 67d6166461..ef30e539b5 100644 --- a/tox.ini +++ b/tox.ini @@ -673,7 +673,9 @@ deps = strawberry-latest: strawberry-graphql[fastapi,flask] # Tornado - tornado: pytest<8.2 + # Tornado <6.4.1 is incompatible with Pytest ≥8.2 + # See https://github.com/tornadoweb/tornado/pull/3382. + tornado-{v6.0,v6.2}: pytest<8.2 tornado-v6.0: tornado~=6.0.0 tornado-v6.2: tornado~=6.2.0 tornado-latest: tornado From 02d09346e6d070e03b828807d72485b6f23b2c11 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Tue, 29 Oct 2024 13:14:06 -0400 Subject: [PATCH 14/21] fix(profiling): Use `type()` instead when extracting frames (#3716) When extract frame names, we should avoid accessing the `__class__` attribute as it can be overwritten in the class implementation. In this particular instance, the `SimpleLazyObject` class in django wraps `__class__` so when it is accessed, it can cause the underlying lazy object to be evaluation unexpectedly. To avoid this, use the `type()` builtin function which does cannot be overwritten and will return the correct class. Note that this does not work with old style classes but since dropping python 2 support, we only need to consider new style classes. --- sentry_sdk/profiler/utils.py | 2 +- tests/integrations/django/test_basic.py | 48 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/profiler/utils.py b/sentry_sdk/profiler/utils.py index e78ea54256..3554cddb5d 100644 --- a/sentry_sdk/profiler/utils.py +++ b/sentry_sdk/profiler/utils.py @@ -89,7 +89,7 @@ def get_frame_name(frame): and co_varnames[0] == "self" and "self" in frame.f_locals ): - for cls in frame.f_locals["self"].__class__.__mro__: + for cls in type(frame.f_locals["self"]).__mro__: if name in cls.__dict__: return "{}.{}".format(cls.__name__, name) except (AttributeError, ValueError): diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index c8282412ea..0e3f700105 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -1,6 +1,8 @@ +import inspect import json import os import re +import sys import pytest from functools import partial from unittest.mock import patch @@ -12,6 +14,7 @@ from django.core.management import execute_from_command_line from django.db.utils import OperationalError, ProgrammingError, DataError from django.http.request import RawPostDataException +from django.utils.functional import SimpleLazyObject try: from django.urls import reverse @@ -29,6 +32,7 @@ ) from sentry_sdk.integrations.django.signals_handlers import _get_receiver_name from sentry_sdk.integrations.executing import ExecutingIntegration +from sentry_sdk.profiler.utils import get_frame_name from sentry_sdk.tracing import Span from tests.conftest import unpack_werkzeug_response from tests.integrations.django.myapp.wsgi import application @@ -1295,3 +1299,47 @@ def test_ensures_no_spotlight_middleware_when_no_spotlight( added = frozenset(settings.MIDDLEWARE) ^ original_middleware assert "sentry_sdk.spotlight.SpotlightMiddleware" not in added + + +def test_get_frame_name_when_in_lazy_object(): + allowed_to_init = False + + class SimpleLazyObjectWrapper(SimpleLazyObject): + def unproxied_method(self): + """ + For testing purposes. We inject a method on the SimpleLazyObject + class so if python is executing this method, we should get + this class instead of the wrapped class and avoid evaluating + the wrapped object too early. + """ + return inspect.currentframe() + + class GetFrame: + def __init__(self): + assert allowed_to_init, "GetFrame not permitted to initialize yet" + + def proxied_method(self): + """ + For testing purposes. We add an proxied method on the instance + class so if python is executing this method, we should get + this class instead of the wrapper class. + """ + return inspect.currentframe() + + instance = SimpleLazyObjectWrapper(lambda: GetFrame()) + + assert get_frame_name(instance.unproxied_method()) == ( + "SimpleLazyObjectWrapper.unproxied_method" + if sys.version_info < (3, 11) + else "test_get_frame_name_when_in_lazy_object..SimpleLazyObjectWrapper.unproxied_method" + ) + + # Now that we're about to access an instance method on the wrapped class, + # we should permit initializing it + allowed_to_init = True + + assert get_frame_name(instance.proxied_method()) == ( + "GetFrame.proxied_method" + if sys.version_info < (3, 11) + else "test_get_frame_name_when_in_lazy_object..GetFrame.proxied_method" + ) From ce9986cb19ee80d92bdf68bee6243d5c049fdb54 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 31 Oct 2024 12:57:41 +0000 Subject: [PATCH 15/21] fix(http2): Check for h2 existence (#3690) The new `HTTP2Transport` needs `httpcore` _and_ `h2` and we only checked for `httpcore`. This caused runtime errors and dropping of all events during testing as the test platform had `httpcore` installed but not `h2`. This patch adds both as conditions for the new transport implementation. Ideally, when we switch out the old transport, we'd silently check for `h2` existence only and set the `http2` option accordingly. --- sentry_sdk/transport.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index 1b1842d03e..8798115898 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -720,6 +720,7 @@ def _request( try: import httpcore + import h2 # type: ignore # noqa: F401 except ImportError: # Sorry, no Http2Transport for you class Http2Transport(HttpTransport): From 5c5d98a7937330bd4ab69ee8a10b0d4e438c00ea Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 31 Oct 2024 15:59:36 +0000 Subject: [PATCH 16/21] test: Fix UTC assuming test (#3722) Fixes #3720. --- tests/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 87e2659a12..6e01bb4f3a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -69,8 +69,8 @@ def _normalize_distribution_name(name): ), # UTC time ( "2021-01-01T00:00:00.000000", - datetime(2021, 1, 1, tzinfo=timezone.utc), - ), # No TZ -- assume UTC + datetime(2021, 1, 1).astimezone(timezone.utc), + ), # No TZ -- assume local but convert to UTC ( "2021-01-01T00:00:00Z", datetime(2021, 1, 1, tzinfo=timezone.utc), From 5e2d2cf7fdf367dc3bced0d4c4efe33c1046887c Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Thu, 31 Oct 2024 16:12:07 -0400 Subject: [PATCH 17/21] fix(tracing): End http.client span on timeout (#3723) If the http request times out, the http client span never gets finished. So make sure to finish it no matter what. --- sentry_sdk/integrations/stdlib.py | 10 ++++--- tests/integrations/stdlib/test_httplib.py | 33 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 287c8cb272..d388c5bca6 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -127,11 +127,13 @@ def getresponse(self, *args, **kwargs): if span is None: return real_getresponse(self, *args, **kwargs) - rv = real_getresponse(self, *args, **kwargs) + try: + rv = real_getresponse(self, *args, **kwargs) - span.set_http_status(int(rv.status)) - span.set_data("reason", rv.reason) - span.finish() + span.set_http_status(int(rv.status)) + span.set_data("reason", rv.reason) + finally: + span.finish() return rv diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index c327331608..200b282f53 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1,5 +1,6 @@ import random from http.client import HTTPConnection, HTTPSConnection +from socket import SocketIO from urllib.request import urlopen from unittest import mock @@ -342,3 +343,35 @@ def test_span_origin(sentry_init, capture_events): assert event["spans"][0]["op"] == "http.client" assert event["spans"][0]["origin"] == "auto.http.stdlib.httplib" + + +def test_http_timeout(monkeypatch, sentry_init, capture_envelopes): + mock_readinto = mock.Mock(side_effect=TimeoutError) + monkeypatch.setattr(SocketIO, "readinto", mock_readinto) + + sentry_init(traces_sample_rate=1.0) + + envelopes = capture_envelopes() + + with start_transaction(op="op", name="name"): + try: + conn = HTTPSConnection("www.squirrelchasers.com") + conn.request("GET", "/top-chasers") + conn.getresponse() + except Exception: + pass + + items = [ + item + for envelope in envelopes + for item in envelope.items + if item.type == "transaction" + ] + assert len(items) == 1 + + transaction = items[0].payload.json + assert len(transaction["spans"]) == 1 + + span = transaction["spans"][0] + assert span["op"] == "http.client" + assert span["description"] == "GET https://www.squirrelchasers.com/top-chasers" From d06a1897e5106e2a0521bc51857eb30abddb0ef4 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Mon, 4 Nov 2024 11:24:01 +0100 Subject: [PATCH 18/21] docs(hub): Correct typo in a comment (#3726) --- sentry_sdk/hub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index ec30e25419..7fda9202df 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -101,7 +101,7 @@ def current(cls): rv = _local.get(None) if rv is None: with _suppress_hub_deprecation_warning(): - # This will raise a deprecation warning; supress it since we already warned above. + # This will raise a deprecation warning; suppress it since we already warned above. rv = Hub(GLOBAL_HUB) _local.set(rv) return rv From dd1117d63fd690d502b32c263e9e970b682fa280 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Mon, 4 Nov 2024 06:00:41 -0600 Subject: [PATCH 19/21] Add LaunchDarkly and OpenFeature integration (#3648) Adds LaunchDarkly and OpenFeature integration and extends the `Scope` with a `flags` property. As flags are evaluated by an application they are stored within the Sentry SDK (lru cache). When an error occurs we fetch the flags stored in the SDK and serialize them on the event. --------- Co-authored-by: Anton Pirker Co-authored-by: Ivana Kellyer Co-authored-by: Andrew Liu <159852527+aliu39@users.noreply.github.com> --- .../test-integrations-miscellaneous.yml | 16 +++ mypy.ini | 2 + requirements-linting.txt | 2 + .../split-tox-gh-actions.py | 2 + sentry_sdk/_lru_cache.py | 17 +++ sentry_sdk/consts.py | 1 + sentry_sdk/flag_utils.py | 47 +++++++ sentry_sdk/integrations/launchdarkly.py | 64 ++++++++++ sentry_sdk/integrations/openfeature.py | 43 +++++++ sentry_sdk/scope.py | 16 +++ setup.py | 2 + tests/integrations/launchdarkly/__init__.py | 3 + .../launchdarkly/test_launchdarkly.py | 116 ++++++++++++++++++ tests/integrations/openfeature/__init__.py | 3 + .../openfeature/test_openfeature.py | 80 ++++++++++++ tests/test_flag_utils.py | 43 +++++++ tests/test_lru_cache.py | 23 ++++ tox.ini | 18 +++ 18 files changed, 498 insertions(+) create mode 100644 sentry_sdk/flag_utils.py create mode 100644 sentry_sdk/integrations/launchdarkly.py create mode 100644 sentry_sdk/integrations/openfeature.py create mode 100644 tests/integrations/launchdarkly/__init__.py create mode 100644 tests/integrations/launchdarkly/test_launchdarkly.py create mode 100644 tests/integrations/openfeature/__init__.py create mode 100644 tests/integrations/openfeature/test_openfeature.py create mode 100644 tests/test_flag_utils.py diff --git a/.github/workflows/test-integrations-miscellaneous.yml b/.github/workflows/test-integrations-miscellaneous.yml index 0b49a27219..88a576505e 100644 --- a/.github/workflows/test-integrations-miscellaneous.yml +++ b/.github/workflows/test-integrations-miscellaneous.yml @@ -45,10 +45,18 @@ 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 @@ -117,10 +125,18 @@ 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 diff --git a/mypy.ini b/mypy.ini index bacba96ceb..63fa7f334f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -74,6 +74,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-openai.*] ignore_missing_imports = True +[mypy-openfeature.*] +ignore_missing_imports = True [mypy-huggingface_hub.*] ignore_missing_imports = True [mypy-arq.*] diff --git a/requirements-linting.txt b/requirements-linting.txt index d2a65b31db..c9d4bd7f5c 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -15,3 +15,5 @@ flake8-bugbear pep8-naming pre-commit # local linting httpcore +openfeature-sdk +launchdarkly-server-sdk 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 7ed2505f40..c0bf2a7a09 100755 --- a/scripts/split-tox-gh-actions/split-tox-gh-actions.py +++ b/scripts/split-tox-gh-actions/split-tox-gh-actions.py @@ -125,7 +125,9 @@ "tornado", ], "Miscellaneous": [ + "launchdarkly", "loguru", + "openfeature", "opentelemetry", "potel", "pure_eval", diff --git a/sentry_sdk/_lru_cache.py b/sentry_sdk/_lru_cache.py index 37e86e5fe3..ec557b1093 100644 --- a/sentry_sdk/_lru_cache.py +++ b/sentry_sdk/_lru_cache.py @@ -62,6 +62,8 @@ """ +from copy import copy + SENTINEL = object() @@ -89,6 +91,13 @@ def __init__(self, max_size): self.hits = self.misses = 0 + def __copy__(self): + cache = LRUCache(self.max_size) + cache.full = self.full + cache.cache = copy(self.cache) + cache.root = copy(self.root) + return cache + def set(self, key, value): link = self.cache.get(key, SENTINEL) @@ -154,3 +163,11 @@ def get(self, key, default=None): self.hits += 1 return link[VALUE] + + def get_all(self): + nodes = [] + node = self.root[NEXT] + while node is not self.root: + nodes.append((node[KEY], node[VALUE])) + node = node[NEXT] + return nodes diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 6791abeb0e..fdb20caadf 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -59,6 +59,7 @@ class CompressionAlgo(Enum): "Experiments", { "max_spans": Optional[int], + "max_flags": Optional[int], "record_sql_params": Optional[bool], "continuous_profiling_auto_start": Optional[bool], "continuous_profiling_mode": Optional[ContinuousProfilerMode], diff --git a/sentry_sdk/flag_utils.py b/sentry_sdk/flag_utils.py new file mode 100644 index 0000000000..2b345a7f0b --- /dev/null +++ b/sentry_sdk/flag_utils.py @@ -0,0 +1,47 @@ +from copy import copy +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk._lru_cache import LRUCache + +if TYPE_CHECKING: + from typing import TypedDict, Optional + from sentry_sdk._types import Event, ExcInfo + + FlagData = TypedDict("FlagData", {"flag": str, "result": bool}) + + +DEFAULT_FLAG_CAPACITY = 100 + + +class FlagBuffer: + + def __init__(self, capacity): + # type: (int) -> None + self.buffer = LRUCache(capacity) + self.capacity = capacity + + 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()] + + def set(self, flag, result): + # type: (str, bool) -> None + 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 diff --git a/sentry_sdk/integrations/launchdarkly.py b/sentry_sdk/integrations/launchdarkly.py new file mode 100644 index 0000000000..9e00e12ede --- /dev/null +++ b/sentry_sdk/integrations/launchdarkly.py @@ -0,0 +1,64 @@ +from typing import TYPE_CHECKING +import sentry_sdk + +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.flag_utils import flag_error_processor + +try: + import ldclient + from ldclient.hook import Hook, Metadata + + if TYPE_CHECKING: + from ldclient import LDClient + from ldclient.hook import EvaluationSeriesContext + from ldclient.evaluation import EvaluationDetail + + from typing import Any +except ImportError: + raise DidNotEnable("LaunchDarkly is not installed") + + +class LaunchDarklyIntegration(Integration): + identifier = "launchdarkly" + + def __init__(self, ld_client=None): + # type: (LDClient | None) -> None + """ + :param client: An initialized LDClient instance. If a client is not provided, this + integration will attempt to use the shared global instance. + """ + try: + 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) + + +class LaunchDarklyHook(Hook): + + @property + def metadata(self): + # type: () -> Metadata + return Metadata(name="sentry-feature-flag-recorder") + + def after_evaluation(self, series_context, data, detail): + # type: (EvaluationSeriesContext, dict[Any, Any], EvaluationDetail) -> dict[Any, Any] + if isinstance(detail.value, bool): + flags = sentry_sdk.get_current_scope().flags + flags.set(series_context.key, detail.value) + return data + + def before_evaluation(self, series_context, data): + # type: (EvaluationSeriesContext, dict[Any, Any]) -> dict[Any, Any] + return data # No-op. diff --git a/sentry_sdk/integrations/openfeature.py b/sentry_sdk/integrations/openfeature.py new file mode 100644 index 0000000000..18f968a703 --- /dev/null +++ b/sentry_sdk/integrations/openfeature.py @@ -0,0 +1,43 @@ +from typing import TYPE_CHECKING +import sentry_sdk + +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.flag_utils import flag_error_processor + +try: + from openfeature import api + from openfeature.hook import Hook + + if TYPE_CHECKING: + from openfeature.flag_evaluation import FlagEvaluationDetails + from openfeature.hook import HookContext, HookHints +except ImportError: + raise DidNotEnable("OpenFeature is not installed") + + +class OpenFeatureIntegration(Integration): + identifier = "openfeature" + + @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()]) + + +class OpenFeatureHook(Hook): + + def after(self, hook_context, details, hints): + # type: (HookContext, FlagEvaluationDetails[bool], HookHints) -> None + if isinstance(details.value, bool): + flags = sentry_sdk.get_current_scope().flags + flags.set(details.flag_key, details.value) + + def error(self, hook_context, exception, hints): + # type: (HookContext, Exception, HookHints) -> None + if isinstance(hook_context.default_value, bool): + flags = sentry_sdk.get_current_scope().flags + flags.set(hook_context.flag_key, hook_context.default_value) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 0c0482904e..34ccc7f940 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -11,6 +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.profiler.continuous_profiler import try_autostart_continuous_profiler from sentry_sdk.profiler.transaction_profiler import Profile from sentry_sdk.session import Session @@ -192,6 +193,7 @@ class Scope: "client", "_type", "_last_event_id", + "_flags", ) def __init__(self, ty=None, client=None): @@ -249,6 +251,8 @@ def __copy__(self): rv._last_event_id = self._last_event_id + rv._flags = copy(self._flags) + return rv @classmethod @@ -685,6 +689,7 @@ def clear(self): # self._last_event_id is only applicable to isolation scopes self._last_event_id = None # type: Optional[str] + self._flags = None # type: Optional[FlagBuffer] @_attr_setter def level(self, value): @@ -1546,6 +1551,17 @@ def __repr__(self): self._type, ) + @property + def flags(self): + # type: () -> FlagBuffer + if self._flags is None: + max_flags = ( + self.get_client().options["_experiments"].get("max_flags") + or DEFAULT_FLAG_CAPACITY + ) + self._flags = FlagBuffer(capacity=max_flags) + return self._flags + @contextmanager def new_scope(): diff --git a/setup.py b/setup.py index e9c83eb1fa..e5e0c8eaa4 100644 --- a/setup.py +++ b/setup.py @@ -63,9 +63,11 @@ def get_file_text(file_name): "huey": ["huey>=2"], "huggingface_hub": ["huggingface_hub>=0.22"], "langchain": ["langchain>=0.0.210"], + "launchdarkly": ["launchdarkly-server-sdk>=9.8.0"], "litestar": ["litestar>=2.0.0"], "loguru": ["loguru>=0.5"], "openai": ["openai>=1.0.0", "tiktoken>=0.3.0"], + "openfeature": ["openfeature-sdk>=0.7.1"], "opentelemetry": ["opentelemetry-distro>=0.35b0"], "opentelemetry-experimental": ["opentelemetry-distro"], "pure_eval": ["pure_eval", "executing", "asttokens"], diff --git a/tests/integrations/launchdarkly/__init__.py b/tests/integrations/launchdarkly/__init__.py new file mode 100644 index 0000000000..06e09884c8 --- /dev/null +++ b/tests/integrations/launchdarkly/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("ldclient") diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py new file mode 100644 index 0000000000..acbe764104 --- /dev/null +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -0,0 +1,116 @@ +import asyncio +import concurrent.futures as cf + +import ldclient + +import sentry_sdk +import pytest + +from ldclient import LDClient +from ldclient.config import Config +from ldclient.context import Context +from ldclient.integrations.test_data import TestData + +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration + + +@pytest.mark.parametrize( + "use_global_client", + (False, True), +) +def test_launchdarkly_integration(sentry_init, use_global_client): + td = TestData.data_source() + config = Config("sdk-key", update_processor_class=td) + if use_global_client: + ldclient.set_config(config) + sentry_init(integrations=[LaunchDarklyIntegration()]) + client = ldclient.get() + else: + 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) + 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}, + ] + + +def test_launchdarkly_integration_threaded(sentry_init): + 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") + + 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()] + + 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) + + 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"] + + +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") + + 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()] + + 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)) + client.variation("hello", context, False) + + results = asyncio.run(runner()).result() + assert results[0] == ["hello", "world"] + assert results[1] == ["hello", "other"] + + +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() + ldclient.__config = None + finally: + ldclient.__lock.unlock() + + 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) diff --git a/tests/integrations/openfeature/__init__.py b/tests/integrations/openfeature/__init__.py new file mode 100644 index 0000000000..a17549ea79 --- /dev/null +++ b/tests/integrations/openfeature/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("openfeature") diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py new file mode 100644 index 0000000000..24e7857f9a --- /dev/null +++ b/tests/integrations/openfeature/test_openfeature.py @@ -0,0 +1,80 @@ +import asyncio +import concurrent.futures as cf +import sentry_sdk + +from openfeature import api +from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider +from sentry_sdk.integrations.openfeature import OpenFeatureIntegration + + +def test_openfeature_integration(sentry_init): + sentry_init(integrations=[OpenFeatureIntegration()]) + + flags = { + "hello": InMemoryFlag("on", {"on": True, "off": False}), + "world": InMemoryFlag("off", {"on": True, "off": False}), + } + api.set_provider(InMemoryProvider(flags)) + + client = api.get_client() + client.get_boolean_value("hello", default_value=False) + 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}, + ] + + +def test_openfeature_integration_threaded(sentry_init): + sentry_init(integrations=[OpenFeatureIntegration()]) + + flags = { + "hello": InMemoryFlag("on", {"on": True, "off": False}), + "world": InMemoryFlag("off", {"on": True, "off": False}), + } + api.set_provider(InMemoryProvider(flags)) + + client = api.get_client() + client.get_boolean_value("hello", default_value=False) + + 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()] + + 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"] + + +def test_openfeature_integration_asyncio(sentry_init): + """Assert concurrently evaluated flags do not pollute one another.""" + + 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()] + + 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)) + + 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"] diff --git a/tests/test_flag_utils.py b/tests/test_flag_utils.py new file mode 100644 index 0000000000..3fa4f3abfe --- /dev/null +++ b/tests/test_flag_utils.py @@ -0,0 +1,43 @@ +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}, + ] diff --git a/tests/test_lru_cache.py b/tests/test_lru_cache.py index 5343e76169..3e9c0ac964 100644 --- a/tests/test_lru_cache.py +++ b/tests/test_lru_cache.py @@ -35,3 +35,26 @@ def test_cache_eviction(): cache.set(4, 4) assert cache.get(3) is None assert cache.get(4) == 4 + + +def test_cache_miss(): + cache = LRUCache(1) + assert cache.get(0) is None + + +def test_cache_set_overwrite(): + cache = LRUCache(3) + cache.set(0, 0) + cache.set(0, 1) + assert cache.get(0) == 1 + + +def test_cache_get_all(): + cache = LRUCache(3) + cache.set(0, 0) + cache.set(1, 1) + cache.set(2, 2) + cache.set(3, 3) + assert cache.get_all() == [(1, 1), (2, 2), (3, 3)] + cache.get(1) + assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] diff --git a/tox.ini b/tox.ini index ef30e539b5..f3a7ba4ea0 100644 --- a/tox.ini +++ b/tox.ini @@ -184,6 +184,14 @@ envlist = {py3.9,py3.11,py3.12}-openai-latest {py3.9,py3.11,py3.12}-openai-notiktoken + # OpenFeature + {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 @@ -539,6 +547,14 @@ deps = openai-latest: tiktoken~=0.6.0 openai-notiktoken: openai + # OpenFeature + openfeature-v0.7: openfeature-sdk~=0.7.1 + openfeature-latest: openfeature-sdk + + # LaunchDarkly + launchdarkly-v9.8.0: launchdarkly-server-sdk~=9.8.0 + launchdarkly-latest: launchdarkly-server-sdk + # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro @@ -727,9 +743,11 @@ setenv = huey: TESTPATH=tests/integrations/huey huggingface_hub: TESTPATH=tests/integrations/huggingface_hub langchain: TESTPATH=tests/integrations/langchain + launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru openai: TESTPATH=tests/integrations/openai + openfeature: TESTPATH=tests/integrations/openfeature opentelemetry: TESTPATH=tests/integrations/opentelemetry potel: TESTPATH=tests/integrations/opentelemetry pure_eval: TESTPATH=tests/integrations/pure_eval From 0a8ef922b8b5c933a5c0478622e2db0f1768244c Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 4 Nov 2024 13:16:51 +0000 Subject: [PATCH 20/21] release: 2.18.0 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df6014abc..0bc4d1beb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 2.18.0 + +### Various fixes & improvements + +- Add LaunchDarkly and OpenFeature integration (#3648) by @cmanallen +- docs(hub): Correct typo in a comment (#3726) by @szokeasaurusrex +- fix(tracing): End http.client span on timeout (#3723) by @Zylphrex +- test: Fix UTC assuming test (#3722) by @BYK +- fix(http2): Check for h2 existence (#3690) by @BYK +- fix(profiling): Use `type()` instead when extracting frames (#3716) by @Zylphrex +- test(tornado): Unpin `pytest` for `tornado-latest` tests (#3714) by @szokeasaurusrex +- fix(starlette): Prefer python_multipart import over multipart (#3710) by @musicinmybrain +- test(redis): Install `pytest-asyncio` for `redis` tests (Python 3.12-13) (#3706) by @szokeasaurusrex +- ci: Clarify that only pinned tests are required (#3713) by @szokeasaurusrex +- test(rq): Remove accidentally-committed print (#3712) by @szokeasaurusrex +- test: Disable broken RQ test in newly-released RQ 2.0 (#3708) by @szokeasaurusrex +- test(tox): Unpin `pytest` for `celery` tests (#3701) by @szokeasaurusrex +- test(tox): Unpin `pytest` on Python 3.8+ `gevent` tests (#3700) by @szokeasaurusrex +- ci(tox): Unpin `pytest` for Python 3.8+ `common` tests (#3697) by @szokeasaurusrex +- ci: Run license compliance action on all PRs (#3699) by @szokeasaurusrex +- ci: Run CodeQL action on all PRs (#3698) by @szokeasaurusrex +- build: Remove pytest pin in requirements-devenv.txt (#3696) by @szokeasaurusrex +- ci(tox): Exclude fakeredis 2.26.0 on py3.6 and 3.7 (#3695) by @szokeasaurusrex +- fix(profiling): Update active thread for asgi (#3669) by @Zylphrex +- tests: Test with Falcon 4.0 (#3684) by @sentrivana +- fix(HTTP2Transport): Only enable HTTP2 when DSN is HTTPS (#3678) by @BYK +- fix(strawberry): prepare for upstream extension removal (#3649) by @DoctorJohn +- docs(sdk): Enhance README with improved clarity and developer-friendly examples (#3667) by @UTSAVS26 + ## 2.17.0 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 0489358dd9..6d33e5809a 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.17.0" +release = "2.18.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index fdb20caadf..ae32294d05 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -575,4 +575,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.17.0" +VERSION = "2.18.0" diff --git a/setup.py b/setup.py index e5e0c8eaa4..7ac4b56fde 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.17.0", + version="2.18.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From 09946cb6246e700c4cfbdb880dda5751472249aa Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 4 Nov 2024 14:34:24 +0100 Subject: [PATCH 21/21] Update CHANGELOG.md --- CHANGELOG.md | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc4d1beb0..c47d0e0458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,29 +5,29 @@ ### Various fixes & improvements - Add LaunchDarkly and OpenFeature integration (#3648) by @cmanallen -- docs(hub): Correct typo in a comment (#3726) by @szokeasaurusrex -- fix(tracing): End http.client span on timeout (#3723) by @Zylphrex -- test: Fix UTC assuming test (#3722) by @BYK -- fix(http2): Check for h2 existence (#3690) by @BYK -- fix(profiling): Use `type()` instead when extracting frames (#3716) by @Zylphrex -- test(tornado): Unpin `pytest` for `tornado-latest` tests (#3714) by @szokeasaurusrex -- fix(starlette): Prefer python_multipart import over multipart (#3710) by @musicinmybrain -- test(redis): Install `pytest-asyncio` for `redis` tests (Python 3.12-13) (#3706) by @szokeasaurusrex -- ci: Clarify that only pinned tests are required (#3713) by @szokeasaurusrex -- test(rq): Remove accidentally-committed print (#3712) by @szokeasaurusrex -- test: Disable broken RQ test in newly-released RQ 2.0 (#3708) by @szokeasaurusrex -- test(tox): Unpin `pytest` for `celery` tests (#3701) by @szokeasaurusrex -- test(tox): Unpin `pytest` on Python 3.8+ `gevent` tests (#3700) by @szokeasaurusrex -- ci(tox): Unpin `pytest` for Python 3.8+ `common` tests (#3697) by @szokeasaurusrex -- ci: Run license compliance action on all PRs (#3699) by @szokeasaurusrex -- ci: Run CodeQL action on all PRs (#3698) by @szokeasaurusrex -- build: Remove pytest pin in requirements-devenv.txt (#3696) by @szokeasaurusrex -- ci(tox): Exclude fakeredis 2.26.0 on py3.6 and 3.7 (#3695) by @szokeasaurusrex -- fix(profiling): Update active thread for asgi (#3669) by @Zylphrex -- tests: Test with Falcon 4.0 (#3684) by @sentrivana -- fix(HTTP2Transport): Only enable HTTP2 when DSN is HTTPS (#3678) by @BYK -- fix(strawberry): prepare for upstream extension removal (#3649) by @DoctorJohn -- docs(sdk): Enhance README with improved clarity and developer-friendly examples (#3667) by @UTSAVS26 +- Correct typo in a comment (#3726) by @szokeasaurusrex +- End `http.client` span on timeout (#3723) by @Zylphrex +- Check for `h2` existence in HTTP/2 transport (#3690) by @BYK +- Use `type()` instead when extracting frames (#3716) by @Zylphrex +- Prefer `python_multipart` import over `multipart` (#3710) by @musicinmybrain +- Update active thread for asgi (#3669) by @Zylphrex +- Only enable HTTP2 when DSN is HTTPS (#3678) by @BYK +- Prepare for upstream Strawberry extension removal (#3649) by @DoctorJohn +- Enhance README with improved clarity and developer-friendly examples (#3667) by @UTSAVS26 +- Run license compliance action on all PRs (#3699) by @szokeasaurusrex +- Run CodeQL action on all PRs (#3698) by @szokeasaurusrex +- Fix UTC assuming test (#3722) by @BYK +- Exclude fakeredis 2.26.0 on py3.6 and 3.7 (#3695) by @szokeasaurusrex +- Unpin `pytest` for `tornado-latest` tests (#3714) by @szokeasaurusrex +- Install `pytest-asyncio` for `redis` tests (Python 3.12-13) (#3706) by @szokeasaurusrex +- Clarify that only pinned tests are required (#3713) by @szokeasaurusrex +- Remove accidentally-committed print (#3712) by @szokeasaurusrex +- Disable broken RQ test in newly-released RQ 2.0 (#3708) by @szokeasaurusrex +- Unpin `pytest` for `celery` tests (#3701) by @szokeasaurusrex +- Unpin `pytest` on Python 3.8+ `gevent` tests (#3700) by @szokeasaurusrex +- Unpin `pytest` for Python 3.8+ `common` tests (#3697) by @szokeasaurusrex +- Remove `pytest` pin in `requirements-devenv.txt` (#3696) by @szokeasaurusrex +- Test with Falcon 4.0 (#3684) by @sentrivana ## 2.17.0