Skip to content

Commit f3687fc

Browse files
feat(spans): Record flag evaluations as span attributes (#4280)
Flags evaluated within a span are appended to the span as attributes. --------- Co-authored-by: Daniel Szoke <[email protected]>
1 parent 815de9f commit f3687fc

File tree

10 files changed

+170
-12
lines changed

10 files changed

+170
-12
lines changed

sentry_sdk/feature_flags.py

+4
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,7 @@ def add_feature_flag(flag, result):
6666
"""
6767
flags = sentry_sdk.get_current_scope().flags
6868
flags.set(flag, result)
69+
70+
span = sentry_sdk.get_current_span()
71+
if span:
72+
span.set_flag(f"flag.evaluation.{flag}", result)

sentry_sdk/integrations/launchdarkly.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import TYPE_CHECKING
2-
import sentry_sdk
32

3+
from sentry_sdk.feature_flags import add_feature_flag
44
from sentry_sdk.integrations import DidNotEnable, Integration
55

66
try:
@@ -53,8 +53,8 @@ def metadata(self):
5353
def after_evaluation(self, series_context, data, detail):
5454
# type: (EvaluationSeriesContext, dict[Any, Any], EvaluationDetail) -> dict[Any, Any]
5555
if isinstance(detail.value, bool):
56-
flags = sentry_sdk.get_current_scope().flags
57-
flags.set(series_context.key, detail.value)
56+
add_feature_flag(series_context.key, detail.value)
57+
5858
return data
5959

6060
def before_evaluation(self, series_context, data):

sentry_sdk/integrations/openfeature.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import TYPE_CHECKING
2-
import sentry_sdk
32

3+
from sentry_sdk.feature_flags import add_feature_flag
44
from sentry_sdk.integrations import DidNotEnable, Integration
55

66
try:
@@ -29,11 +29,9 @@ class OpenFeatureHook(Hook):
2929
def after(self, hook_context, details, hints):
3030
# type: (HookContext, FlagEvaluationDetails[bool], HookHints) -> None
3131
if isinstance(details.value, bool):
32-
flags = sentry_sdk.get_current_scope().flags
33-
flags.set(details.flag_key, details.value)
32+
add_feature_flag(details.flag_key, details.value)
3433

3534
def error(self, hook_context, exception, hints):
3635
# type: (HookContext, Exception, HookHints) -> None
3736
if isinstance(hook_context.default_value, bool):
38-
flags = sentry_sdk.get_current_scope().flags
39-
flags.set(hook_context.flag_key, hook_context.default_value)
37+
add_feature_flag(hook_context.flag_key, hook_context.default_value)

sentry_sdk/integrations/unleash.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from functools import wraps
22
from typing import Any
33

4-
import sentry_sdk
4+
from sentry_sdk.feature_flags import add_feature_flag
55
from sentry_sdk.integrations import Integration, DidNotEnable
66

77
try:
@@ -26,8 +26,7 @@ def sentry_is_enabled(self, feature, *args, **kwargs):
2626

2727
# We have no way of knowing what type of unleash feature this is, so we have to treat
2828
# it as a boolean / toggle feature.
29-
flags = sentry_sdk.get_current_scope().flags
30-
flags.set(feature, enabled)
29+
add_feature_flag(feature, enabled)
3130

3231
return enabled
3332

sentry_sdk/tracing.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,8 @@ class Span:
278278
"scope",
279279
"origin",
280280
"name",
281+
"_flags",
282+
"_flags_capacity",
281283
)
282284

283285
def __init__(
@@ -313,6 +315,8 @@ def __init__(
313315
self._tags = {} # type: MutableMapping[str, str]
314316
self._data = {} # type: Dict[str, Any]
315317
self._containing_transaction = containing_transaction
318+
self._flags = {} # type: Dict[str, bool]
319+
self._flags_capacity = 10
316320

317321
if hub is not None:
318322
warnings.warn(
@@ -597,6 +601,11 @@ def set_data(self, key, value):
597601
# type: (str, Any) -> None
598602
self._data[key] = value
599603

604+
def set_flag(self, flag, result):
605+
# type: (str, bool) -> None
606+
if len(self._flags) < self._flags_capacity:
607+
self._flags[flag] = result
608+
600609
def set_status(self, value):
601610
# type: (str) -> None
602611
self.status = value
@@ -700,7 +709,9 @@ def to_json(self):
700709
if tags:
701710
rv["tags"] = tags
702711

703-
data = self._data
712+
data = {}
713+
data.update(self._flags)
714+
data.update(self._data)
704715
if data:
705716
rv["data"] = data
706717

tests/integrations/launchdarkly/test_launchdarkly.py

+41
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import sentry_sdk
1313
from sentry_sdk.integrations import DidNotEnable
1414
from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration
15+
from sentry_sdk import start_span, start_transaction
16+
from tests.conftest import ApproxDict
1517

1618

1719
@pytest.mark.parametrize(
@@ -202,3 +204,42 @@ def test_launchdarkly_integration_did_not_enable(monkeypatch):
202204
monkeypatch.setattr(client, "is_initialized", lambda: False)
203205
with pytest.raises(DidNotEnable):
204206
LaunchDarklyIntegration(ld_client=client)
207+
208+
209+
@pytest.mark.parametrize(
210+
"use_global_client",
211+
(False, True),
212+
)
213+
def test_launchdarkly_span_integration(
214+
sentry_init, use_global_client, capture_events, uninstall_integration
215+
):
216+
td = TestData.data_source()
217+
td.update(td.flag("hello").variation_for_all(True))
218+
# Disable background requests as we aren't using a server.
219+
config = Config(
220+
"sdk-key", update_processor_class=td, diagnostic_opt_out=True, send_events=False
221+
)
222+
223+
uninstall_integration(LaunchDarklyIntegration.identifier)
224+
if use_global_client:
225+
ldclient.set_config(config)
226+
sentry_init(traces_sample_rate=1.0, integrations=[LaunchDarklyIntegration()])
227+
client = ldclient.get()
228+
else:
229+
client = LDClient(config=config)
230+
sentry_init(
231+
traces_sample_rate=1.0,
232+
integrations=[LaunchDarklyIntegration(ld_client=client)],
233+
)
234+
235+
events = capture_events()
236+
237+
with start_transaction(name="hi"):
238+
with start_span(op="foo", name="bar"):
239+
client.variation("hello", Context.create("my-org", "organization"), False)
240+
client.variation("other", Context.create("my-org", "organization"), False)
241+
242+
(event,) = events
243+
assert event["spans"][0]["data"] == ApproxDict(
244+
{"flag.evaluation.hello": True, "flag.evaluation.other": False}
245+
)

tests/integrations/openfeature/test_openfeature.py

+26
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
88

99
import sentry_sdk
10+
from sentry_sdk import start_span, start_transaction
1011
from sentry_sdk.integrations.openfeature import OpenFeatureIntegration
12+
from tests.conftest import ApproxDict
1113

1214

1315
def test_openfeature_integration(sentry_init, capture_events, uninstall_integration):
@@ -151,3 +153,27 @@ async def runner():
151153
{"flag": "world", "result": False},
152154
]
153155
}
156+
157+
158+
def test_openfeature_span_integration(
159+
sentry_init, capture_events, uninstall_integration
160+
):
161+
uninstall_integration(OpenFeatureIntegration.identifier)
162+
sentry_init(traces_sample_rate=1.0, integrations=[OpenFeatureIntegration()])
163+
164+
api.set_provider(
165+
InMemoryProvider({"hello": InMemoryFlag("on", {"on": True, "off": False})})
166+
)
167+
client = api.get_client()
168+
169+
events = capture_events()
170+
171+
with start_transaction(name="hi"):
172+
with start_span(op="foo", name="bar"):
173+
client.get_boolean_value("hello", default_value=False)
174+
client.get_boolean_value("world", default_value=False)
175+
176+
(event,) = events
177+
assert event["spans"][0]["data"] == ApproxDict(
178+
{"flag.evaluation.hello": True, "flag.evaluation.world": False}
179+
)

tests/integrations/statsig/test_statsig.py

+20
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from statsig.statsig_user import StatsigUser
66
from random import random
77
from unittest.mock import Mock
8+
from sentry_sdk import start_span, start_transaction
9+
from tests.conftest import ApproxDict
810

911
import pytest
1012

@@ -181,3 +183,21 @@ def test_wrapper_attributes(sentry_init, uninstall_integration):
181183

182184
# Clean up
183185
statsig.check_gate = original_check_gate
186+
187+
188+
def test_statsig_span_integration(sentry_init, capture_events, uninstall_integration):
189+
uninstall_integration(StatsigIntegration.identifier)
190+
191+
with mock_statsig({"hello": True}):
192+
sentry_init(traces_sample_rate=1.0, integrations=[StatsigIntegration()])
193+
events = capture_events()
194+
user = StatsigUser(user_id="user-id")
195+
with start_transaction(name="hi"):
196+
with start_span(op="foo", name="bar"):
197+
statsig.check_gate(user, "hello")
198+
statsig.check_gate(user, "world")
199+
200+
(event,) = events
201+
assert event["spans"][0]["data"] == ApproxDict(
202+
{"flag.evaluation.hello": True, "flag.evaluation.world": False}
203+
)

tests/integrations/unleash/test_unleash.py

+20
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
import sentry_sdk
1010
from sentry_sdk.integrations.unleash import UnleashIntegration
11+
from sentry_sdk import start_span, start_transaction
1112
from tests.integrations.unleash.testutils import mock_unleash_client
13+
from tests.conftest import ApproxDict
1214

1315

1416
def test_is_enabled(sentry_init, capture_events, uninstall_integration):
@@ -164,3 +166,21 @@ def test_wrapper_attributes(sentry_init, uninstall_integration):
164166
# Mock clients methods have not lost their qualified names after decoration.
165167
assert client.is_enabled.__name__ == "is_enabled"
166168
assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__
169+
170+
171+
def test_unleash_span_integration(sentry_init, capture_events, uninstall_integration):
172+
uninstall_integration(UnleashIntegration.identifier)
173+
174+
with mock_unleash_client():
175+
sentry_init(traces_sample_rate=1.0, integrations=[UnleashIntegration()])
176+
events = capture_events()
177+
client = UnleashClient() # type: ignore[arg-type]
178+
with start_transaction(name="hi"):
179+
with start_span(op="foo", name="bar"):
180+
client.is_enabled("hello")
181+
client.is_enabled("other")
182+
183+
(event,) = events
184+
assert event["spans"][0]["data"] == ApproxDict(
185+
{"flag.evaluation.hello": True, "flag.evaluation.other": False}
186+
)

tests/test_feature_flags.py

+39
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import sentry_sdk
99
from sentry_sdk.feature_flags import add_feature_flag, FlagBuffer
10+
from sentry_sdk import start_span, start_transaction
11+
from tests.conftest import ApproxDict
1012

1113

1214
def test_featureflags_integration(sentry_init, capture_events, uninstall_integration):
@@ -220,3 +222,40 @@ def reader():
220222
# shared resource. When deepcopying we should have exclusive access to the underlying
221223
# memory.
222224
assert error_occurred is False
225+
226+
227+
def test_flag_limit(sentry_init, capture_events):
228+
sentry_init(traces_sample_rate=1.0)
229+
230+
events = capture_events()
231+
232+
with start_transaction(name="hi"):
233+
with start_span(op="foo", name="bar"):
234+
add_feature_flag("0", True)
235+
add_feature_flag("1", True)
236+
add_feature_flag("2", True)
237+
add_feature_flag("3", True)
238+
add_feature_flag("4", True)
239+
add_feature_flag("5", True)
240+
add_feature_flag("6", True)
241+
add_feature_flag("7", True)
242+
add_feature_flag("8", True)
243+
add_feature_flag("9", True)
244+
add_feature_flag("10", True)
245+
246+
(event,) = events
247+
assert event["spans"][0]["data"] == ApproxDict(
248+
{
249+
"flag.evaluation.0": True,
250+
"flag.evaluation.1": True,
251+
"flag.evaluation.2": True,
252+
"flag.evaluation.3": True,
253+
"flag.evaluation.4": True,
254+
"flag.evaluation.5": True,
255+
"flag.evaluation.6": True,
256+
"flag.evaluation.7": True,
257+
"flag.evaluation.8": True,
258+
"flag.evaluation.9": True,
259+
}
260+
)
261+
assert "flag.evaluation.10" not in event["spans"][0]["data"]

0 commit comments

Comments
 (0)