Repository: getsentry/sentry-python
Pull request #4280: feat(spans): Record flag evaluations as span attributes
Author: login: cmanallen
Created: 2025-04-10T18:57:40Z | Merged: 2025-04-17T13:24:49Z

Flags evaluated within a span are appended to the span as attributes.


Diff:
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"]


Reviews:
user: login: szokeasaurusrex
body: Generally lgtm, although I have some questions on some items which were unclear to me.

Also, prior to merging these changes, we should settle on semantic conventions for feature flag span data keys and add them to our [develop docs](https://develop.sentry.dev/sdk/telemetry/traces/span-data-conventions/) and/or the [semantic convention docs](https://getsentry.github.io/sentry-conventions/generated/attributes/). I discussed the span conventions with @AbhiPrasad, it sounds like he has some ideas about how we should name span data keys, so you should discuss with him.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T11:46:52Z
user: login: cmanallen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T13:04:15Z
user: login: szokeasaurusrex
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T13:05:08Z
user: login: cmanallen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T13:05:53Z
user: login: cmanallen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T13:07:36Z
user: login: szokeasaurusrex
body: Awesome, hadn't seen those other conversations/PRs
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T13:33:39Z
user: login: AbhiPrasad
body: Exciting!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T21:22:14Z

Review comments:
diff_hunk: @@ -24,11 +24,19 @@ def sentry_is_enabled(self, feature, *args, **kwargs):
             # type: (UnleashClient, str, *Any, **Any) -> Any
             enabled = old_is_enabled(self, feature, *args, **kwargs)
 
+            # Errors support.
+            #
             # 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)
 
+            # Spans support.
+            span = sentry_sdk.get_current_span()
+            print(span)
path: sentry_sdk/integrations/unleash.py
user: login: szokeasaurusrex
body: I'm guessing you added this for debugging

```suggestion
```
created_at: 2025-04-11T11:19:41Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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)
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: szokeasaurusrex
body: [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
created_at: 2025-04-11T11:21:24Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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, integrations=[LaunchDarklyIntegration()])
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: szokeasaurusrex
body: `traces_sample_rate` is typed as `Optional[float]`

https://github.com/getsentry/sentry-python/blob/ef0dc37eb666bbd7e2d68f67d1ca62d10c640b4f/sentry_sdk/consts.py#L528

So please pass `1.0` instead of just `1`

```suggestion
        sentry_init(traces_sample_rate=1.0, integrations=[LaunchDarklyIntegration()])
```
created_at: 2025-04-11T11:25:01Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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, integrations=[LaunchDarklyIntegration()])
+        client = ldclient.get()
+    else:
+        client = LDClient(config=config)
+        sentry_init(
+            traces_sample_rate=1,
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: szokeasaurusrex
body: ```suggestion
            traces_sample_rate=1.0,
```
created_at: 2025-04-11T11:25:43Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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, integrations=[LaunchDarklyIntegration()])
+        client = ldclient.get()
+    else:
+        client = LDClient(config=config)
+        sentry_init(
+            traces_sample_rate=1,
+            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.hello": True, "flag.other": False}
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: szokeasaurusrex
body: [question] maybe I am missing something or just don't understand how `LaunchDarkly` works, but I am struggling to see why these two flags have different values. It seems like the calls involving both are equivalent, so I am unsure then how they get to have different values. Where does the difference come from?
created_at: 2025-04-11T11:29:02Z
author_association: MEMBER
diff_hunk: @@ -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, integrations=[StatsigIntegration()])
path: tests/integrations/statsig/test_statsig.py
user: login: szokeasaurusrex
body: ```suggestion
        sentry_init(traces_sample_rate=1.0, integrations=[StatsigIntegration()])
```
created_at: 2025-04-11T11:36:47Z
author_association: MEMBER
diff_hunk: @@ -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, integrations=[UnleashIntegration()])
path: tests/integrations/unleash/test_unleash.py
user: login: szokeasaurusrex
body: ```suggestion
        sentry_init(traces_sample_rate=1.0, integrations=[UnleashIntegration()])
```
created_at: 2025-04-11T11:37:09Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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, integrations=[LaunchDarklyIntegration()])
+        client = ldclient.get()
+    else:
+        client = LDClient(config=config)
+        sentry_init(
+            traces_sample_rate=1,
+            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.hello": True, "flag.other": False}
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: szokeasaurusrex
body: Ah, never mind. Now I see that above, you set the flags `"hello"` and `"world"` both to `True`. You did not set `"other"` at all, so I guess that is why it ends up being `False`. Is that correct?

Why are we setting the `"world"` flag at all, if we don't end up asserting on it? I think a code comment to describe why we set `"world"` but then assert on `"other"` might help me and anyone else who looks at this code in the future to understand what is going on here.
created_at: 2025-04-11T11:42:38Z
author_association: MEMBER
diff_hunk: @@ -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, 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.hello": True, "flag.other": False}
+    )
path: tests/integrations/unleash/test_unleash.py
user: login: szokeasaurusrex
body: Are you intentionally checking the `"other"` flag here?

From what I can tell, the `mock_unleash_client` is defining a `"hello"` and `"world"` flag:

https://github.com/getsentry/sentry-python/blob/ef0dc37eb666bbd7e2d68f67d1ca62d10c640b4f/tests/integrations/unleash/testutils.py#L36-L45

Most of the other tests (besides `LaunchDarkly`, which I also commented on) test against the `"hello"` and `"world"` flags, what is the reason for checking `"other"` here instead of `"world"`?
created_at: 2025-04-11T11:43:49Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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)
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: cmanallen
body: 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.
created_at: 2025-04-11T13:04:14Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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)
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: szokeasaurusrex
body: 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.
created_at: 2025-04-11T13:05:07Z
author_association: MEMBER
diff_hunk: @@ -202,3 +204,43 @@ 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))
+    td.update(td.flag("world").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, integrations=[LaunchDarklyIntegration()])
+        client = ldclient.get()
+    else:
+        client = LDClient(config=config)
+        sentry_init(
+            traces_sample_rate=1,
+            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.hello": True, "flag.other": False}
path: tests/integrations/launchdarkly/test_launchdarkly.py
user: login: cmanallen
body: Removed the world variation.
created_at: 2025-04-11T13:05:53Z
author_association: MEMBER
diff_hunk: @@ -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, 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.hello": True, "flag.other": False}
+    )
path: tests/integrations/unleash/test_unleash.py
user: login: cmanallen
body: https://github.com/getsentry/sentry-python/blob/b391602d2a98d900070f0497073828b1d48084ec/tests/integrations/unleash/testutils.py#L45

Testing for missing keys.  Default value is `False`.
created_at: 2025-04-11T13:07:36Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-10T19:04:36Z
body: ## [Codecov](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 80.30%. Comparing base [(`6000f87`)](https://app.codecov.io/gh/getsentry/sentry-python/commit/6000f87d2d3ec77fc4a1ec391d357ff3969a873b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) to head [(`0934e31`)](https://app.codecov.io/gh/getsentry/sentry-python/commit/0934e311601bc28dd144e10b3106b3bd6216cee2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry).
> Report is 19 commits behind head on master.

:white_check_mark: All tests successful. No failed tests found.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #4280      +/-   ##
==========================================
+ Coverage   79.54%   80.30%   +0.75%     
==========================================
  Files         142      142              
  Lines       15907    15925      +18     
  Branches     2723     2724       +1     
==========================================
+ Hits        12654    12788     +134     
+ Misses       2389     2262     -127     
- Partials      864      875      +11     
```

| [Files with missing lines](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry) | Coverage Δ | |
|---|---|---|
| [sentry\_sdk/feature\_flags.py](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280?src=pr&el=tree&filepath=sentry_sdk%2Ffeature_flags.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c2VudHJ5X3Nkay9mZWF0dXJlX2ZsYWdzLnB5) | `82.85% <100.00%> (+1.60%)` | :arrow_up: |
| [sentry\_sdk/integrations/launchdarkly.py](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280?src=pr&el=tree&filepath=sentry_sdk%2Fintegrations%2Flaunchdarkly.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c2VudHJ5X3Nkay9pbnRlZ3JhdGlvbnMvbGF1bmNoZGFya2x5LnB5) | `77.77% <100.00%> (-0.61%)` | :arrow_down: |
| [sentry\_sdk/integrations/openfeature.py](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280?src=pr&el=tree&filepath=sentry_sdk%2Fintegrations%2Fopenfeature.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c2VudHJ5X3Nkay9pbnRlZ3JhdGlvbnMvb3BlbmZlYXR1cmUucHk=) | `69.56% <100.00%> (-2.44%)` | :arrow_down: |
| [sentry\_sdk/integrations/unleash.py](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280?src=pr&el=tree&filepath=sentry_sdk%2Fintegrations%2Funleash.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c2VudHJ5X3Nkay9pbnRlZ3JhdGlvbnMvdW5sZWFzaC5weQ==) | `89.47% <100.00%> (-0.53%)` | :arrow_down: |
| [sentry\_sdk/tracing.py](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280?src=pr&el=tree&filepath=sentry_sdk%2Ftracing.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry#diff-c2VudHJ5X3Nkay90cmFjaW5nLnB5) | `78.44% <100.00%> (+0.82%)` | :arrow_up: |

... and [9 files with indirect coverage changes](https://app.codecov.io/gh/getsentry/sentry-python/pull/4280/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getsentry)

</details>
author_association: NONE
user: login: cmanallen
created_at: 2025-04-10T19:10:36Z
body: Or to simplify we could remove the span specific recording in each integration and on each call to `start_span` we extract the last `n` flags from the flags buffer.  Then the integrations are only responsible for recording flags in the buffer.  The SDK manages where they go.

But the down side of that is the flags buffer has no idea what span the flag was evaluated in.  So it violates the contract that only flags evaluated _within the span_ are appended to the span as attributes.  So we'd need a second buffer which empties after every call to `start_span`.  Which we might need anyway if we're going to cap the number of flag attributes we send on a span.
author_association: MEMBER
user: login: cmanallen
created_at: 2025-04-10T19:16:03Z
body: What facilities do we have for capping span attributes?  Could a customer send a span with one million attributes today?
author_association: MEMBER
user: login: indragiek
created_at: 2025-04-10T22:23:52Z
body: @cmanallen We don't cap the # of span attributes today but we will likely cap a) the total size of the event b) the number of attributes or c) both - we haven't made a decision on this yet.
author_association: MEMBER
user: login: cmanallen
created_at: 2025-04-11T17:02:04Z
body: Now capping the number of flags to `10`.  No particular reason for that default.  We can make it larger or smaller.
author_association: MEMBER
user: login: cmanallen
created_at: 2025-04-16T13:17:22Z
body: @szokeasaurusrex Did you have any more thoughts on this PR?
author_association: MEMBER
user: login: szokeasaurusrex
created_at: 2025-04-16T13:19:51Z
body: @cmanallen nothing to add, just please make sure to address my previous comment: https://github.com/getsentry/sentry-python/pull/4280#pullrequestreview-2760010843

Specifically to make sure the semantic convention is agreed upon and documented. @AbhiPrasad had some thoughts on this, I can put him as a reviewer
author_association: MEMBER
user: login: cmanallen
created_at: 2025-04-16T13:21:08Z
body: @szokeasaurusrex https://github.com/getsentry/sentry-conventions/pull/42 https://github.com/getsentry/sentry-docs/pull/13362
author_association: MEMBER