diff --git a/sentry_sdk/feature_flags.py b/sentry_sdk/feature_flags.py index a0b1338356..dd8d41c32e 100644 --- a/sentry_sdk/feature_flags.py +++ b/sentry_sdk/feature_flags.py @@ -66,3 +66,7 @@ def add_feature_flag(flag, result): """ flags = sentry_sdk.get_current_scope().flags flags.set(flag, result) + + span = sentry_sdk.get_current_span() + if span: + span.set_flag(f"flag.evaluation.{flag}", result) diff --git a/sentry_sdk/integrations/launchdarkly.py b/sentry_sdk/integrations/launchdarkly.py index cb9e911463..d3c423e7be 100644 --- a/sentry_sdk/integrations/launchdarkly.py +++ b/sentry_sdk/integrations/launchdarkly.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -import sentry_sdk +from sentry_sdk.feature_flags import add_feature_flag from sentry_sdk.integrations import DidNotEnable, Integration try: @@ -53,8 +53,8 @@ def metadata(self): def after_evaluation(self, series_context, data, detail): # type: (EvaluationSeriesContext, dict[Any, Any], EvaluationDetail) -> dict[Any, Any] if isinstance(detail.value, bool): - flags = sentry_sdk.get_current_scope().flags - flags.set(series_context.key, detail.value) + add_feature_flag(series_context.key, detail.value) + return data def before_evaluation(self, series_context, data): diff --git a/sentry_sdk/integrations/openfeature.py b/sentry_sdk/integrations/openfeature.py index bf66b94e8b..e2b33d83f2 100644 --- a/sentry_sdk/integrations/openfeature.py +++ b/sentry_sdk/integrations/openfeature.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -import sentry_sdk +from sentry_sdk.feature_flags import add_feature_flag from sentry_sdk.integrations import DidNotEnable, Integration try: @@ -29,11 +29,9 @@ class OpenFeatureHook(Hook): def after(self, hook_context, details, hints): # type: (HookContext, FlagEvaluationDetails[bool], HookHints) -> None if isinstance(details.value, bool): - flags = sentry_sdk.get_current_scope().flags - flags.set(details.flag_key, details.value) + add_feature_flag(details.flag_key, details.value) def error(self, hook_context, exception, hints): # type: (HookContext, Exception, HookHints) -> None if isinstance(hook_context.default_value, bool): - flags = sentry_sdk.get_current_scope().flags - flags.set(hook_context.flag_key, hook_context.default_value) + add_feature_flag(hook_context.flag_key, hook_context.default_value) diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index 873f36c68b..6daa0a411f 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -1,7 +1,7 @@ from functools import wraps from typing import Any -import sentry_sdk +from sentry_sdk.feature_flags import add_feature_flag from sentry_sdk.integrations import Integration, DidNotEnable try: @@ -26,8 +26,7 @@ def sentry_is_enabled(self, feature, *args, **kwargs): # We have no way of knowing what type of unleash feature this is, so we have to treat # it as a boolean / toggle feature. - flags = sentry_sdk.get_current_scope().flags - flags.set(feature, enabled) + add_feature_flag(feature, enabled) return enabled diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index ab1a7a8fdf..07697e6762 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -278,6 +278,8 @@ class Span: "scope", "origin", "name", + "_flags", + "_flags_capacity", ) def __init__( @@ -313,6 +315,8 @@ def __init__( self._tags = {} # type: MutableMapping[str, str] self._data = {} # type: Dict[str, Any] self._containing_transaction = containing_transaction + self._flags = {} # type: Dict[str, bool] + self._flags_capacity = 10 if hub is not None: warnings.warn( @@ -604,6 +608,11 @@ def set_data(self, key, value): # type: (str, Any) -> None self._data[key] = value + def set_flag(self, flag, result): + # type: (str, bool) -> None + if len(self._flags) < self._flags_capacity: + self._flags[flag] = result + def set_status(self, value): # type: (str) -> None self.status = value @@ -707,7 +716,9 @@ def to_json(self): if tags: rv["tags"] = tags - data = self._data + data = {} + data.update(self._flags) + data.update(self._data) if data: rv["data"] = data diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index 20566ce09a..20bb4d031f 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -12,6 +12,8 @@ import sentry_sdk from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration +from sentry_sdk import start_span, start_transaction +from tests.conftest import ApproxDict @pytest.mark.parametrize( @@ -202,3 +204,42 @@ def test_launchdarkly_integration_did_not_enable(monkeypatch): monkeypatch.setattr(client, "is_initialized", lambda: False) with pytest.raises(DidNotEnable): LaunchDarklyIntegration(ld_client=client) + + +@pytest.mark.parametrize( + "use_global_client", + (False, True), +) +def test_launchdarkly_span_integration( + sentry_init, use_global_client, capture_events, uninstall_integration +): + td = TestData.data_source() + td.update(td.flag("hello").variation_for_all(True)) + # Disable background requests as we aren't using a server. + config = Config( + "sdk-key", update_processor_class=td, diagnostic_opt_out=True, send_events=False + ) + + uninstall_integration(LaunchDarklyIntegration.identifier) + if use_global_client: + ldclient.set_config(config) + sentry_init(traces_sample_rate=1.0, integrations=[LaunchDarklyIntegration()]) + client = ldclient.get() + else: + client = LDClient(config=config) + sentry_init( + traces_sample_rate=1.0, + integrations=[LaunchDarklyIntegration(ld_client=client)], + ) + + events = capture_events() + + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + client.variation("hello", Context.create("my-org", "organization"), False) + client.variation("other", Context.create("my-org", "organization"), False) + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.other": False} + ) diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py index c180211c3f..46acc61ae7 100644 --- a/tests/integrations/openfeature/test_openfeature.py +++ b/tests/integrations/openfeature/test_openfeature.py @@ -7,7 +7,9 @@ from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider import sentry_sdk +from sentry_sdk import start_span, start_transaction from sentry_sdk.integrations.openfeature import OpenFeatureIntegration +from tests.conftest import ApproxDict def test_openfeature_integration(sentry_init, capture_events, uninstall_integration): @@ -151,3 +153,27 @@ async def runner(): {"flag": "world", "result": False}, ] } + + +def test_openfeature_span_integration( + sentry_init, capture_events, uninstall_integration +): + uninstall_integration(OpenFeatureIntegration.identifier) + sentry_init(traces_sample_rate=1.0, integrations=[OpenFeatureIntegration()]) + + api.set_provider( + InMemoryProvider({"hello": InMemoryFlag("on", {"on": True, "off": False})}) + ) + client = api.get_client() + + events = capture_events() + + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + client.get_boolean_value("hello", default_value=False) + client.get_boolean_value("world", default_value=False) + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.world": False} + ) diff --git a/tests/integrations/statsig/test_statsig.py b/tests/integrations/statsig/test_statsig.py index c1666bde4d..5eb2cf39f3 100644 --- a/tests/integrations/statsig/test_statsig.py +++ b/tests/integrations/statsig/test_statsig.py @@ -5,6 +5,8 @@ from statsig.statsig_user import StatsigUser from random import random from unittest.mock import Mock +from sentry_sdk import start_span, start_transaction +from tests.conftest import ApproxDict import pytest @@ -181,3 +183,21 @@ def test_wrapper_attributes(sentry_init, uninstall_integration): # Clean up statsig.check_gate = original_check_gate + + +def test_statsig_span_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(StatsigIntegration.identifier) + + with mock_statsig({"hello": True}): + sentry_init(traces_sample_rate=1.0, integrations=[StatsigIntegration()]) + events = capture_events() + user = StatsigUser(user_id="user-id") + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + statsig.check_gate(user, "hello") + statsig.check_gate(user, "world") + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.world": False} + ) diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 379abba8f6..98a6188181 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -8,7 +8,9 @@ import sentry_sdk from sentry_sdk.integrations.unleash import UnleashIntegration +from sentry_sdk import start_span, start_transaction from tests.integrations.unleash.testutils import mock_unleash_client +from tests.conftest import ApproxDict def test_is_enabled(sentry_init, capture_events, uninstall_integration): @@ -164,3 +166,21 @@ def test_wrapper_attributes(sentry_init, uninstall_integration): # Mock clients methods have not lost their qualified names after decoration. assert client.is_enabled.__name__ == "is_enabled" assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__ + + +def test_unleash_span_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(UnleashIntegration.identifier) + + with mock_unleash_client(): + sentry_init(traces_sample_rate=1.0, integrations=[UnleashIntegration()]) + events = capture_events() + client = UnleashClient() # type: ignore[arg-type] + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + client.is_enabled("hello") + client.is_enabled("other") + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.other": False} + ) diff --git a/tests/test_feature_flags.py b/tests/test_feature_flags.py index 0df30bd0ea..1b0ed13d49 100644 --- a/tests/test_feature_flags.py +++ b/tests/test_feature_flags.py @@ -7,6 +7,8 @@ import sentry_sdk from sentry_sdk.feature_flags import add_feature_flag, FlagBuffer +from sentry_sdk import start_span, start_transaction +from tests.conftest import ApproxDict def test_featureflags_integration(sentry_init, capture_events, uninstall_integration): @@ -220,3 +222,40 @@ def reader(): # shared resource. When deepcopying we should have exclusive access to the underlying # memory. assert error_occurred is False + + +def test_flag_limit(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0) + + events = capture_events() + + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + add_feature_flag("0", True) + add_feature_flag("1", True) + add_feature_flag("2", True) + add_feature_flag("3", True) + add_feature_flag("4", True) + add_feature_flag("5", True) + add_feature_flag("6", True) + add_feature_flag("7", True) + add_feature_flag("8", True) + add_feature_flag("9", True) + add_feature_flag("10", True) + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + { + "flag.evaluation.0": True, + "flag.evaluation.1": True, + "flag.evaluation.2": True, + "flag.evaluation.3": True, + "flag.evaluation.4": True, + "flag.evaluation.5": True, + "flag.evaluation.6": True, + "flag.evaluation.7": True, + "flag.evaluation.8": True, + "flag.evaluation.9": True, + } + ) + assert "flag.evaluation.10" not in event["spans"][0]["data"]