Skip to content

Commit 50222ca

Browse files
aliu39antonpirker
andauthored
feat(flags): Add integration for custom tracking of flag evaluations (#3860)
* Add new integration and unit tests * Test flag values for LD and OF threaded/asyncio, not just flag names * update ffIntegration test to be e2e, and fix LRU copy bug * make a helper fixture and test error processor in original thread * Move api to top-level, rename to add_flag * Add docstrs * Rename to add_feature_flag * Rm extra import in test_lru_cache * Revert lru comment * Type annotate * Review comments * Update launchdarkly and openfeature tests to be e2e * Update docstrs * Skip threading test for <3.7 * Skip ffs asyncio test if 3.6 * undo 'skip threading test' * Try commenting out asyncio * Use importorskip * Import order --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent 4e69cb7 commit 50222ca

File tree

6 files changed

+377
-43
lines changed

6 files changed

+377
-43
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from sentry_sdk.flag_utils import flag_error_processor
2+
3+
import sentry_sdk
4+
from sentry_sdk.integrations import Integration
5+
6+
7+
class FeatureFlagsIntegration(Integration):
8+
"""
9+
Sentry integration for capturing feature flags on error events. To manually buffer flag data,
10+
call `integrations.featureflags.add_feature_flag`. We recommend you do this on each flag
11+
evaluation.
12+
13+
See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags)
14+
for more information.
15+
16+
@example
17+
```
18+
import sentry_sdk
19+
from sentry_sdk.integrations.featureflags import FeatureFlagsIntegration, add_feature_flag
20+
21+
sentry_sdk.init(dsn="my_dsn", integrations=[FeatureFlagsIntegration()]);
22+
23+
add_feature_flag('my-flag', true);
24+
sentry_sdk.capture_exception(Exception('broke')); // 'my-flag' should be captured on this Sentry event.
25+
```
26+
"""
27+
28+
identifier = "featureflags"
29+
30+
@staticmethod
31+
def setup_once():
32+
# type: () -> None
33+
scope = sentry_sdk.get_current_scope()
34+
scope.add_error_processor(flag_error_processor)
35+
36+
37+
def add_feature_flag(flag, result):
38+
# type: (str, bool) -> None
39+
"""
40+
Records a flag and its value to be sent on subsequent error events by FeatureFlagsIntegration.
41+
We recommend you do this on flag evaluations. Flags are buffered per Sentry scope.
42+
"""
43+
flags = sentry_sdk.get_current_scope().flags
44+
flags.set(flag, result)

tests/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,17 @@ def reset_integrations():
184184
_installed_integrations.clear()
185185

186186

187+
@pytest.fixture
188+
def uninstall_integration():
189+
"""Use to force the next call to sentry_init to re-install/setup an integration."""
190+
191+
def inner(identifier):
192+
_processed_integrations.discard(identifier)
193+
_installed_integrations.discard(identifier)
194+
195+
return inner
196+
197+
187198
@pytest.fixture
188199
def sentry_init(request):
189200
def inner(*a, **kw):

tests/integrations/featureflags/__init__.py

Whitespace-only changes.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import concurrent.futures as cf
2+
import sys
3+
4+
import pytest
5+
6+
import sentry_sdk
7+
from sentry_sdk.integrations.featureflags import (
8+
FeatureFlagsIntegration,
9+
add_feature_flag,
10+
)
11+
12+
13+
def test_featureflags_integration(sentry_init, capture_events, uninstall_integration):
14+
uninstall_integration(FeatureFlagsIntegration.identifier)
15+
sentry_init(integrations=[FeatureFlagsIntegration()])
16+
17+
add_feature_flag("hello", False)
18+
add_feature_flag("world", True)
19+
add_feature_flag("other", False)
20+
21+
events = capture_events()
22+
sentry_sdk.capture_exception(Exception("something wrong!"))
23+
24+
assert len(events) == 1
25+
assert events[0]["contexts"]["flags"] == {
26+
"values": [
27+
{"flag": "hello", "result": False},
28+
{"flag": "world", "result": True},
29+
{"flag": "other", "result": False},
30+
]
31+
}
32+
33+
34+
def test_featureflags_integration_threaded(
35+
sentry_init, capture_events, uninstall_integration
36+
):
37+
uninstall_integration(FeatureFlagsIntegration.identifier)
38+
sentry_init(integrations=[FeatureFlagsIntegration()])
39+
events = capture_events()
40+
41+
# Capture an eval before we split isolation scopes.
42+
add_feature_flag("hello", False)
43+
44+
def task(flag_key):
45+
# Creates a new isolation scope for the thread.
46+
# This means the evaluations in each task are captured separately.
47+
with sentry_sdk.isolation_scope():
48+
add_feature_flag(flag_key, False)
49+
# use a tag to identify to identify events later on
50+
sentry_sdk.set_tag("task_id", flag_key)
51+
sentry_sdk.capture_exception(Exception("something wrong!"))
52+
53+
# Run tasks in separate threads
54+
with cf.ThreadPoolExecutor(max_workers=2) as pool:
55+
pool.map(task, ["world", "other"])
56+
57+
# Capture error in original scope
58+
sentry_sdk.set_tag("task_id", "0")
59+
sentry_sdk.capture_exception(Exception("something wrong!"))
60+
61+
assert len(events) == 3
62+
events.sort(key=lambda e: e["tags"]["task_id"])
63+
64+
assert events[0]["contexts"]["flags"] == {
65+
"values": [
66+
{"flag": "hello", "result": False},
67+
]
68+
}
69+
assert events[1]["contexts"]["flags"] == {
70+
"values": [
71+
{"flag": "hello", "result": False},
72+
{"flag": "other", "result": False},
73+
]
74+
}
75+
assert events[2]["contexts"]["flags"] == {
76+
"values": [
77+
{"flag": "hello", "result": False},
78+
{"flag": "world", "result": False},
79+
]
80+
}
81+
82+
83+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
84+
def test_featureflags_integration_asyncio(
85+
sentry_init, capture_events, uninstall_integration
86+
):
87+
asyncio = pytest.importorskip("asyncio")
88+
89+
uninstall_integration(FeatureFlagsIntegration.identifier)
90+
sentry_init(integrations=[FeatureFlagsIntegration()])
91+
events = capture_events()
92+
93+
# Capture an eval before we split isolation scopes.
94+
add_feature_flag("hello", False)
95+
96+
async def task(flag_key):
97+
# Creates a new isolation scope for the thread.
98+
# This means the evaluations in each task are captured separately.
99+
with sentry_sdk.isolation_scope():
100+
add_feature_flag(flag_key, False)
101+
# use a tag to identify to identify events later on
102+
sentry_sdk.set_tag("task_id", flag_key)
103+
sentry_sdk.capture_exception(Exception("something wrong!"))
104+
105+
async def runner():
106+
return asyncio.gather(task("world"), task("other"))
107+
108+
asyncio.run(runner())
109+
110+
# Capture error in original scope
111+
sentry_sdk.set_tag("task_id", "0")
112+
sentry_sdk.capture_exception(Exception("something wrong!"))
113+
114+
assert len(events) == 3
115+
events.sort(key=lambda e: e["tags"]["task_id"])
116+
117+
assert events[0]["contexts"]["flags"] == {
118+
"values": [
119+
{"flag": "hello", "result": False},
120+
]
121+
}
122+
assert events[1]["contexts"]["flags"] == {
123+
"values": [
124+
{"flag": "hello", "result": False},
125+
{"flag": "other", "result": False},
126+
]
127+
}
128+
assert events[2]["contexts"]["flags"] == {
129+
"values": [
130+
{"flag": "hello", "result": False},
131+
{"flag": "world", "result": False},
132+
]
133+
}

tests/integrations/launchdarkly/test_launchdarkly.py

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
1-
import asyncio
21
import concurrent.futures as cf
2+
import sys
33

44
import ldclient
5-
6-
import sentry_sdk
75
import pytest
86

97
from ldclient import LDClient
108
from ldclient.config import Config
119
from ldclient.context import Context
1210
from ldclient.integrations.test_data import TestData
1311

12+
import sentry_sdk
1413
from sentry_sdk.integrations import DidNotEnable
1514
from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration
1615

@@ -19,9 +18,13 @@
1918
"use_global_client",
2019
(False, True),
2120
)
22-
def test_launchdarkly_integration(sentry_init, use_global_client):
21+
def test_launchdarkly_integration(
22+
sentry_init, use_global_client, capture_events, uninstall_integration
23+
):
2324
td = TestData.data_source()
2425
config = Config("sdk-key", update_processor_class=td)
26+
27+
uninstall_integration(LaunchDarklyIntegration.identifier)
2528
if use_global_client:
2629
ldclient.set_config(config)
2730
sentry_init(integrations=[LaunchDarklyIntegration()])
@@ -39,60 +42,130 @@ def test_launchdarkly_integration(sentry_init, use_global_client):
3942
client.variation("world", Context.create("user1", "user"), False)
4043
client.variation("other", Context.create("user2", "user"), False)
4144

42-
assert sentry_sdk.get_current_scope().flags.get() == [
43-
{"flag": "hello", "result": True},
44-
{"flag": "world", "result": True},
45-
{"flag": "other", "result": False},
46-
]
45+
events = capture_events()
46+
sentry_sdk.capture_exception(Exception("something wrong!"))
4747

48+
assert len(events) == 1
49+
assert events[0]["contexts"]["flags"] == {
50+
"values": [
51+
{"flag": "hello", "result": True},
52+
{"flag": "world", "result": True},
53+
{"flag": "other", "result": False},
54+
]
55+
}
4856

49-
def test_launchdarkly_integration_threaded(sentry_init):
57+
58+
def test_launchdarkly_integration_threaded(
59+
sentry_init, capture_events, uninstall_integration
60+
):
5061
td = TestData.data_source()
5162
client = LDClient(config=Config("sdk-key", update_processor_class=td))
52-
sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
5363
context = Context.create("user1")
5464

65+
uninstall_integration(LaunchDarklyIntegration.identifier)
66+
sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
67+
events = capture_events()
68+
5569
def task(flag_key):
5670
# Creates a new isolation scope for the thread.
5771
# This means the evaluations in each task are captured separately.
5872
with sentry_sdk.isolation_scope():
5973
client.variation(flag_key, context, False)
60-
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
74+
# use a tag to identify to identify events later on
75+
sentry_sdk.set_tag("task_id", flag_key)
76+
sentry_sdk.capture_exception(Exception("something wrong!"))
6177

6278
td.update(td.flag("hello").variation_for_all(True))
6379
td.update(td.flag("world").variation_for_all(False))
6480
# Capture an eval before we split isolation scopes.
6581
client.variation("hello", context, False)
6682

6783
with cf.ThreadPoolExecutor(max_workers=2) as pool:
68-
results = list(pool.map(task, ["world", "other"]))
69-
70-
assert results[0] == ["hello", "world"]
71-
assert results[1] == ["hello", "other"]
84+
pool.map(task, ["world", "other"])
85+
86+
# Capture error in original scope
87+
sentry_sdk.set_tag("task_id", "0")
88+
sentry_sdk.capture_exception(Exception("something wrong!"))
89+
90+
assert len(events) == 3
91+
events.sort(key=lambda e: e["tags"]["task_id"])
92+
93+
assert events[0]["contexts"]["flags"] == {
94+
"values": [
95+
{"flag": "hello", "result": True},
96+
]
97+
}
98+
assert events[1]["contexts"]["flags"] == {
99+
"values": [
100+
{"flag": "hello", "result": True},
101+
{"flag": "other", "result": False},
102+
]
103+
}
104+
assert events[2]["contexts"]["flags"] == {
105+
"values": [
106+
{"flag": "hello", "result": True},
107+
{"flag": "world", "result": False},
108+
]
109+
}
110+
111+
112+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
113+
def test_launchdarkly_integration_asyncio(
114+
sentry_init, capture_events, uninstall_integration
115+
):
116+
"""Assert concurrently evaluated flags do not pollute one another."""
72117

118+
asyncio = pytest.importorskip("asyncio")
73119

74-
def test_launchdarkly_integration_asyncio(sentry_init):
75-
"""Assert concurrently evaluated flags do not pollute one another."""
76120
td = TestData.data_source()
77121
client = LDClient(config=Config("sdk-key", update_processor_class=td))
78-
sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
79122
context = Context.create("user1")
80123

124+
uninstall_integration(LaunchDarklyIntegration.identifier)
125+
sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
126+
events = capture_events()
127+
81128
async def task(flag_key):
82129
with sentry_sdk.isolation_scope():
83130
client.variation(flag_key, context, False)
84-
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
131+
# use a tag to identify to identify events later on
132+
sentry_sdk.set_tag("task_id", flag_key)
133+
sentry_sdk.capture_exception(Exception("something wrong!"))
85134

86135
async def runner():
87136
return asyncio.gather(task("world"), task("other"))
88137

89138
td.update(td.flag("hello").variation_for_all(True))
90139
td.update(td.flag("world").variation_for_all(False))
140+
# Capture an eval before we split isolation scopes.
91141
client.variation("hello", context, False)
92142

93-
results = asyncio.run(runner()).result()
94-
assert results[0] == ["hello", "world"]
95-
assert results[1] == ["hello", "other"]
143+
asyncio.run(runner())
144+
145+
# Capture error in original scope
146+
sentry_sdk.set_tag("task_id", "0")
147+
sentry_sdk.capture_exception(Exception("something wrong!"))
148+
149+
assert len(events) == 3
150+
events.sort(key=lambda e: e["tags"]["task_id"])
151+
152+
assert events[0]["contexts"]["flags"] == {
153+
"values": [
154+
{"flag": "hello", "result": True},
155+
]
156+
}
157+
assert events[1]["contexts"]["flags"] == {
158+
"values": [
159+
{"flag": "hello", "result": True},
160+
{"flag": "other", "result": False},
161+
]
162+
}
163+
assert events[2]["contexts"]["flags"] == {
164+
"values": [
165+
{"flag": "hello", "result": True},
166+
{"flag": "world", "result": False},
167+
]
168+
}
96169

97170

98171
def test_launchdarkly_integration_did_not_enable(monkeypatch):

0 commit comments

Comments
 (0)