diff --git a/sentry_sdk/integrations/openfeature.py b/sentry_sdk/integrations/openfeature.py index 18f968a703..525330e1d3 100644 --- a/sentry_sdk/integrations/openfeature.py +++ b/sentry_sdk/integrations/openfeature.py @@ -4,6 +4,9 @@ from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.flag_utils import flag_error_processor +if TYPE_CHECKING: + from typing import Optional + try: from openfeature import api from openfeature.hook import Hook @@ -11,22 +14,35 @@ if TYPE_CHECKING: from openfeature.flag_evaluation import FlagEvaluationDetails from openfeature.hook import HookContext, HookHints + from openfeature.client import OpenFeatureClient except ImportError: raise DidNotEnable("OpenFeature is not installed") class OpenFeatureIntegration(Integration): identifier = "openfeature" + _client = None # type: Optional[OpenFeatureClient] + + def __init__(self, client=None): + # type: (Optional[OpenFeatureClient]) -> None + self.__class__._client = client @staticmethod def setup_once(): # type: () -> None + + client = OpenFeatureIntegration._client + if client: + # Register the hook within the openfeature client. + client.add_hooks(hooks=[OpenFeatureHook()]) + print("added hook to", client) + else: + # Register the hook within the global openfeature hooks list. + api.add_hooks(hooks=[OpenFeatureHook()]) + scope = sentry_sdk.get_current_scope() scope.add_error_processor(flag_error_processor) - # Register the hook within the global openfeature hooks list. - api.add_hooks(hooks=[OpenFeatureHook()]) - class OpenFeatureHook(Hook): diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index e7576bb469..89fe232f59 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -14,17 +14,32 @@ from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration +@pytest.fixture +def reset_launchdarkly(uninstall_integration): + yield + + uninstall_integration(LaunchDarklyIntegration.identifier) + + # Resets global client and config only. We're using ldclient internals here, so this might + # break if their implementation changes. + ldclient._reset_client() + try: + ldclient.__lock.lock() + ldclient.__config = None + finally: + ldclient.__lock.unlock() + + @pytest.mark.parametrize( "use_global_client", (False, True), ) def test_launchdarkly_integration( - sentry_init, use_global_client, capture_events, uninstall_integration + sentry_init, use_global_client, capture_events, reset_launchdarkly ): td = TestData.data_source() config = Config("sdk-key", update_processor_class=td) - uninstall_integration(LaunchDarklyIntegration.identifier) if use_global_client: ldclient.set_config(config) sentry_init(integrations=[LaunchDarklyIntegration()]) @@ -56,13 +71,12 @@ def test_launchdarkly_integration( def test_launchdarkly_integration_threaded( - sentry_init, capture_events, uninstall_integration + sentry_init, capture_events, reset_launchdarkly ): td = TestData.data_source() client = LDClient(config=Config("sdk-key", update_processor_class=td)) context = Context.create("user1") - uninstall_integration(LaunchDarklyIntegration.identifier) sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) events = capture_events() @@ -111,7 +125,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_launchdarkly ): """Assert concurrently evaluated flags do not pollute one another.""" @@ -121,7 +135,6 @@ def test_launchdarkly_integration_asyncio( client = LDClient(config=Config("sdk-key", update_processor_class=td)) context = Context.create("user1") - uninstall_integration(LaunchDarklyIntegration.identifier) sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) events = capture_events() @@ -168,21 +181,35 @@ async def runner(): } -def test_launchdarkly_integration_did_not_enable(sentry_init, uninstall_integration): +def test_launchdarkly_integration_client_isolation( + sentry_init, capture_events, reset_launchdarkly +): """ - Setup should fail when using global client and ldclient.set_config wasn't called. - - We're accessing ldclient internals to set up this test, so it might break if launchdarkly's - implementation changes. + If the integration is tracking a single client, evaluations from other clients should not be + captured. """ + td = TestData.data_source() + td.update(td.flag("hello").variation_for_all(True)) + td.update(td.flag("world").variation_for_all(True)) + client = LDClient(config=Config("sdk-key", update_processor_class=td)) + sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) - ldclient._reset_client() - try: - ldclient.__lock.lock() - ldclient.__config = None - finally: - ldclient.__lock.unlock() + # For isolation you must use a new Config object, but data source can be the same. + other_client = LDClient(Config("sdk-key", update_processor_class=td)) + other_client.variation("hello", Context.create("my-org", "organization"), False) + other_client.variation("world", Context.create("user1", "user"), False) + other_client.variation("other", Context.create("user2", "user"), False) - uninstall_integration(LaunchDarklyIntegration.identifier) + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == {"values": []} + + +def test_launchdarkly_integration_did_not_enable(sentry_init, reset_launchdarkly): + """ + Setup should fail when using global client and ldclient.set_config wasn't called. + """ with pytest.raises(DidNotEnable): sentry_init(integrations=[LaunchDarklyIntegration()]) diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py index c180211c3f..f37ddd79dc 100644 --- a/tests/integrations/openfeature/test_openfeature.py +++ b/tests/integrations/openfeature/test_openfeature.py @@ -1,6 +1,5 @@ import concurrent.futures as cf import sys - import pytest from openfeature import api @@ -10,17 +9,35 @@ from sentry_sdk.integrations.openfeature import OpenFeatureIntegration -def test_openfeature_integration(sentry_init, capture_events, uninstall_integration): +@pytest.fixture +def reset_openfeature(uninstall_integration): + yield + + # Teardown uninstall_integration(OpenFeatureIntegration.identifier) - sentry_init(integrations=[OpenFeatureIntegration()]) + api.clear_hooks() + api.shutdown() # provider clean up + +@pytest.mark.parametrize( + "use_global_client", + (False, True), +) +def test_openfeature_integration( + sentry_init, use_global_client, capture_events, reset_openfeature +): flags = { "hello": InMemoryFlag("on", {"on": True, "off": False}), "world": InMemoryFlag("off", {"on": True, "off": False}), } api.set_provider(InMemoryProvider(flags)) - client = api.get_client() + + if use_global_client: + sentry_init(integrations=[OpenFeatureIntegration()]) + else: + sentry_init(integrations=[OpenFeatureIntegration(client=client)]) + client.get_boolean_value("hello", default_value=False) client.get_boolean_value("world", default_value=False) client.get_boolean_value("other", default_value=True) @@ -39,20 +56,19 @@ 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_openfeature ): - uninstall_integration(OpenFeatureIntegration.identifier) - sentry_init(integrations=[OpenFeatureIntegration()]) - events = capture_events() - flags = { "hello": InMemoryFlag("on", {"on": True, "off": False}), "world": InMemoryFlag("off", {"on": True, "off": False}), } api.set_provider(InMemoryProvider(flags)) + client = api.get_client() + + sentry_init(integrations=[OpenFeatureIntegration(client=client)]) + events = capture_events() # Capture an eval before we split isolation scopes. - client = api.get_client() client.get_boolean_value("hello", default_value=False) def task(flag): @@ -95,16 +111,25 @@ 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_openfeature ): """Assert concurrently evaluated flags do not pollute one another.""" asyncio = pytest.importorskip("asyncio") - uninstall_integration(OpenFeatureIntegration.identifier) - sentry_init(integrations=[OpenFeatureIntegration()]) + flags = { + "hello": InMemoryFlag("on", {"on": True, "off": False}), + "world": InMemoryFlag("off", {"on": True, "off": False}), + } + api.set_provider(InMemoryProvider(flags)) + client = api.get_client() + + sentry_init(integrations=[OpenFeatureIntegration(client=client)]) events = capture_events() + # Capture an eval before we split isolation scopes. + client.get_boolean_value("hello", default_value=False) + async def task(flag): with sentry_sdk.isolation_scope(): client.get_boolean_value(flag, default_value=False) @@ -115,16 +140,6 @@ async def task(flag): async def runner(): return asyncio.gather(task("world"), task("other")) - flags = { - "hello": InMemoryFlag("on", {"on": True, "off": False}), - "world": InMemoryFlag("off", {"on": True, "off": False}), - } - api.set_provider(InMemoryProvider(flags)) - - # Capture an eval before we split isolation scopes. - client = api.get_client() - client.get_boolean_value("hello", default_value=False) - asyncio.run(runner()) # Capture error in original scope @@ -151,3 +166,31 @@ async def runner(): {"flag": "world", "result": False}, ] } + + +def test_openfeature_integration_client_isolation( + sentry_init, capture_events, reset_openfeature +): + """ + If the integration is tracking a single client, evaluations from other clients should not be + captured. + """ + flags = { + "hello": InMemoryFlag("on", {"on": True, "off": False}), + "world": InMemoryFlag("off", {"on": True, "off": False}), + } + api.set_provider(InMemoryProvider(flags)) + client = api.get_client() + sentry_init(integrations=[OpenFeatureIntegration(client=client)]) + + other_client = api.get_client() + other_client.get_boolean_value("hello", default_value=False) + other_client.get_boolean_value("world", default_value=False) + other_client.get_boolean_value("other", default_value=True) + + events = capture_events() + sentry_sdk.set_tag("apple", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == {"values": []}