From c6aff34f2d40806309e75e914494ba46566c4d08 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 20 Dec 2024 18:00:29 -0800 Subject: [PATCH 01/22] feat(flags): add Unleash feature flagging integration --- requirements-linting.txt | 1 + requirements-testing.txt | 1 + sentry_sdk/integrations/unleash.py | 41 ++++++++++++++++++++++++++++++ setup.py | 1 + tox.ini | 17 ++++++++++--- 5 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 sentry_sdk/integrations/unleash.py 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/requirements-testing.txt b/requirements-testing.txt index dfbd821845..1636429c9d 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -14,3 +14,4 @@ socksio httpcore[http2] setuptools Brotli +UnleashClient diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py new file mode 100644 index 0000000000..dd92ef554b --- /dev/null +++ b/sentry_sdk/integrations/unleash.py @@ -0,0 +1,41 @@ +from functools import wraps + +import sentry_sdk +from sentry_sdk.integrations import Integration + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Optional + from UnleashClient import UnleashClient + + +class UnleashIntegration(Integration): + identifier = "unleash" + + def __init__(self, unleash_client): + # type: (Optional[UnleashClient]) -> None + self.unleash_client = unleash_client + + @staticmethod + def setup_once(): + # type: () -> None + integration = sentry_sdk.get_client().get_integration(UnleashIntegration) + if integration is None: + return + + unleash_client = integration.unleash_client + old_is_enabled = unleash_client.is_enabled + + @wraps(old_is_enabled) + def sentry_is_enabled(self, *a, **kw): + # TODO: # type: + key, value = a[0], a[1] # TODO: this is a placeholder + if isinstance(value, bool): + flags = sentry_sdk.get_current_scope().flags + flags.set(key, value) + return old_is_enabled(self, *a, **kw) + + unleash_client.is_enabled = sentry_is_enabled # type: ignore + + # TODO: get_variant 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/tox.ini b/tox.ini index 717ea62141..559a8c6d2d 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 @@ -291,6 +291,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 @@ -572,6 +576,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 @@ -795,6 +803,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 d4c9b8192bec6efa9c663f0f275ce72d6fead69b Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 21:51:44 -0800 Subject: [PATCH 02/22] Finish class attr implementation and basic test --- requirements-testing.txt | 1 - sentry_sdk/integrations/unleash.py | 59 +++++++++++++++------- tests/integrations/unleash/__init__.py | 0 tests/integrations/unleash/test_unleash.py | 45 +++++++++++++++++ 4 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 tests/integrations/unleash/__init__.py create mode 100644 tests/integrations/unleash/test_unleash.py diff --git a/requirements-testing.txt b/requirements-testing.txt index 1636429c9d..dfbd821845 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -14,4 +14,3 @@ socksio httpcore[http2] setuptools Brotli -UnleashClient diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index dd92ef554b..d39b663bc6 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -1,41 +1,66 @@ from functools import wraps import sentry_sdk -from sentry_sdk.integrations import Integration +from sentry_sdk.flag_utils import flag_error_processor +from sentry_sdk.integrations import Integration, DidNotEnable from typing import TYPE_CHECKING + if TYPE_CHECKING: - from typing import Optional + from typing import Optional, Any from UnleashClient import UnleashClient class UnleashIntegration(Integration): identifier = "unleash" + _unleash_client = None # type: Optional[UnleashClient] def __init__(self, unleash_client): - # type: (Optional[UnleashClient]) -> None - self.unleash_client = unleash_client + # type: (UnleashClient) -> None + self.__class__._unleash_client = unleash_client @staticmethod def setup_once(): # type: () -> None - integration = sentry_sdk.get_client().get_integration(UnleashIntegration) - if integration is None: - return - unleash_client = integration.unleash_client - old_is_enabled = unleash_client.is_enabled + # Wrap and patch evaluation functions + client = UnleashIntegration._unleash_client + if not client: + raise DidNotEnable("Error finding UnleashClient instance.") + + # Instance methods (self is excluded) + old_is_enabled = client.is_enabled + old_get_variant = client.get_variant @wraps(old_is_enabled) - def sentry_is_enabled(self, *a, **kw): - # TODO: # type: - key, value = a[0], a[1] # TODO: this is a placeholder - if isinstance(value, bool): + def sentry_is_enabled(feature, *a, **kw): + # type: (str, *Any, **Any) -> Any + enabled = old_is_enabled(feature, *a, **kw) + + # We have no way of knowing what type of feature this is. Have to treat it as + # boolean flag. TODO: Unless we fetch a list of non-bool flags on startup.. + flags = sentry_sdk.get_current_scope().flags + flags.set(feature, enabled) + + return enabled + + @wraps(old_get_variant) + def sentry_get_variant(feature, *a, **kw): + # type: (str, *Any, **Any) -> Any + variant = old_get_variant(feature, *a, **kw) + enabled = variant.get("enabled", False) + payload_type = variant.get("payload", {}).get("type") + + if payload_type is None or payload_type == "boolean": flags = sentry_sdk.get_current_scope().flags - flags.set(key, value) - return old_is_enabled(self, *a, **kw) + flags.set(feature, enabled) + + return variant - unleash_client.is_enabled = sentry_is_enabled # type: ignore + client.is_enabled = sentry_is_enabled # type: ignore + client.get_variant = sentry_get_variant # type: ignore - # TODO: get_variant + # Error processor + scope = sentry_sdk.get_current_scope() + scope.add_error_processor(flag_error_processor) diff --git a/tests/integrations/unleash/__init__.py b/tests/integrations/unleash/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py new file mode 100644 index 0000000000..eaed16d808 --- /dev/null +++ b/tests/integrations/unleash/test_unleash.py @@ -0,0 +1,45 @@ +from unittest.mock import Mock + +import sentry_sdk +from sentry_sdk.integrations.unleash import UnleashIntegration + +import pytest + + +@pytest.fixture +def mock_unleash_client(): + features = { + "hello": True, + "world": False, + } + + def is_enabled(feature: str, *a, **kw) -> bool: + return features.get(feature, False) + + client = Mock() + client.is_enabled = is_enabled + return client + + +def test_unleash_integration( + sentry_init, capture_events, uninstall_integration, mock_unleash_client +): + uninstall_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) + + # Evaluate + mock_unleash_client.is_enabled("hello") + mock_unleash_client.is_enabled("world") + mock_unleash_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}, + ] + } From e97418800adaf8db018a1e0b3d8f06d0ac0415d7 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 22:04:53 -0800 Subject: [PATCH 03/22] Test get_variant --- sentry_sdk/integrations/unleash.py | 2 +- tests/integrations/unleash/test_unleash.py | 47 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index d39b663bc6..4f7eb78dcc 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -52,7 +52,7 @@ def sentry_get_variant(feature, *a, **kw): enabled = variant.get("enabled", False) payload_type = variant.get("payload", {}).get("type") - if payload_type is None or payload_type == "boolean": + if payload_type is None: flags = sentry_sdk.get_current_scope().flags flags.set(feature, enabled) diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index eaed16d808..3598b0f848 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -1,3 +1,4 @@ +from typing import Any from unittest.mock import Mock import sentry_sdk @@ -16,18 +17,37 @@ def mock_unleash_client(): def is_enabled(feature: str, *a, **kw) -> bool: return features.get(feature, False) + feature_to_variant = { + "str_feature": { + "name": "variant1", + "enabled": True, + "payload": {"type": "string", "value": "val1"}, + }, + "json_feature": { + "name": "variant1", + "enabled": True, + "payload": {"type": "json", "value": {"key1": 0.53}}, + }, + "toggle_feature": {"name": "variant1", "enabled": True}, + } + + disabled_variant = {"name": "disabled", "enabled": False} + + def get_variant(feature: str, *a, **kw) -> dict[str, Any]: + return feature_to_variant.get(feature, disabled_variant) + client = Mock() client.is_enabled = is_enabled + client.get_variant = get_variant return client -def test_unleash_integration( +def test_is_enabled( sentry_init, capture_events, uninstall_integration, mock_unleash_client ): uninstall_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) - # Evaluate mock_unleash_client.is_enabled("hello") mock_unleash_client.is_enabled("world") mock_unleash_client.is_enabled("other") @@ -43,3 +63,26 @@ def test_unleash_integration( {"flag": "other", "result": False}, ] } + + +def test_get_variant( + sentry_init, capture_events, uninstall_integration, mock_unleash_client +): + uninstall_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) + + mock_unleash_client.get_variant("toggle_feature") + mock_unleash_client.get_variant("str_feature") + mock_unleash_client.get_variant("json_feature") + mock_unleash_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": "toggle_feature", "result": True}, + {"flag": "unknown_feature", "result": False}, + ] + } From 3b78bd65b11fd8e9ea8110eec57c2bf4525544fb Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 22:14:29 -0800 Subject: [PATCH 04/22] Test additional variant payload types and split up to 1 file per method --- ....py => test_unleash_get_variant_method.py} | 53 ++++++------------- .../unleash/test_unleash_is_enabled_method.py | 43 +++++++++++++++ 2 files changed, 59 insertions(+), 37 deletions(-) rename tests/integrations/unleash/{test_unleash.py => test_unleash_get_variant_method.py} (58%) create mode 100644 tests/integrations/unleash/test_unleash_is_enabled_method.py diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash_get_variant_method.py similarity index 58% rename from tests/integrations/unleash/test_unleash.py rename to tests/integrations/unleash/test_unleash_get_variant_method.py index 3598b0f848..ba7a71780a 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash_get_variant_method.py @@ -1,24 +1,15 @@ +import pytest from typing import Any from unittest.mock import Mock import sentry_sdk from sentry_sdk.integrations.unleash import UnleashIntegration -import pytest - @pytest.fixture def mock_unleash_client(): - features = { - "hello": True, - "world": False, - } - - def is_enabled(feature: str, *a, **kw) -> bool: - return features.get(feature, False) - feature_to_variant = { - "str_feature": { + "string_feature": { "name": "variant1", "enabled": True, "payload": {"type": "string", "value": "val1"}, @@ -26,7 +17,17 @@ def is_enabled(feature: str, *a, **kw) -> bool: "json_feature": { "name": "variant1", "enabled": True, - "payload": {"type": "json", "value": {"key1": 0.53}}, + "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"}, }, "toggle_feature": {"name": "variant1", "enabled": True}, } @@ -37,34 +38,10 @@ def get_variant(feature: str, *a, **kw) -> dict[str, Any]: return feature_to_variant.get(feature, disabled_variant) client = Mock() - client.is_enabled = is_enabled client.get_variant = get_variant return client -def test_is_enabled( - sentry_init, capture_events, uninstall_integration, mock_unleash_client -): - uninstall_integration(UnleashIntegration.identifier) - sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) - - mock_unleash_client.is_enabled("hello") - mock_unleash_client.is_enabled("world") - mock_unleash_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, mock_unleash_client ): @@ -72,8 +49,10 @@ def test_get_variant( sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) mock_unleash_client.get_variant("toggle_feature") - mock_unleash_client.get_variant("str_feature") + mock_unleash_client.get_variant("string_feature") mock_unleash_client.get_variant("json_feature") + mock_unleash_client.get_variant("csv_feature") + mock_unleash_client.get_variant("number_feature") mock_unleash_client.get_variant("unknown_feature") events = capture_events() diff --git a/tests/integrations/unleash/test_unleash_is_enabled_method.py b/tests/integrations/unleash/test_unleash_is_enabled_method.py new file mode 100644 index 0000000000..ac57dc3e41 --- /dev/null +++ b/tests/integrations/unleash/test_unleash_is_enabled_method.py @@ -0,0 +1,43 @@ +import pytest +from unittest.mock import Mock + +import sentry_sdk +from sentry_sdk.integrations.unleash import UnleashIntegration + + +@pytest.fixture +def mock_unleash_client(): + features = { + "hello": True, + "world": False, + } + + def is_enabled(feature: str, *a, **kw) -> bool: + return features.get(feature, False) + + client = Mock() + client.is_enabled = is_enabled + return client + + +def test_is_enabled( + sentry_init, capture_events, uninstall_integration, mock_unleash_client +): + uninstall_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) + + mock_unleash_client.is_enabled("hello") + mock_unleash_client.is_enabled("world") + mock_unleash_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}, + ] + } From cf0be41ba74dda055bbee041bb5ed12b84b047e5 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 22:15:54 -0800 Subject: [PATCH 05/22] Add to split_tox_gh_actions.py --- scripts/split_tox_gh_actions/split_tox_gh_actions.py | 1 + 1 file changed, 1 insertion(+) 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", ], } From da2a21eb4f6654cac990cc996fc597b685709611 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 22:18:09 -0800 Subject: [PATCH 06/22] Update gh workflow config by running split_tox_gh_actions --- .github/workflows/test-integrations-misc.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 1a4e910383..a14a8f5f0a 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: | From 2ef2f7d05e5bb9ccd4cb82e09a6921db6ff09e91 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 22:56:22 -0800 Subject: [PATCH 07/22] Rename tests and rm test type annotations --- ...leash_get_variant_method.py => test_unleash_get_variant.py} | 3 +-- ...unleash_is_enabled_method.py => test_unleash_is_enabled.py} | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) rename tests/integrations/unleash/{test_unleash_get_variant_method.py => test_unleash_get_variant.py} (95%) rename tests/integrations/unleash/{test_unleash_is_enabled_method.py => test_unleash_is_enabled.py} (95%) diff --git a/tests/integrations/unleash/test_unleash_get_variant_method.py b/tests/integrations/unleash/test_unleash_get_variant.py similarity index 95% rename from tests/integrations/unleash/test_unleash_get_variant_method.py rename to tests/integrations/unleash/test_unleash_get_variant.py index ba7a71780a..5f992187f1 100644 --- a/tests/integrations/unleash/test_unleash_get_variant_method.py +++ b/tests/integrations/unleash/test_unleash_get_variant.py @@ -1,5 +1,4 @@ import pytest -from typing import Any from unittest.mock import Mock import sentry_sdk @@ -34,7 +33,7 @@ def mock_unleash_client(): disabled_variant = {"name": "disabled", "enabled": False} - def get_variant(feature: str, *a, **kw) -> dict[str, Any]: + def get_variant(feature, *a, **kw): return feature_to_variant.get(feature, disabled_variant) client = Mock() diff --git a/tests/integrations/unleash/test_unleash_is_enabled_method.py b/tests/integrations/unleash/test_unleash_is_enabled.py similarity index 95% rename from tests/integrations/unleash/test_unleash_is_enabled_method.py rename to tests/integrations/unleash/test_unleash_is_enabled.py index ac57dc3e41..004f5b81f2 100644 --- a/tests/integrations/unleash/test_unleash_is_enabled_method.py +++ b/tests/integrations/unleash/test_unleash_is_enabled.py @@ -12,7 +12,7 @@ def mock_unleash_client(): "world": False, } - def is_enabled(feature: str, *a, **kw) -> bool: + def is_enabled(feature, *a, **kw): return features.get(feature, False) client = Mock() From 2a443b9cd0fd42fa37976cdfd1b5679d39f47cc4 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:12:08 -0800 Subject: [PATCH 08/22] Patch class method instead of instance, so passing in client is not needed. Ref tests back to 1 file --- sentry_sdk/integrations/unleash.py | 39 ++++------- tests/integrations/unleash/__init__.py | 39 +++++++++++ tests/integrations/unleash/test_unleash.py | 54 +++++++++++++++ .../unleash/test_unleash_get_variant.py | 66 ------------------- .../unleash/test_unleash_is_enabled.py | 43 ------------ 5 files changed, 107 insertions(+), 134 deletions(-) create mode 100644 tests/integrations/unleash/test_unleash.py delete mode 100644 tests/integrations/unleash/test_unleash_get_variant.py delete mode 100644 tests/integrations/unleash/test_unleash_is_enabled.py diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 4f7eb78dcc..87ac9d6395 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -1,42 +1,31 @@ from functools import wraps +from typing import TYPE_CHECKING import sentry_sdk from sentry_sdk.flag_utils import flag_error_processor -from sentry_sdk.integrations import Integration, DidNotEnable - -from typing import TYPE_CHECKING +from sentry_sdk.integrations import Integration +from UnleashClient import UnleashClient if TYPE_CHECKING: - from typing import Optional, Any - from UnleashClient import UnleashClient + from typing import Any class UnleashIntegration(Integration): identifier = "unleash" - _unleash_client = None # type: Optional[UnleashClient] - - def __init__(self, unleash_client): - # type: (UnleashClient) -> None - self.__class__._unleash_client = unleash_client @staticmethod def setup_once(): # type: () -> None # Wrap and patch evaluation functions - client = UnleashIntegration._unleash_client - if not client: - raise DidNotEnable("Error finding UnleashClient instance.") - - # Instance methods (self is excluded) - old_is_enabled = client.is_enabled - old_get_variant = client.get_variant + old_is_enabled = UnleashClient.is_enabled + old_get_variant = UnleashClient.get_variant @wraps(old_is_enabled) - def sentry_is_enabled(feature, *a, **kw): - # type: (str, *Any, **Any) -> Any - enabled = old_is_enabled(feature, *a, **kw) + def sentry_is_enabled(self, feature, *a, **kw): + # type: (UnleashClient, str, *Any, **Any) -> Any + enabled = old_is_enabled(self, feature, *a, **kw) # We have no way of knowing what type of feature this is. Have to treat it as # boolean flag. TODO: Unless we fetch a list of non-bool flags on startup.. @@ -46,9 +35,9 @@ def sentry_is_enabled(feature, *a, **kw): return enabled @wraps(old_get_variant) - def sentry_get_variant(feature, *a, **kw): - # type: (str, *Any, **Any) -> Any - variant = old_get_variant(feature, *a, **kw) + def sentry_get_variant(self, feature, *a, **kw): + # type: (UnleashClient, str, *Any, **Any) -> Any + variant = old_get_variant(self, feature, *a, **kw) enabled = variant.get("enabled", False) payload_type = variant.get("payload", {}).get("type") @@ -58,8 +47,8 @@ def sentry_get_variant(feature, *a, **kw): return variant - client.is_enabled = sentry_is_enabled # type: ignore - client.get_variant = sentry_get_variant # type: ignore + UnleashClient.is_enabled = sentry_is_enabled # type: ignore + UnleashClient.get_variant = sentry_get_variant # type: ignore # Error processor scope = sentry_sdk.get_current_scope() diff --git a/tests/integrations/unleash/__init__.py b/tests/integrations/unleash/__init__.py index e69de29bb2..582105f3f3 100644 --- a/tests/integrations/unleash/__init__.py +++ b/tests/integrations/unleash/__init__.py @@ -0,0 +1,39 @@ +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"}, + }, + "toggle_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/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py new file mode 100644 index 0000000000..08b18fd8d7 --- /dev/null +++ b/tests/integrations/unleash/test_unleash.py @@ -0,0 +1,54 @@ +from unittest.mock import patch + +import sentry_sdk +from sentry_sdk.integrations.unleash import UnleashIntegration +from tests.integrations.unleash import MockUnleashClient + + +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) +def test_is_enabled(sentry_init, capture_events, uninstall_integration): + mock_unleash_client = MockUnleashClient() + + uninstall_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration()]) + + mock_unleash_client.is_enabled("hello") + mock_unleash_client.is_enabled("world") + mock_unleash_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}, + ] + } + + +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) +def test_get_variant(sentry_init, capture_events, uninstall_integration): + mock_unleash_client = MockUnleashClient() + uninstall_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration()]) + + mock_unleash_client.get_variant("toggle_feature") + mock_unleash_client.get_variant("string_feature") + mock_unleash_client.get_variant("json_feature") + mock_unleash_client.get_variant("csv_feature") + mock_unleash_client.get_variant("number_feature") + mock_unleash_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": "toggle_feature", "result": True}, + {"flag": "unknown_feature", "result": False}, + ] + } diff --git a/tests/integrations/unleash/test_unleash_get_variant.py b/tests/integrations/unleash/test_unleash_get_variant.py deleted file mode 100644 index 5f992187f1..0000000000 --- a/tests/integrations/unleash/test_unleash_get_variant.py +++ /dev/null @@ -1,66 +0,0 @@ -import pytest -from unittest.mock import Mock - -import sentry_sdk -from sentry_sdk.integrations.unleash import UnleashIntegration - - -@pytest.fixture -def mock_unleash_client(): - 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"}, - }, - "toggle_feature": {"name": "variant1", "enabled": True}, - } - - disabled_variant = {"name": "disabled", "enabled": False} - - def get_variant(feature, *a, **kw): - return feature_to_variant.get(feature, disabled_variant) - - client = Mock() - client.get_variant = get_variant - return client - - -def test_get_variant( - sentry_init, capture_events, uninstall_integration, mock_unleash_client -): - uninstall_integration(UnleashIntegration.identifier) - sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) - - mock_unleash_client.get_variant("toggle_feature") - mock_unleash_client.get_variant("string_feature") - mock_unleash_client.get_variant("json_feature") - mock_unleash_client.get_variant("csv_feature") - mock_unleash_client.get_variant("number_feature") - mock_unleash_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": "toggle_feature", "result": True}, - {"flag": "unknown_feature", "result": False}, - ] - } diff --git a/tests/integrations/unleash/test_unleash_is_enabled.py b/tests/integrations/unleash/test_unleash_is_enabled.py deleted file mode 100644 index 004f5b81f2..0000000000 --- a/tests/integrations/unleash/test_unleash_is_enabled.py +++ /dev/null @@ -1,43 +0,0 @@ -import pytest -from unittest.mock import Mock - -import sentry_sdk -from sentry_sdk.integrations.unleash import UnleashIntegration - - -@pytest.fixture -def mock_unleash_client(): - features = { - "hello": True, - "world": False, - } - - def is_enabled(feature, *a, **kw): - return features.get(feature, False) - - client = Mock() - client.is_enabled = is_enabled - return client - - -def test_is_enabled( - sentry_init, capture_events, uninstall_integration, mock_unleash_client -): - uninstall_integration(UnleashIntegration.identifier) - sentry_init(integrations=[UnleashIntegration(mock_unleash_client)]) - - mock_unleash_client.is_enabled("hello") - mock_unleash_client.is_enabled("world") - mock_unleash_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}, - ] - } From 0e638b9887166a9f9e75e673aefe4e521830a7d4 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:17:29 -0800 Subject: [PATCH 09/22] Rename uninstall_integration --- tests/conftest.py | 2 +- tests/integrations/featureflags/test_featureflags.py | 12 ++++++------ tests/integrations/launchdarkly/test_launchdarkly.py | 12 ++++++------ tests/integrations/openfeature/test_openfeature.py | 12 ++++++------ tests/integrations/unleash/test_unleash.py | 9 ++++----- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c0383d94b7..44ba48c681 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -185,7 +185,7 @@ def reset_integrations(): @pytest.fixture -def uninstall_integration(): +def reset_integration(): """Use to force the next call to sentry_init to re-install/setup an integration.""" def inner(identifier): diff --git a/tests/integrations/featureflags/test_featureflags.py b/tests/integrations/featureflags/test_featureflags.py index 539e910607..137e2268f3 100644 --- a/tests/integrations/featureflags/test_featureflags.py +++ b/tests/integrations/featureflags/test_featureflags.py @@ -10,8 +10,8 @@ ) -def test_featureflags_integration(sentry_init, capture_events, uninstall_integration): - uninstall_integration(FeatureFlagsIntegration.identifier) +def test_featureflags_integration(sentry_init, capture_events, reset_integration): + reset_integration(FeatureFlagsIntegration.identifier) sentry_init(integrations=[FeatureFlagsIntegration()]) add_feature_flag("hello", False) @@ -32,9 +32,9 @@ def test_featureflags_integration(sentry_init, capture_events, uninstall_integra def test_featureflags_integration_threaded( - sentry_init, capture_events, uninstall_integration + sentry_init, capture_events, reset_integration ): - uninstall_integration(FeatureFlagsIntegration.identifier) + reset_integration(FeatureFlagsIntegration.identifier) sentry_init(integrations=[FeatureFlagsIntegration()]) events = capture_events() @@ -82,11 +82,11 @@ def task(flag_key): @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 + sentry_init, capture_events, reset_integration ): asyncio = pytest.importorskip("asyncio") - uninstall_integration(FeatureFlagsIntegration.identifier) + reset_integration(FeatureFlagsIntegration.identifier) sentry_init(integrations=[FeatureFlagsIntegration()]) events = capture_events() diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index f66a4219ec..d9f12215bf 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -19,12 +19,12 @@ (False, True), ) def test_launchdarkly_integration( - sentry_init, use_global_client, capture_events, uninstall_integration + sentry_init, use_global_client, capture_events, reset_integration ): td = TestData.data_source() config = Config("sdk-key", update_processor_class=td) - uninstall_integration(LaunchDarklyIntegration.identifier) + reset_integration(LaunchDarklyIntegration.identifier) if use_global_client: ldclient.set_config(config) sentry_init(integrations=[LaunchDarklyIntegration()]) @@ -56,13 +56,13 @@ def test_launchdarkly_integration( def test_launchdarkly_integration_threaded( - sentry_init, capture_events, uninstall_integration + sentry_init, capture_events, reset_integration ): td = TestData.data_source() client = LDClient(config=Config("sdk-key", update_processor_class=td)) context = Context.create("user1") - uninstall_integration(LaunchDarklyIntegration.identifier) + reset_integration(LaunchDarklyIntegration.identifier) sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) events = capture_events() @@ -111,7 +111,7 @@ def task(flag_key): @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 + sentry_init, capture_events, reset_integration ): """Assert concurrently evaluated flags do not pollute one another.""" @@ -121,7 +121,7 @@ def test_launchdarkly_integration_asyncio( client = LDClient(config=Config("sdk-key", update_processor_class=td)) context = Context.create("user1") - uninstall_integration(LaunchDarklyIntegration.identifier) + reset_integration(LaunchDarklyIntegration.identifier) sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) events = capture_events() diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py index c180211c3f..2c68b55ad4 100644 --- a/tests/integrations/openfeature/test_openfeature.py +++ b/tests/integrations/openfeature/test_openfeature.py @@ -10,8 +10,8 @@ from sentry_sdk.integrations.openfeature import OpenFeatureIntegration -def test_openfeature_integration(sentry_init, capture_events, uninstall_integration): - uninstall_integration(OpenFeatureIntegration.identifier) +def test_openfeature_integration(sentry_init, capture_events, reset_integration): + reset_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) flags = { @@ -39,9 +39,9 @@ def test_openfeature_integration(sentry_init, capture_events, uninstall_integrat def test_openfeature_integration_threaded( - sentry_init, capture_events, uninstall_integration + sentry_init, capture_events, reset_integration ): - uninstall_integration(OpenFeatureIntegration.identifier) + reset_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) events = capture_events() @@ -95,13 +95,13 @@ def task(flag): @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 + sentry_init, capture_events, reset_integration ): """Assert concurrently evaluated flags do not pollute one another.""" asyncio = pytest.importorskip("asyncio") - uninstall_integration(OpenFeatureIntegration.identifier) + reset_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) events = capture_events() diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 08b18fd8d7..5564b81b0d 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -6,10 +6,9 @@ @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) -def test_is_enabled(sentry_init, capture_events, uninstall_integration): +def test_is_enabled(sentry_init, capture_events, reset_integration): mock_unleash_client = MockUnleashClient() - - uninstall_integration(UnleashIntegration.identifier) + reset_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) mock_unleash_client.is_enabled("hello") @@ -30,9 +29,9 @@ def test_is_enabled(sentry_init, capture_events, uninstall_integration): @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) -def test_get_variant(sentry_init, capture_events, uninstall_integration): +def test_get_variant(sentry_init, capture_events, reset_integration): mock_unleash_client = MockUnleashClient() - uninstall_integration(UnleashIntegration.identifier) + reset_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) mock_unleash_client.get_variant("toggle_feature") From 08f4e3dff42bb28f4e3486ae19f5c0ef33098ece Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:22:47 -0800 Subject: [PATCH 10/22] test wrapper attributes --- tests/integrations/unleash/test_unleash.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 5564b81b0d..10c556a6a1 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -4,6 +4,9 @@ from sentry_sdk.integrations.unleash import UnleashIntegration from tests.integrations.unleash import MockUnleashClient +original_is_enabled = MockUnleashClient.is_enabled +original_get_variant = MockUnleashClient.get_variant + @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_is_enabled(sentry_init, capture_events, reset_integration): @@ -51,3 +54,21 @@ def test_get_variant(sentry_init, capture_events, reset_integration): {"flag": "unknown_feature", "result": False}, ] } + + +def test_wrapper_attributes(sentry_init, reset_integration): + reset_integration(UnleashIntegration.identifier) + sentry_init(integrations=[UnleashIntegration()]) + + client = MockUnleashClient() + assert client.is_enabled.__name__ == "is_enabled" + assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__ + assert MockUnleashClient.is_enabled.__name__ == "is_enabled" + assert MockUnleashClient.is_enabled.__qualname__ == original_is_enabled.__qualname__ + + assert client.get_variant.__name__ == "get_variant" + assert client.get_variant.__qualname__ == original_get_variant.__qualname__ + assert MockUnleashClient.get_variant.__name__ == "get_variant" + assert ( + MockUnleashClient.get_variant.__qualname__ == original_get_variant.__qualname__ + ) From 4ee3667828c77d8d7135c9524425919954a1f8e4 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:24:18 -0800 Subject: [PATCH 11/22] importorskip in test module --- tests/integrations/unleash/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integrations/unleash/__init__.py b/tests/integrations/unleash/__init__.py index 582105f3f3..86fef08774 100644 --- a/tests/integrations/unleash/__init__.py +++ b/tests/integrations/unleash/__init__.py @@ -1,3 +1,8 @@ +import pytest + +pytest.importorskip("UnleashClient") + + class MockUnleashClient: def __init__(self, *a, **kw): From 38f279b43525cd0a597e6a9291a95ea95c693c6f Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sat, 21 Dec 2024 23:27:03 -0800 Subject: [PATCH 12/22] handle not installed import error --- sentry_sdk/integrations/unleash.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 87ac9d6395..98ec51939f 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -3,9 +3,12 @@ import sentry_sdk from sentry_sdk.flag_utils import flag_error_processor -from sentry_sdk.integrations import Integration +from sentry_sdk.integrations import Integration, DidNotEnable -from UnleashClient import UnleashClient +try: + from UnleashClient import UnleashClient +except ImportError: + raise DidNotEnable("UnleashClient is not installed") if TYPE_CHECKING: from typing import Any From da54e4981cb31e1d6e76071922144de717d4e738 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 22 Dec 2024 17:05:47 -0800 Subject: [PATCH 13/22] wraps_original test --- tests/integrations/unleash/__init__.py | 41 ---------------- tests/integrations/unleash/test_unleash.py | 56 +++++++++++++++++----- tests/integrations/unleash/testutils.py | 39 +++++++++++++++ 3 files changed, 83 insertions(+), 53 deletions(-) create mode 100644 tests/integrations/unleash/testutils.py diff --git a/tests/integrations/unleash/__init__.py b/tests/integrations/unleash/__init__.py index 86fef08774..33cff3e65a 100644 --- a/tests/integrations/unleash/__init__.py +++ b/tests/integrations/unleash/__init__.py @@ -1,44 +1,3 @@ import pytest pytest.importorskip("UnleashClient") - - -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"}, - }, - "toggle_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/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 10c556a6a1..2608f46fd5 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -1,8 +1,9 @@ +from random import random from unittest.mock import patch import sentry_sdk from sentry_sdk.integrations.unleash import UnleashIntegration -from tests.integrations.unleash import MockUnleashClient +from tests.integrations.unleash.testutils import MockUnleashClient original_is_enabled = MockUnleashClient.is_enabled original_get_variant = MockUnleashClient.get_variant @@ -10,13 +11,13 @@ @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_is_enabled(sentry_init, capture_events, reset_integration): - mock_unleash_client = MockUnleashClient() + client = MockUnleashClient() reset_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) - mock_unleash_client.is_enabled("hello") - mock_unleash_client.is_enabled("world") - mock_unleash_client.is_enabled("other") + client.is_enabled("hello") + client.is_enabled("world") + client.is_enabled("other") events = capture_events() sentry_sdk.capture_exception(Exception("something wrong!")) @@ -33,16 +34,16 @@ def test_is_enabled(sentry_init, capture_events, reset_integration): @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_get_variant(sentry_init, capture_events, reset_integration): - mock_unleash_client = MockUnleashClient() + client = MockUnleashClient() reset_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) - mock_unleash_client.get_variant("toggle_feature") - mock_unleash_client.get_variant("string_feature") - mock_unleash_client.get_variant("json_feature") - mock_unleash_client.get_variant("csv_feature") - mock_unleash_client.get_variant("number_feature") - mock_unleash_client.get_variant("unknown_feature") + client.get_variant("toggle_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!")) @@ -56,6 +57,37 @@ def test_get_variant(sentry_init, capture_events, reset_integration): } +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) +def test_wraps_original(sentry_init, reset_integration): + reset_integration(UnleashIntegration.identifier) + + with patch( + "sentry_sdk.integrations.unleash.UnleashClient.is_enabled" + ) as mock_is_enabled: + with patch( + "sentry_sdk.integrations.unleash.UnleashClient.get_variant" + ) as mock_get_variant: + mock_is_enabled.return_value = random() < 0.5 + mock_get_variant.return_value = {"enabled": random() < 0.5} + sentry_init(integrations=[UnleashIntegration()]) + client = MockUnleashClient() + + res = client.is_enabled("test-flag", "arg", kwarg=1) + assert res == mock_is_enabled.return_value + assert mock_is_enabled.call_args == ( + (client, "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 == ( + (client, "test-flag", "arg"), + {"kwarg": 1}, + ) + + +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_wrapper_attributes(sentry_init, reset_integration): reset_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) diff --git a/tests/integrations/unleash/testutils.py b/tests/integrations/unleash/testutils.py new file mode 100644 index 0000000000..582105f3f3 --- /dev/null +++ b/tests/integrations/unleash/testutils.py @@ -0,0 +1,39 @@ +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"}, + }, + "toggle_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 0538bce388466ad76e857bb734da93fe77bcc3c7 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 22 Dec 2024 17:16:52 -0800 Subject: [PATCH 14/22] ref uninstall_integration fixture --- tests/conftest.py | 15 ++++++++++++--- .../featureflags/test_featureflags.py | 12 ++++++------ .../launchdarkly/test_launchdarkly.py | 12 ++++++------ .../integrations/openfeature/test_openfeature.py | 12 ++++++------ tests/integrations/unleash/test_unleash.py | 16 ++++++++-------- 5 files changed, 38 insertions(+), 29 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 44ba48c681..230d2e317d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import pytest import jsonschema + try: import gevent except ImportError: @@ -27,6 +28,7 @@ _DEFAULT_INTEGRATIONS, _installed_integrations, _processed_integrations, + Integration, ) from sentry_sdk.profiler import teardown_profiler from sentry_sdk.profiler.continuous_profiler import teardown_continuous_profiler @@ -185,10 +187,17 @@ def reset_integrations(): @pytest.fixture -def reset_integration(): - """Use to force the next call to sentry_init to re-install/setup an integration.""" +def uninstall_integration(): + """ + Forces the next call to sentry_init to re-install an integration and call `setup_once`. + No effect if the integration is not installed. + """ - def inner(identifier): + def inner(name_or_cls): + if isinstance(name_or_cls, str): + identifier = name_or_cls + else: + identifier = name_or_cls.identifier _processed_integrations.discard(identifier) _installed_integrations.discard(identifier) diff --git a/tests/integrations/featureflags/test_featureflags.py b/tests/integrations/featureflags/test_featureflags.py index 137e2268f3..539e910607 100644 --- a/tests/integrations/featureflags/test_featureflags.py +++ b/tests/integrations/featureflags/test_featureflags.py @@ -10,8 +10,8 @@ ) -def test_featureflags_integration(sentry_init, capture_events, reset_integration): - reset_integration(FeatureFlagsIntegration.identifier) +def test_featureflags_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(FeatureFlagsIntegration.identifier) sentry_init(integrations=[FeatureFlagsIntegration()]) add_feature_flag("hello", False) @@ -32,9 +32,9 @@ def test_featureflags_integration(sentry_init, capture_events, reset_integration def test_featureflags_integration_threaded( - sentry_init, capture_events, reset_integration + sentry_init, capture_events, uninstall_integration ): - reset_integration(FeatureFlagsIntegration.identifier) + uninstall_integration(FeatureFlagsIntegration.identifier) sentry_init(integrations=[FeatureFlagsIntegration()]) events = capture_events() @@ -82,11 +82,11 @@ def task(flag_key): @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") def test_featureflags_integration_asyncio( - sentry_init, capture_events, reset_integration + sentry_init, capture_events, uninstall_integration ): asyncio = pytest.importorskip("asyncio") - reset_integration(FeatureFlagsIntegration.identifier) + uninstall_integration(FeatureFlagsIntegration.identifier) sentry_init(integrations=[FeatureFlagsIntegration()]) events = capture_events() diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index d9f12215bf..f66a4219ec 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -19,12 +19,12 @@ (False, True), ) def test_launchdarkly_integration( - sentry_init, use_global_client, capture_events, reset_integration + sentry_init, use_global_client, capture_events, uninstall_integration ): td = TestData.data_source() config = Config("sdk-key", update_processor_class=td) - reset_integration(LaunchDarklyIntegration.identifier) + uninstall_integration(LaunchDarklyIntegration.identifier) if use_global_client: ldclient.set_config(config) sentry_init(integrations=[LaunchDarklyIntegration()]) @@ -56,13 +56,13 @@ def test_launchdarkly_integration( def test_launchdarkly_integration_threaded( - sentry_init, capture_events, reset_integration + sentry_init, capture_events, uninstall_integration ): td = TestData.data_source() client = LDClient(config=Config("sdk-key", update_processor_class=td)) context = Context.create("user1") - reset_integration(LaunchDarklyIntegration.identifier) + uninstall_integration(LaunchDarklyIntegration.identifier) sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) events = capture_events() @@ -111,7 +111,7 @@ def task(flag_key): @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") def test_launchdarkly_integration_asyncio( - sentry_init, capture_events, reset_integration + sentry_init, capture_events, uninstall_integration ): """Assert concurrently evaluated flags do not pollute one another.""" @@ -121,7 +121,7 @@ def test_launchdarkly_integration_asyncio( client = LDClient(config=Config("sdk-key", update_processor_class=td)) context = Context.create("user1") - reset_integration(LaunchDarklyIntegration.identifier) + uninstall_integration(LaunchDarklyIntegration.identifier) sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) events = capture_events() diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py index 2c68b55ad4..c180211c3f 100644 --- a/tests/integrations/openfeature/test_openfeature.py +++ b/tests/integrations/openfeature/test_openfeature.py @@ -10,8 +10,8 @@ from sentry_sdk.integrations.openfeature import OpenFeatureIntegration -def test_openfeature_integration(sentry_init, capture_events, reset_integration): - reset_integration(OpenFeatureIntegration.identifier) +def test_openfeature_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) flags = { @@ -39,9 +39,9 @@ def test_openfeature_integration(sentry_init, capture_events, reset_integration) def test_openfeature_integration_threaded( - sentry_init, capture_events, reset_integration + sentry_init, capture_events, uninstall_integration ): - reset_integration(OpenFeatureIntegration.identifier) + uninstall_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) events = capture_events() @@ -95,13 +95,13 @@ def task(flag): @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") def test_openfeature_integration_asyncio( - sentry_init, capture_events, reset_integration + sentry_init, capture_events, uninstall_integration ): """Assert concurrently evaluated flags do not pollute one another.""" asyncio = pytest.importorskip("asyncio") - reset_integration(OpenFeatureIntegration.identifier) + uninstall_integration(OpenFeatureIntegration.identifier) sentry_init(integrations=[OpenFeatureIntegration()]) events = capture_events() diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 2608f46fd5..221a0fbfdd 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -10,9 +10,9 @@ @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) -def test_is_enabled(sentry_init, capture_events, reset_integration): +def test_is_enabled(sentry_init, capture_events, uninstall_integration): client = MockUnleashClient() - reset_integration(UnleashIntegration.identifier) + uninstall_integration(UnleashIntegration) sentry_init(integrations=[UnleashIntegration()]) client.is_enabled("hello") @@ -33,9 +33,9 @@ def test_is_enabled(sentry_init, capture_events, reset_integration): @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) -def test_get_variant(sentry_init, capture_events, reset_integration): +def test_get_variant(sentry_init, capture_events, uninstall_integration): client = MockUnleashClient() - reset_integration(UnleashIntegration.identifier) + uninstall_integration(UnleashIntegration) sentry_init(integrations=[UnleashIntegration()]) client.get_variant("toggle_feature") @@ -58,8 +58,8 @@ def test_get_variant(sentry_init, capture_events, reset_integration): @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) -def test_wraps_original(sentry_init, reset_integration): - reset_integration(UnleashIntegration.identifier) +def test_wraps_original(sentry_init, uninstall_integration): + uninstall_integration(UnleashIntegration) with patch( "sentry_sdk.integrations.unleash.UnleashClient.is_enabled" @@ -88,8 +88,8 @@ def test_wraps_original(sentry_init, reset_integration): @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) -def test_wrapper_attributes(sentry_init, reset_integration): - reset_integration(UnleashIntegration.identifier) +def test_wrapper_attributes(sentry_init, uninstall_integration): + uninstall_integration(UnleashIntegration) sentry_init(integrations=[UnleashIntegration()]) client = MockUnleashClient() From 47d29c304877c5c611afc0800bba777514471372 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 22 Dec 2024 17:30:06 -0800 Subject: [PATCH 15/22] Threaded and asyncio tests --- tests/integrations/unleash/test_unleash.py | 202 +++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 221a0fbfdd..2dc49e5a43 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -1,6 +1,10 @@ +import concurrent.futures as cf +import sys from random import random from unittest.mock import patch +import pytest + import sentry_sdk from sentry_sdk.integrations.unleash import UnleashIntegration from tests.integrations.unleash.testutils import MockUnleashClient @@ -57,6 +61,204 @@ def test_get_variant(sentry_init, capture_events, uninstall_integration): } +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) +def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration): + client = MockUnleashClient() + uninstall_integration(UnleashIntegration) + sentry_init(integrations=[UnleashIntegration()]) + 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}, + ] + } + + +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) +def test_get_variant_threaded(sentry_init, capture_events, uninstall_integration): + client = MockUnleashClient() + uninstall_integration(UnleashIntegration) + sentry_init(integrations=[UnleashIntegration()]) + 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, ["other", "toggle_feature"]) + + # 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": "toggle_feature", "result": True}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) +def test_is_enabled_asyncio(sentry_init, capture_events, uninstall_integration): + asyncio = pytest.importorskip("asyncio") + + client = MockUnleashClient() + uninstall_integration(UnleashIntegration) + sentry_init(integrations=[UnleashIntegration()]) + 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") +@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) +def test_get_variant_asyncio(sentry_init, capture_events, uninstall_integration): + asyncio = pytest.importorskip("asyncio") + + client = MockUnleashClient() + uninstall_integration(UnleashIntegration) + sentry_init(integrations=[UnleashIntegration()]) + 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("other"), task("toggle_feature")) + + # 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": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "toggle_feature", "result": True}, + ] + } + + @patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_wraps_original(sentry_init, uninstall_integration): uninstall_integration(UnleashIntegration) From 35b8dc20db72e9fb9dcd83f40a5f04d8b8624b43 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 22 Dec 2024 20:35:06 -0800 Subject: [PATCH 16/22] Rm unused conftest import --- tests/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 230d2e317d..f86000f389 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,6 @@ _DEFAULT_INTEGRATIONS, _installed_integrations, _processed_integrations, - Integration, ) from sentry_sdk.profiler import teardown_profiler from sentry_sdk.profiler.continuous_profiler import teardown_continuous_profiler From 4cc1cae67cd31edbf84ea6d46fcc79435f8609fb Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Sun, 22 Dec 2024 20:49:10 -0800 Subject: [PATCH 17/22] Track all variants regardless of payload --- sentry_sdk/integrations/unleash.py | 15 ++++++++------- tests/integrations/unleash/test_unleash.py | 20 ++++++++++++-------- tests/integrations/unleash/testutils.py | 2 +- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 98ec51939f..1f90d7ad32 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -30,8 +30,8 @@ def sentry_is_enabled(self, feature, *a, **kw): # type: (UnleashClient, str, *Any, **Any) -> Any enabled = old_is_enabled(self, feature, *a, **kw) - # We have no way of knowing what type of feature this is. Have to treat it as - # boolean flag. TODO: Unless we fetch a list of non-bool flags on startup.. + # 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) @@ -42,12 +42,13 @@ def sentry_get_variant(self, feature, *a, **kw): # type: (UnleashClient, str, *Any, **Any) -> Any variant = old_get_variant(self, feature, *a, **kw) enabled = variant.get("enabled", False) - payload_type = variant.get("payload", {}).get("type") - - if payload_type is None: - flags = sentry_sdk.get_current_scope().flags - flags.set(feature, enabled) + # _payload_type = variant.get("payload", {}).get("type") + # 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 diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 2dc49e5a43..71bcaf1663 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -42,7 +42,7 @@ def test_get_variant(sentry_init, capture_events, uninstall_integration): uninstall_integration(UnleashIntegration) sentry_init(integrations=[UnleashIntegration()]) - client.get_variant("toggle_feature") + client.get_variant("no_payload_feature") client.get_variant("string_feature") client.get_variant("json_feature") client.get_variant("csv_feature") @@ -55,7 +55,11 @@ def test_get_variant(sentry_init, capture_events, uninstall_integration): assert len(events) == 1 assert events[0]["contexts"]["flags"] == { "values": [ - {"flag": "toggle_feature", "result": True}, + {"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}, ] } @@ -129,7 +133,7 @@ def task(flag_key): client.get_variant("hello") with cf.ThreadPoolExecutor(max_workers=2) as pool: - pool.map(task, ["other", "toggle_feature"]) + pool.map(task, ["no_payload_feature", "other"]) # Capture error in original scope sentry_sdk.set_tag("task_id", "0") @@ -146,13 +150,13 @@ def task(flag_key): assert events[1]["contexts"]["flags"] == { "values": [ {"flag": "hello", "result": False}, - {"flag": "other", "result": False}, + {"flag": "no_payload_feature", "result": True}, ] } assert events[2]["contexts"]["flags"] == { "values": [ {"flag": "hello", "result": False}, - {"flag": "toggle_feature", "result": True}, + {"flag": "other", "result": False}, ] } @@ -226,7 +230,7 @@ async def task(flag_key): sentry_sdk.capture_exception(Exception("something wrong!")) async def runner(): - return asyncio.gather(task("other"), task("toggle_feature")) + return asyncio.gather(task("no_payload_feature"), task("other")) # Capture an eval before we split isolation scopes. client.get_variant("hello") @@ -248,13 +252,13 @@ async def runner(): assert events[1]["contexts"]["flags"] == { "values": [ {"flag": "hello", "result": False}, - {"flag": "other", "result": False}, + {"flag": "no_payload_feature", "result": True}, ] } assert events[2]["contexts"]["flags"] == { "values": [ {"flag": "hello", "result": False}, - {"flag": "toggle_feature", "result": True}, + {"flag": "other", "result": False}, ] } diff --git a/tests/integrations/unleash/testutils.py b/tests/integrations/unleash/testutils.py index 582105f3f3..1f6f08fca8 100644 --- a/tests/integrations/unleash/testutils.py +++ b/tests/integrations/unleash/testutils.py @@ -27,7 +27,7 @@ def __init__(self, *a, **kw): "enabled": True, "payload": {"type": "csv", "value": "abc 123\ncsbq 94"}, }, - "toggle_feature": {"name": "variant1", "enabled": True}, + "no_payload_feature": {"name": "variant1", "enabled": True}, } self.disabled_variant = {"name": "disabled", "enabled": False} From c8104b820d929e64f4ff78a20100d0e8117fee7b Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Mon, 23 Dec 2024 14:07:01 -0800 Subject: [PATCH 18/22] Patch an UnleashClient instance instead of the class --- sentry_sdk/integrations/unleash.py | 43 ++++++----- tests/integrations/unleash/test_unleash.py | 86 +++++++++------------- 2 files changed, 60 insertions(+), 69 deletions(-) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 1f90d7ad32..dcad378a15 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -5,30 +5,39 @@ 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") - if TYPE_CHECKING: - from typing import Any + from typing import Any, Optional + + try: + from UnleashClient import UnleashClient + except ImportError: + raise DidNotEnable("UnleashClient is not installed") class UnleashIntegration(Integration): identifier = "unleash" + _unleash_client = None # type: Optional[UnleashClient] + + def __init__(self, unleash_client): + # type: (Optional[UnleashClient]) -> None + self.__class__._unleash_client = unleash_client @staticmethod def setup_once(): # type: () -> None - # Wrap and patch evaluation functions - old_is_enabled = UnleashClient.is_enabled - old_get_variant = UnleashClient.get_variant + client = UnleashIntegration._unleash_client + if not client: + raise DidNotEnable("Error getting UnleashClient instance") + + # Wrap and patch evaluation methods (instance methods) + old_is_enabled = client.is_enabled + old_get_variant = client.get_variant @wraps(old_is_enabled) - def sentry_is_enabled(self, feature, *a, **kw): - # type: (UnleashClient, str, *Any, **Any) -> Any - enabled = old_is_enabled(self, feature, *a, **kw) + def sentry_is_enabled(feature, *a, **kw): + # type: (str, *Any, **Any) -> Any + enabled = old_is_enabled(feature, *a, **kw) # We have no way of knowing what type of unleash feature this is, so we have to treat # it as a boolean / toggle feature. @@ -38,9 +47,9 @@ def sentry_is_enabled(self, feature, *a, **kw): return enabled @wraps(old_get_variant) - def sentry_get_variant(self, feature, *a, **kw): - # type: (UnleashClient, str, *Any, **Any) -> Any - variant = old_get_variant(self, feature, *a, **kw) + def sentry_get_variant(feature, *a, **kw): + # type: (str, *Any, **Any) -> Any + variant = old_get_variant(feature, *a, **kw) enabled = variant.get("enabled", False) # _payload_type = variant.get("payload", {}).get("type") @@ -51,8 +60,8 @@ def sentry_get_variant(self, feature, *a, **kw): flags.set(feature, enabled) return variant - UnleashClient.is_enabled = sentry_is_enabled # type: ignore - UnleashClient.get_variant = sentry_get_variant # type: ignore + client.is_enabled = sentry_is_enabled # type: ignore + client.get_variant = sentry_get_variant # type: ignore # Error processor scope = sentry_sdk.get_current_scope() diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 71bcaf1663..98ba37e322 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -1,7 +1,7 @@ import concurrent.futures as cf import sys from random import random -from unittest.mock import patch +from unittest import mock import pytest @@ -9,15 +9,11 @@ from sentry_sdk.integrations.unleash import UnleashIntegration from tests.integrations.unleash.testutils import MockUnleashClient -original_is_enabled = MockUnleashClient.is_enabled -original_get_variant = MockUnleashClient.get_variant - -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_is_enabled(sentry_init, capture_events, uninstall_integration): client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration()]) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore client.is_enabled("hello") client.is_enabled("world") @@ -36,11 +32,10 @@ def test_is_enabled(sentry_init, capture_events, uninstall_integration): } -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_get_variant(sentry_init, capture_events, uninstall_integration): client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration()]) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore client.get_variant("no_payload_feature") client.get_variant("string_feature") @@ -65,11 +60,10 @@ def test_get_variant(sentry_init, capture_events, uninstall_integration): } -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration): client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration()]) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore events = capture_events() def task(flag_key): @@ -113,11 +107,10 @@ def task(flag_key): } -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_get_variant_threaded(sentry_init, capture_events, uninstall_integration): client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration()]) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore events = capture_events() def task(flag_key): @@ -162,13 +155,12 @@ def task(flag_key): @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_is_enabled_asyncio(sentry_init, capture_events, uninstall_integration): asyncio = pytest.importorskip("asyncio") client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration()]) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore events = capture_events() async def task(flag_key): @@ -213,13 +205,12 @@ async def runner(): @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_get_variant_asyncio(sentry_init, capture_events, uninstall_integration): asyncio = pytest.importorskip("asyncio") client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration()]) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore events = capture_events() async def task(flag_key): @@ -263,50 +254,41 @@ async def runner(): } -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_wraps_original(sentry_init, uninstall_integration): + client = MockUnleashClient() + mock_is_enabled = mock.Mock(return_value=random() < 0.5) + client.is_enabled = mock_is_enabled + mock_get_variant = mock.Mock(return_value={"enabled": random() < 0.5}) + client.get_variant = mock_get_variant + uninstall_integration(UnleashIntegration) + sentry_init(integrations=[UnleashIntegration(client)]) # 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}, + ) + - with patch( - "sentry_sdk.integrations.unleash.UnleashClient.is_enabled" - ) as mock_is_enabled: - with patch( - "sentry_sdk.integrations.unleash.UnleashClient.get_variant" - ) as mock_get_variant: - mock_is_enabled.return_value = random() < 0.5 - mock_get_variant.return_value = {"enabled": random() < 0.5} - sentry_init(integrations=[UnleashIntegration()]) - client = MockUnleashClient() - - res = client.is_enabled("test-flag", "arg", kwarg=1) - assert res == mock_is_enabled.return_value - assert mock_is_enabled.call_args == ( - (client, "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 == ( - (client, "test-flag", "arg"), - {"kwarg": 1}, - ) - - -@patch("sentry_sdk.integrations.unleash.UnleashClient", MockUnleashClient) def test_wrapper_attributes(sentry_init, uninstall_integration): + client = MockUnleashClient() + original_is_enabled = client.is_enabled + original_get_variant = client.get_variant + uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration()]) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore - client = MockUnleashClient() assert client.is_enabled.__name__ == "is_enabled" assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__ - assert MockUnleashClient.is_enabled.__name__ == "is_enabled" - assert MockUnleashClient.is_enabled.__qualname__ == original_is_enabled.__qualname__ assert client.get_variant.__name__ == "get_variant" assert client.get_variant.__qualname__ == original_get_variant.__qualname__ - assert MockUnleashClient.get_variant.__name__ == "get_variant" - assert ( - MockUnleashClient.get_variant.__qualname__ == original_get_variant.__qualname__ - ) From 3946bf21141f31823c62cd485ca8e14ede541054 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 27 Dec 2024 00:21:23 -0800 Subject: [PATCH 19/22] Add client isolation test and fix init typing --- sentry_sdk/integrations/unleash.py | 2 +- tests/integrations/unleash/test_unleash.py | 24 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index dcad378a15..52f431ec21 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -19,7 +19,7 @@ class UnleashIntegration(Integration): _unleash_client = None # type: Optional[UnleashClient] def __init__(self, unleash_client): - # type: (Optional[UnleashClient]) -> None + # type: (UnleashClient) -> None self.__class__._unleash_client = unleash_client @staticmethod diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 98ba37e322..8bdf7bc404 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -254,6 +254,30 @@ async def runner(): } +def test_client_isolation(sentry_init, capture_events, uninstall_integration): + """ + If the integration is tracking a single client, evaluations from other clients should not be + captured. + """ + client = MockUnleashClient() + uninstall_integration(UnleashIntegration) + sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore + + other_client = MockUnleashClient() + + other_client.is_enabled("hello") + other_client.is_enabled("world") + other_client.is_enabled("other") + other_client.get_variant("no_payload_feature") + other_client.get_variant("json_feature") + + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == {"values": []} + + def test_wraps_original(sentry_init, uninstall_integration): client = MockUnleashClient() mock_is_enabled = mock.Mock(return_value=random() < 0.5) From 633b3eded447e38c3627c77133c9f518c1f30c7c Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 3 Jan 2025 10:31:34 -0600 Subject: [PATCH 20/22] Wrap UnleashClient directly --- sentry_sdk/integrations/unleash.py | 44 ++--- tests/integrations/unleash/test_unleash.py | 216 ++++++++++----------- tests/integrations/unleash/testutils.py | 38 ++++ 3 files changed, 156 insertions(+), 142 deletions(-) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 52f431ec21..6fe3164551 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -1,43 +1,30 @@ from functools import wraps -from typing import TYPE_CHECKING +from typing import Any import sentry_sdk from sentry_sdk.flag_utils import flag_error_processor from sentry_sdk.integrations import Integration, DidNotEnable -if TYPE_CHECKING: - from typing import Any, Optional - - try: - from UnleashClient import UnleashClient - except ImportError: - raise DidNotEnable("UnleashClient is not installed") +try: + from UnleashClient import UnleashClient +except ImportError: + raise DidNotEnable("UnleashClient is not installed") class UnleashIntegration(Integration): identifier = "unleash" - _unleash_client = None # type: Optional[UnleashClient] - - def __init__(self, unleash_client): - # type: (UnleashClient) -> None - self.__class__._unleash_client = unleash_client @staticmethod def setup_once(): # type: () -> None - - client = UnleashIntegration._unleash_client - if not client: - raise DidNotEnable("Error getting UnleashClient instance") - # Wrap and patch evaluation methods (instance methods) - old_is_enabled = client.is_enabled - old_get_variant = client.get_variant + old_is_enabled = UnleashClient.is_enabled + old_get_variant = UnleashClient.get_variant @wraps(old_is_enabled) - def sentry_is_enabled(feature, *a, **kw): - # type: (str, *Any, **Any) -> Any - enabled = old_is_enabled(feature, *a, **kw) + 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. @@ -47,11 +34,10 @@ def sentry_is_enabled(feature, *a, **kw): return enabled @wraps(old_get_variant) - def sentry_get_variant(feature, *a, **kw): - # type: (str, *Any, **Any) -> Any - variant = old_get_variant(feature, *a, **kw) + 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) - # _payload_type = variant.get("payload", {}).get("type") # 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 @@ -60,8 +46,8 @@ def sentry_get_variant(feature, *a, **kw): flags.set(feature, enabled) return variant - client.is_enabled = sentry_is_enabled # type: ignore - client.get_variant = sentry_get_variant # type: ignore + UnleashClient.is_enabled = sentry_is_enabled # type: ignore + UnleashClient.get_variant = sentry_get_variant # type: ignore # Error processor scope = sentry_sdk.get_current_scope() diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 8bdf7bc404..e1d96d7026 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -2,22 +2,24 @@ 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 MockUnleashClient +from tests.integrations.unleash.testutils import mock_unleash_client def test_is_enabled(sentry_init, capture_events, uninstall_integration): - client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore - client.is_enabled("hello") - client.is_enabled("world") - client.is_enabled("other") + 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!")) @@ -33,16 +35,17 @@ def test_is_enabled(sentry_init, capture_events, uninstall_integration): def test_get_variant(sentry_init, capture_events, uninstall_integration): - client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # 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") + 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!")) @@ -61,25 +64,27 @@ def test_get_variant(sentry_init, capture_events, uninstall_integration): def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration): - client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # 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!")) + 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") + # 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"]) + 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") @@ -108,25 +113,27 @@ def task(flag_key): def test_get_variant_threaded(sentry_init, capture_events, uninstall_integration): - client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # 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!")) + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + events = capture_events() - # Capture an eval before we split isolation scopes. - client.get_variant("hello") + 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!")) - with cf.ThreadPoolExecutor(max_workers=2) as pool: - pool.map(task, ["no_payload_feature", "other"]) + # 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") @@ -157,26 +164,27 @@ def task(flag_key): @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") - - client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # 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!")) + with mock_unleash_client(): + client = UnleashClient() + sentry_init(integrations=[UnleashIntegration()]) # type: ignore + events = capture_events() - async def runner(): - return asyncio.gather(task("world"), task("other")) + 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!")) - # Capture an eval before we split isolation scopes. - client.is_enabled("hello") + async def runner(): + return asyncio.gather(task("world"), task("other")) - asyncio.run(runner()) + # 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") @@ -208,25 +216,27 @@ async def runner(): def test_get_variant_asyncio(sentry_init, capture_events, uninstall_integration): asyncio = pytest.importorskip("asyncio") - client = MockUnleashClient() uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # 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!")) + 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")) + async def runner(): + return asyncio.gather(task("no_payload_feature"), task("other")) - # Capture an eval before we split isolation scopes. - client.get_variant("hello") + # Capture an eval before we split isolation scopes. + client.get_variant("hello") - asyncio.run(runner()) + asyncio.run(runner()) # Capture error in original scope sentry_sdk.set_tag("task_id", "0") @@ -254,39 +264,17 @@ async def runner(): } -def test_client_isolation(sentry_init, capture_events, uninstall_integration): - """ - If the integration is tracking a single client, evaluations from other clients should not be - captured. - """ - client = MockUnleashClient() - uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore - - other_client = MockUnleashClient() - - other_client.is_enabled("hello") - other_client.is_enabled("world") - other_client.is_enabled("other") - other_client.get_variant("no_payload_feature") - other_client.get_variant("json_feature") - - events = capture_events() - sentry_sdk.capture_exception(Exception("something wrong!")) - - assert len(events) == 1 - assert events[0]["contexts"]["flags"] == {"values": []} - - def test_wraps_original(sentry_init, uninstall_integration): - client = MockUnleashClient() - mock_is_enabled = mock.Mock(return_value=random() < 0.5) - client.is_enabled = mock_is_enabled - mock_get_variant = mock.Mock(return_value={"enabled": random() < 0.5}) - client.get_variant = mock_get_variant + with mock_unleash_client(): + client = UnleashClient() - uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore + 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) + sentry_init(integrations=[UnleashIntegration()]) # type: ignore res = client.is_enabled("test-flag", "arg", kwarg=1) assert res == mock_is_enabled.return_value @@ -304,15 +292,17 @@ def test_wraps_original(sentry_init, uninstall_integration): def test_wrapper_attributes(sentry_init, uninstall_integration): - client = MockUnleashClient() - original_is_enabled = client.is_enabled - original_get_variant = client.get_variant + with mock_unleash_client(): + client = UnleashClient() # <- Returns a MockUnleashClient - uninstall_integration(UnleashIntegration) - sentry_init(integrations=[UnleashIntegration(client)]) # type: ignore + original_is_enabled = client.is_enabled + original_get_variant = client.get_variant - assert client.is_enabled.__name__ == "is_enabled" - assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__ + uninstall_integration(UnleashIntegration) + sentry_init(integrations=[UnleashIntegration()]) # type: ignore - assert client.get_variant.__name__ == "get_variant" - assert client.get_variant.__qualname__ == original_get_variant.__qualname__ + # 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 1f6f08fca8..c424b34c3a 100644 --- a/tests/integrations/unleash/testutils.py +++ b/tests/integrations/unleash/testutils.py @@ -1,3 +1,41 @@ +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): From 9ebee27b4c31861e2199773817b199ac454fd392 Mon Sep 17 00:00:00 2001 From: Colton Allen Date: Fri, 3 Jan 2025 10:35:49 -0600 Subject: [PATCH 21/22] Use identifier --- tests/conftest.py | 11 ++--------- tests/integrations/unleash/test_unleash.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f86000f389..b5ab7aa804 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -187,16 +187,9 @@ def reset_integrations(): @pytest.fixture def uninstall_integration(): - """ - Forces the next call to sentry_init to re-install an integration and call `setup_once`. - No effect if the integration is not installed. - """ + """Use to force the next call to sentry_init to re-install/setup an integration.""" - def inner(name_or_cls): - if isinstance(name_or_cls, str): - identifier = name_or_cls - else: - identifier = name_or_cls.identifier + def inner(identifier): _processed_integrations.discard(identifier) _installed_integrations.discard(identifier) diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index e1d96d7026..9a7a3f57bd 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -12,7 +12,7 @@ def test_is_enabled(sentry_init, capture_events, uninstall_integration): - uninstall_integration(UnleashIntegration) + uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): client = UnleashClient() @@ -35,7 +35,7 @@ def test_is_enabled(sentry_init, capture_events, uninstall_integration): def test_get_variant(sentry_init, capture_events, uninstall_integration): - uninstall_integration(UnleashIntegration) + uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): client = UnleashClient() @@ -64,7 +64,7 @@ def test_get_variant(sentry_init, capture_events, uninstall_integration): def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration): - uninstall_integration(UnleashIntegration) + uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): client = UnleashClient() @@ -113,7 +113,7 @@ def task(flag_key): def test_get_variant_threaded(sentry_init, capture_events, uninstall_integration): - uninstall_integration(UnleashIntegration) + uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): client = UnleashClient() @@ -164,7 +164,7 @@ def task(flag_key): @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) + uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): client = UnleashClient() @@ -216,7 +216,7 @@ async def runner(): def test_get_variant_asyncio(sentry_init, capture_events, uninstall_integration): asyncio = pytest.importorskip("asyncio") - uninstall_integration(UnleashIntegration) + uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): client = UnleashClient() @@ -273,7 +273,7 @@ def test_wraps_original(sentry_init, uninstall_integration): client.is_enabled = mock_is_enabled client.get_variant = mock_get_variant - uninstall_integration(UnleashIntegration) + uninstall_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) # type: ignore res = client.is_enabled("test-flag", "arg", kwarg=1) @@ -298,7 +298,7 @@ def test_wrapper_attributes(sentry_init, uninstall_integration): original_is_enabled = client.is_enabled original_get_variant = client.get_variant - uninstall_integration(UnleashIntegration) + uninstall_integration(UnleashIntegration.identifier) sentry_init(integrations=[UnleashIntegration()]) # type: ignore # Mock clients methods have not lost their qualified names after decoration. From cffb47502e00f40dfede8c1802ddf6dcd7682fb5 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Tue, 7 Jan 2025 12:40:46 +0100 Subject: [PATCH 22/22] whitespace --- sentry_sdk/integrations/unleash.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 6fe3164551..33b0a4b9dc 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -44,6 +44,7 @@ def sentry_get_variant(self, feature, *args, **kwargs): # 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