Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c6a89d6

Browse files
aliu39antonpirkercmanallen
authoredJan 7, 2025··
feat(flags): add Unleash feature flagging integration (#3888)
Adds an integration for tracking flag evaluations from [Unleash](https://www.getunleash.io/) customers. Implementation Unleash has no native support for evaluation hooks/listeners, unless the user opts in for each flag. Therefore we decided to patch the `is_enabled` and `get_variant` methods on the `UnleashClient` class. The methods are wrapped and the only side effect is writing to Sentry scope, so users shouldn't see any change in behavior. We patch one `UnleashClient` instance instead of the whole class. The reasons for this are described in - #3895 It's also safer to not modify the unleash import. References - https://develop.sentry.dev/sdk/expected-features/#feature-flags - https://docs.getunleash.io/reference/sdks/python for methods we're patching/wrapping --------- Co-authored-by: Anton Pirker <[email protected]> Co-authored-by: Colton Allen <[email protected]>
1 parent bf65ede commit c6a89d6

File tree

10 files changed

+468
-4
lines changed

10 files changed

+468
-4
lines changed
 

‎.github/workflows/test-integrations-misc.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ jobs:
7979
run: |
8080
set -x # print commands that are executed
8181
./scripts/runtox.sh "py${{ matrix.python-version }}-typer-latest"
82+
- name: Test unleash latest
83+
run: |
84+
set -x # print commands that are executed
85+
./scripts/runtox.sh "py${{ matrix.python-version }}-unleash-latest"
8286
- name: Generate coverage XML (Python 3.6)
8387
if: ${{ !cancelled() && matrix.python-version == '3.6' }}
8488
run: |
@@ -163,6 +167,10 @@ jobs:
163167
run: |
164168
set -x # print commands that are executed
165169
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-typer"
170+
- name: Test unleash pinned
171+
run: |
172+
set -x # print commands that are executed
173+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-unleash"
166174
- name: Generate coverage XML (Python 3.6)
167175
if: ${{ !cancelled() && matrix.python-version == '3.6' }}
168176
run: |

‎requirements-linting.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ pre-commit # local linting
1717
httpcore
1818
openfeature-sdk
1919
launchdarkly-server-sdk
20+
UnleashClient
2021
typer

‎scripts/split_tox_gh_actions/split_tox_gh_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
"pure_eval",
134134
"trytond",
135135
"typer",
136+
"unleash",
136137
],
137138
}
138139

‎sentry_sdk/integrations/unleash.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from functools import wraps
2+
from typing import Any
3+
4+
import sentry_sdk
5+
from sentry_sdk.flag_utils import flag_error_processor
6+
from sentry_sdk.integrations import Integration, DidNotEnable
7+
8+
try:
9+
from UnleashClient import UnleashClient
10+
except ImportError:
11+
raise DidNotEnable("UnleashClient is not installed")
12+
13+
14+
class UnleashIntegration(Integration):
15+
identifier = "unleash"
16+
17+
@staticmethod
18+
def setup_once():
19+
# type: () -> None
20+
# Wrap and patch evaluation methods (instance methods)
21+
old_is_enabled = UnleashClient.is_enabled
22+
old_get_variant = UnleashClient.get_variant
23+
24+
@wraps(old_is_enabled)
25+
def sentry_is_enabled(self, feature, *args, **kwargs):
26+
# type: (UnleashClient, str, *Any, **Any) -> Any
27+
enabled = old_is_enabled(self, feature, *args, **kwargs)
28+
29+
# We have no way of knowing what type of unleash feature this is, so we have to treat
30+
# it as a boolean / toggle feature.
31+
flags = sentry_sdk.get_current_scope().flags
32+
flags.set(feature, enabled)
33+
34+
return enabled
35+
36+
@wraps(old_get_variant)
37+
def sentry_get_variant(self, feature, *args, **kwargs):
38+
# type: (UnleashClient, str, *Any, **Any) -> Any
39+
variant = old_get_variant(self, feature, *args, **kwargs)
40+
enabled = variant.get("enabled", False)
41+
42+
# Payloads are not always used as the feature's value for application logic. They
43+
# may be used for metrics or debugging context instead. Therefore, we treat every
44+
# variant as a boolean toggle, using the `enabled` field.
45+
flags = sentry_sdk.get_current_scope().flags
46+
flags.set(feature, enabled)
47+
48+
return variant
49+
50+
UnleashClient.is_enabled = sentry_is_enabled # type: ignore
51+
UnleashClient.get_variant = sentry_get_variant # type: ignore
52+
53+
# Error processor
54+
scope = sentry_sdk.get_current_scope()
55+
scope.add_error_processor(flag_error_processor)

‎setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def get_file_text(file_name):
8080
"starlette": ["starlette>=0.19.1"],
8181
"starlite": ["starlite>=1.48"],
8282
"tornado": ["tornado>=6"],
83+
"unleash": ["UnleashClient>=6.0.1"],
8384
},
8485
entry_points={
8586
"opentelemetry_propagator": [

‎tests/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111
import jsonschema
1212

13+
1314
try:
1415
import gevent
1516
except ImportError:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("UnleashClient")
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import concurrent.futures as cf
2+
import sys
3+
from random import random
4+
from unittest import mock
5+
from UnleashClient import UnleashClient
6+
7+
import pytest
8+
9+
import sentry_sdk
10+
from sentry_sdk.integrations.unleash import UnleashIntegration
11+
from tests.integrations.unleash.testutils import mock_unleash_client
12+
13+
14+
def test_is_enabled(sentry_init, capture_events, uninstall_integration):
15+
uninstall_integration(UnleashIntegration.identifier)
16+
17+
with mock_unleash_client():
18+
client = UnleashClient()
19+
sentry_init(integrations=[UnleashIntegration()])
20+
client.is_enabled("hello")
21+
client.is_enabled("world")
22+
client.is_enabled("other")
23+
24+
events = capture_events()
25+
sentry_sdk.capture_exception(Exception("something wrong!"))
26+
27+
assert len(events) == 1
28+
assert events[0]["contexts"]["flags"] == {
29+
"values": [
30+
{"flag": "hello", "result": True},
31+
{"flag": "world", "result": False},
32+
{"flag": "other", "result": False},
33+
]
34+
}
35+
36+
37+
def test_get_variant(sentry_init, capture_events, uninstall_integration):
38+
uninstall_integration(UnleashIntegration.identifier)
39+
40+
with mock_unleash_client():
41+
client = UnleashClient()
42+
sentry_init(integrations=[UnleashIntegration()]) # type: ignore
43+
client.get_variant("no_payload_feature")
44+
client.get_variant("string_feature")
45+
client.get_variant("json_feature")
46+
client.get_variant("csv_feature")
47+
client.get_variant("number_feature")
48+
client.get_variant("unknown_feature")
49+
50+
events = capture_events()
51+
sentry_sdk.capture_exception(Exception("something wrong!"))
52+
53+
assert len(events) == 1
54+
assert events[0]["contexts"]["flags"] == {
55+
"values": [
56+
{"flag": "no_payload_feature", "result": True},
57+
{"flag": "string_feature", "result": True},
58+
{"flag": "json_feature", "result": True},
59+
{"flag": "csv_feature", "result": True},
60+
{"flag": "number_feature", "result": True},
61+
{"flag": "unknown_feature", "result": False},
62+
]
63+
}
64+
65+
66+
def test_is_enabled_threaded(sentry_init, capture_events, uninstall_integration):
67+
uninstall_integration(UnleashIntegration.identifier)
68+
69+
with mock_unleash_client():
70+
client = UnleashClient()
71+
sentry_init(integrations=[UnleashIntegration()]) # type: ignore
72+
events = capture_events()
73+
74+
def task(flag_key):
75+
# Creates a new isolation scope for the thread.
76+
# This means the evaluations in each task are captured separately.
77+
with sentry_sdk.isolation_scope():
78+
client.is_enabled(flag_key)
79+
# use a tag to identify to identify events later on
80+
sentry_sdk.set_tag("task_id", flag_key)
81+
sentry_sdk.capture_exception(Exception("something wrong!"))
82+
83+
# Capture an eval before we split isolation scopes.
84+
client.is_enabled("hello")
85+
86+
with cf.ThreadPoolExecutor(max_workers=2) as pool:
87+
pool.map(task, ["world", "other"])
88+
89+
# Capture error in original scope
90+
sentry_sdk.set_tag("task_id", "0")
91+
sentry_sdk.capture_exception(Exception("something wrong!"))
92+
93+
assert len(events) == 3
94+
events.sort(key=lambda e: e["tags"]["task_id"])
95+
96+
assert events[0]["contexts"]["flags"] == {
97+
"values": [
98+
{"flag": "hello", "result": True},
99+
]
100+
}
101+
assert events[1]["contexts"]["flags"] == {
102+
"values": [
103+
{"flag": "hello", "result": True},
104+
{"flag": "other", "result": False},
105+
]
106+
}
107+
assert events[2]["contexts"]["flags"] == {
108+
"values": [
109+
{"flag": "hello", "result": True},
110+
{"flag": "world", "result": False},
111+
]
112+
}
113+
114+
115+
def test_get_variant_threaded(sentry_init, capture_events, uninstall_integration):
116+
uninstall_integration(UnleashIntegration.identifier)
117+
118+
with mock_unleash_client():
119+
client = UnleashClient()
120+
sentry_init(integrations=[UnleashIntegration()]) # type: ignore
121+
events = capture_events()
122+
123+
def task(flag_key):
124+
# Creates a new isolation scope for the thread.
125+
# This means the evaluations in each task are captured separately.
126+
with sentry_sdk.isolation_scope():
127+
client.get_variant(flag_key)
128+
# use a tag to identify to identify events later on
129+
sentry_sdk.set_tag("task_id", flag_key)
130+
sentry_sdk.capture_exception(Exception("something wrong!"))
131+
132+
# Capture an eval before we split isolation scopes.
133+
client.get_variant("hello")
134+
135+
with cf.ThreadPoolExecutor(max_workers=2) as pool:
136+
pool.map(task, ["no_payload_feature", "other"])
137+
138+
# Capture error in original scope
139+
sentry_sdk.set_tag("task_id", "0")
140+
sentry_sdk.capture_exception(Exception("something wrong!"))
141+
142+
assert len(events) == 3
143+
events.sort(key=lambda e: e["tags"]["task_id"])
144+
145+
assert events[0]["contexts"]["flags"] == {
146+
"values": [
147+
{"flag": "hello", "result": False},
148+
]
149+
}
150+
assert events[1]["contexts"]["flags"] == {
151+
"values": [
152+
{"flag": "hello", "result": False},
153+
{"flag": "no_payload_feature", "result": True},
154+
]
155+
}
156+
assert events[2]["contexts"]["flags"] == {
157+
"values": [
158+
{"flag": "hello", "result": False},
159+
{"flag": "other", "result": False},
160+
]
161+
}
162+
163+
164+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
165+
def test_is_enabled_asyncio(sentry_init, capture_events, uninstall_integration):
166+
asyncio = pytest.importorskip("asyncio")
167+
uninstall_integration(UnleashIntegration.identifier)
168+
169+
with mock_unleash_client():
170+
client = UnleashClient()
171+
sentry_init(integrations=[UnleashIntegration()]) # type: ignore
172+
events = capture_events()
173+
174+
async def task(flag_key):
175+
with sentry_sdk.isolation_scope():
176+
client.is_enabled(flag_key)
177+
# use a tag to identify to identify events later on
178+
sentry_sdk.set_tag("task_id", flag_key)
179+
sentry_sdk.capture_exception(Exception("something wrong!"))
180+
181+
async def runner():
182+
return asyncio.gather(task("world"), task("other"))
183+
184+
# Capture an eval before we split isolation scopes.
185+
client.is_enabled("hello")
186+
187+
asyncio.run(runner())
188+
189+
# Capture error in original scope
190+
sentry_sdk.set_tag("task_id", "0")
191+
sentry_sdk.capture_exception(Exception("something wrong!"))
192+
193+
assert len(events) == 3
194+
events.sort(key=lambda e: e["tags"]["task_id"])
195+
196+
assert events[0]["contexts"]["flags"] == {
197+
"values": [
198+
{"flag": "hello", "result": True},
199+
]
200+
}
201+
assert events[1]["contexts"]["flags"] == {
202+
"values": [
203+
{"flag": "hello", "result": True},
204+
{"flag": "other", "result": False},
205+
]
206+
}
207+
assert events[2]["contexts"]["flags"] == {
208+
"values": [
209+
{"flag": "hello", "result": True},
210+
{"flag": "world", "result": False},
211+
]
212+
}
213+
214+
215+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
216+
def test_get_variant_asyncio(sentry_init, capture_events, uninstall_integration):
217+
asyncio = pytest.importorskip("asyncio")
218+
219+
uninstall_integration(UnleashIntegration.identifier)
220+
221+
with mock_unleash_client():
222+
client = UnleashClient()
223+
sentry_init(integrations=[UnleashIntegration()]) # type: ignore
224+
events = capture_events()
225+
226+
async def task(flag_key):
227+
with sentry_sdk.isolation_scope():
228+
client.get_variant(flag_key)
229+
# use a tag to identify to identify events later on
230+
sentry_sdk.set_tag("task_id", flag_key)
231+
sentry_sdk.capture_exception(Exception("something wrong!"))
232+
233+
async def runner():
234+
return asyncio.gather(task("no_payload_feature"), task("other"))
235+
236+
# Capture an eval before we split isolation scopes.
237+
client.get_variant("hello")
238+
239+
asyncio.run(runner())
240+
241+
# Capture error in original scope
242+
sentry_sdk.set_tag("task_id", "0")
243+
sentry_sdk.capture_exception(Exception("something wrong!"))
244+
245+
assert len(events) == 3
246+
events.sort(key=lambda e: e["tags"]["task_id"])
247+
248+
assert events[0]["contexts"]["flags"] == {
249+
"values": [
250+
{"flag": "hello", "result": False},
251+
]
252+
}
253+
assert events[1]["contexts"]["flags"] == {
254+
"values": [
255+
{"flag": "hello", "result": False},
256+
{"flag": "no_payload_feature", "result": True},
257+
]
258+
}
259+
assert events[2]["contexts"]["flags"] == {
260+
"values": [
261+
{"flag": "hello", "result": False},
262+
{"flag": "other", "result": False},
263+
]
264+
}
265+
266+
267+
def test_wraps_original(sentry_init, uninstall_integration):
268+
with mock_unleash_client():
269+
client = UnleashClient()
270+
271+
mock_is_enabled = mock.Mock(return_value=random() < 0.5)
272+
mock_get_variant = mock.Mock(return_value={"enabled": random() < 0.5})
273+
client.is_enabled = mock_is_enabled
274+
client.get_variant = mock_get_variant
275+
276+
uninstall_integration(UnleashIntegration.identifier)
277+
sentry_init(integrations=[UnleashIntegration()]) # type: ignore
278+
279+
res = client.is_enabled("test-flag", "arg", kwarg=1)
280+
assert res == mock_is_enabled.return_value
281+
assert mock_is_enabled.call_args == (
282+
("test-flag", "arg"),
283+
{"kwarg": 1},
284+
)
285+
286+
res = client.get_variant("test-flag", "arg", kwarg=1)
287+
assert res == mock_get_variant.return_value
288+
assert mock_get_variant.call_args == (
289+
("test-flag", "arg"),
290+
{"kwarg": 1},
291+
)
292+
293+
294+
def test_wrapper_attributes(sentry_init, uninstall_integration):
295+
with mock_unleash_client():
296+
client = UnleashClient() # <- Returns a MockUnleashClient
297+
298+
original_is_enabled = client.is_enabled
299+
original_get_variant = client.get_variant
300+
301+
uninstall_integration(UnleashIntegration.identifier)
302+
sentry_init(integrations=[UnleashIntegration()]) # type: ignore
303+
304+
# Mock clients methods have not lost their qualified names after decoration.
305+
assert client.is_enabled.__name__ == "is_enabled"
306+
assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__
307+
assert client.get_variant.__name__ == "get_variant"
308+
assert client.get_variant.__qualname__ == original_get_variant.__qualname__
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from contextlib import contextmanager
2+
from UnleashClient import UnleashClient
3+
4+
5+
@contextmanager
6+
def mock_unleash_client():
7+
"""
8+
Temporarily replaces UnleashClient's methods with mock implementations
9+
for testing.
10+
11+
This context manager swaps out UnleashClient's __init__, is_enabled,
12+
and get_variant methods with mock versions from MockUnleashClient.
13+
Original methods are restored when exiting the context.
14+
15+
After mocking the client class the integration can be initialized.
16+
The methods on the mock client class are overridden by the
17+
integration and flag tracking proceeds as expected.
18+
19+
Example:
20+
with mock_unleash_client():
21+
client = UnleashClient() # Uses mock implementation
22+
sentry_init(integrations=[UnleashIntegration()])
23+
"""
24+
old_init = UnleashClient.__init__
25+
old_is_enabled = UnleashClient.is_enabled
26+
old_get_variant = UnleashClient.get_variant
27+
28+
UnleashClient.__init__ = MockUnleashClient.__init__
29+
UnleashClient.is_enabled = MockUnleashClient.is_enabled
30+
UnleashClient.get_variant = MockUnleashClient.get_variant
31+
32+
yield
33+
34+
UnleashClient.__init__ = old_init
35+
UnleashClient.is_enabled = old_is_enabled
36+
UnleashClient.get_variant = old_get_variant
37+
38+
39+
class MockUnleashClient:
40+
41+
def __init__(self, *a, **kw):
42+
self.features = {
43+
"hello": True,
44+
"world": False,
45+
}
46+
47+
self.feature_to_variant = {
48+
"string_feature": {
49+
"name": "variant1",
50+
"enabled": True,
51+
"payload": {"type": "string", "value": "val1"},
52+
},
53+
"json_feature": {
54+
"name": "variant1",
55+
"enabled": True,
56+
"payload": {"type": "json", "value": '{"key1": 0.53}'},
57+
},
58+
"number_feature": {
59+
"name": "variant1",
60+
"enabled": True,
61+
"payload": {"type": "number", "value": "134.5"},
62+
},
63+
"csv_feature": {
64+
"name": "variant1",
65+
"enabled": True,
66+
"payload": {"type": "csv", "value": "abc 123\ncsbq 94"},
67+
},
68+
"no_payload_feature": {"name": "variant1", "enabled": True},
69+
}
70+
71+
self.disabled_variant = {"name": "disabled", "enabled": False}
72+
73+
def is_enabled(self, feature, *a, **kw):
74+
return self.features.get(feature, False)
75+
76+
def get_variant(self, feature, *a, **kw):
77+
return self.feature_to_variant.get(feature, self.disabled_variant)

‎tox.ini

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ envlist =
168168
{py3.9,py3.11,py3.12}-langchain-latest
169169
{py3.9,py3.11,py3.12}-langchain-notiktoken
170170

171+
# LaunchDarkly
172+
{py3.8,py3.12,py3.13}-launchdarkly-v9.8.0
173+
{py3.8,py3.12,py3.13}-launchdarkly-latest
174+
171175
# Litestar
172176
{py3.8,py3.11}-litestar-v{2.0}
173177
{py3.8,py3.11,py3.12}-litestar-v{2.6}
@@ -189,10 +193,6 @@ envlist =
189193
{py3.8,py3.12,py3.13}-openfeature-v0.7
190194
{py3.8,py3.12,py3.13}-openfeature-latest
191195

192-
# LaunchDarkly
193-
{py3.8,py3.12,py3.13}-launchdarkly-v9.8.0
194-
{py3.8,py3.12,py3.13}-launchdarkly-latest
195-
196196
# OpenTelemetry (OTel)
197197
{py3.7,py3.9,py3.12,py3.13}-opentelemetry
198198

@@ -290,6 +290,10 @@ envlist =
290290
{py3.7,py3.12,py3.13}-typer-v{0.15}
291291
{py3.7,py3.12,py3.13}-typer-latest
292292

293+
# Unleash
294+
{py3.8,py3.12,py3.13}-unleash-v6.0.1
295+
{py3.8,py3.12,py3.13}-unleash-latest
296+
293297
[testenv]
294298
deps =
295299
# if you change requirements-testing.txt and your change is not being reflected
@@ -571,6 +575,10 @@ deps =
571575
launchdarkly-v9.8.0: launchdarkly-server-sdk~=9.8.0
572576
launchdarkly-latest: launchdarkly-server-sdk
573577

578+
# Unleash
579+
unleash-v6.0.1: UnleashClient~=6.0.1
580+
unleash-latest: UnleashClient
581+
574582
# OpenTelemetry (OTel)
575583
opentelemetry: opentelemetry-distro
576584

@@ -793,6 +801,7 @@ setenv =
793801
tornado: TESTPATH=tests/integrations/tornado
794802
trytond: TESTPATH=tests/integrations/trytond
795803
typer: TESTPATH=tests/integrations/typer
804+
unleash: TESTPATH=tests/integrations/unleash
796805
socket: TESTPATH=tests/integrations/socket
797806

798807
passenv =

0 commit comments

Comments
 (0)
Please sign in to comment.