Skip to content

feat(spans): Record flag evaluations as span attributes #4280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 17, 2025
4 changes: 4 additions & 0 deletions sentry_sdk/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 3 additions & 3 deletions sentry_sdk/integrations/launchdarkly.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 3 additions & 5 deletions sentry_sdk/integrations/openfeature.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)
5 changes: 2 additions & 3 deletions sentry_sdk/integrations/unleash.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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

Expand Down
13 changes: 12 additions & 1 deletion sentry_sdk/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ class Span:
"scope",
"origin",
"name",
"_flags",
"_flags_capacity",
)

def __init__(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions tests/integrations/launchdarkly/test_launchdarkly.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] why are we uninstalling the integration here? My guess is this would reduce the possibility of interactions between tests, but I this is my first time seeing this pattern in our tests so just want to check

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how I found the test suite so I'm following the pattern. I'm not sure why its necessary or what the implications are if its removed. I could look into this more.

Copy link
Member

@szokeasaurusrex szokeasaurusrex Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, perhaps I just haven't touched this part of the codebase – if that's the pattern you can disregard my comment. Maybe @antonpirker or @sentrivana would know why this is needed.

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}
)
26 changes: 26 additions & 0 deletions tests/integrations/openfeature/test_openfeature.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}
)
20 changes: 20 additions & 0 deletions tests/integrations/statsig/test_statsig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}
)
20 changes: 20 additions & 0 deletions tests/integrations/unleash/test_unleash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}
)
39 changes: 39 additions & 0 deletions tests/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"]
Loading