Skip to content

Commit 7a1c010

Browse files
aliu39sentrivana
andauthored
feat(flags): add Statsig integration (#4022)
New integration for tracking [Statsig](https://docs.statsig.com/server/pythonSDK) ([pypi](https://pypi.org/project/statsig/)) flag evaluations, specifically the checkGate method which is used for boolean release flags. Unlike JS, there's no support for event callbacks for Statsig's server SDKs. Instead we wrap the module-level `check_gate` function. Ref https://develop.sentry.dev/sdk/expected-features/#feature-flags Ref - getsentry/team-replay#538 --------- Co-authored-by: Ivana Kellyer <[email protected]>
1 parent 73a61c6 commit 7a1c010

File tree

11 files changed

+248
-2
lines changed

11 files changed

+248
-2
lines changed

.github/workflows/test-integrations-flags.yml

+8
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ jobs:
5555
run: |
5656
set -x # print commands that are executed
5757
./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature-latest"
58+
- name: Test statsig latest
59+
run: |
60+
set -x # print commands that are executed
61+
./scripts/runtox.sh "py${{ matrix.python-version }}-statsig-latest"
5862
- name: Test unleash latest
5963
run: |
6064
set -x # print commands that are executed
@@ -119,6 +123,10 @@ jobs:
119123
run: |
120124
set -x # print commands that are executed
121125
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openfeature"
126+
- name: Test statsig pinned
127+
run: |
128+
set -x # print commands that are executed
129+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-statsig"
122130
- name: Test unleash pinned
123131
run: |
124132
set -x # print commands that are executed

requirements-linting.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ flake8-bugbear
1515
pep8-naming
1616
pre-commit # local linting
1717
httpcore
18-
openfeature-sdk
1918
launchdarkly-server-sdk
19+
openfeature-sdk
20+
statsig
2021
UnleashClient
2122
typer
2223
strawberry-graphql

scripts/populate_tox/populate_tox.py

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"spark",
101101
"starlette",
102102
"starlite",
103+
"statsig",
103104
"sqlalchemy",
104105
"tornado",
105106
"trytond",

scripts/split_tox_gh_actions/split_tox_gh_actions.py

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"Flags": [
8888
"launchdarkly",
8989
"openfeature",
90+
"statsig",
9091
"unleash",
9192
],
9293
"Gevent": [

sentry_sdk/integrations/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def iter_default_integrations(with_auto_enabling_integrations):
151151
"sanic": (0, 8),
152152
"sqlalchemy": (1, 2),
153153
"starlite": (1, 48),
154+
"statsig": (0, 55, 3),
154155
"strawberry": (0, 209, 5),
155156
"tornado": (6, 0),
156157
"typer": (0, 15),

sentry_sdk/integrations/statsig.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from functools import wraps
2+
from typing import Any, TYPE_CHECKING
3+
4+
from sentry_sdk.feature_flags import add_feature_flag
5+
from sentry_sdk.integrations import Integration, DidNotEnable, _check_minimum_version
6+
from sentry_sdk.utils import parse_version
7+
8+
try:
9+
from statsig import statsig as statsig_module
10+
from statsig.version import __version__ as STATSIG_VERSION
11+
except ImportError:
12+
raise DidNotEnable("statsig is not installed")
13+
14+
if TYPE_CHECKING:
15+
from statsig.statsig_user import StatsigUser
16+
17+
18+
class StatsigIntegration(Integration):
19+
identifier = "statsig"
20+
21+
@staticmethod
22+
def setup_once():
23+
# type: () -> None
24+
version = parse_version(STATSIG_VERSION)
25+
_check_minimum_version(StatsigIntegration, version, "statsig")
26+
27+
# Wrap and patch evaluation method(s) in the statsig module
28+
old_check_gate = statsig_module.check_gate
29+
30+
@wraps(old_check_gate)
31+
def sentry_check_gate(user, gate, *args, **kwargs):
32+
# type: (StatsigUser, str, *Any, **Any) -> Any
33+
enabled = old_check_gate(user, gate, *args, **kwargs)
34+
add_feature_flag(gate, enabled)
35+
return enabled
36+
37+
statsig_module.check_gate = sentry_check_gate

sentry_sdk/integrations/unleash.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class UnleashIntegration(Integration):
1616
@staticmethod
1717
def setup_once():
1818
# type: () -> None
19-
# Wrap and patch evaluation methods (instance methods)
19+
# Wrap and patch evaluation methods (class methods)
2020
old_is_enabled = UnleashClient.is_enabled
2121

2222
@wraps(old_is_enabled)

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def get_file_text(file_name):
7979
"sqlalchemy": ["sqlalchemy>=1.2"],
8080
"starlette": ["starlette>=0.19.1"],
8181
"starlite": ["starlite>=1.48"],
82+
"statsig": ["statsig>=0.55.3"],
8283
"tornado": ["tornado>=6"],
8384
"unleash": ["UnleashClient>=6.0.1"],
8485
},
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("statsig")
+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import concurrent.futures as cf
2+
import sys
3+
from contextlib import contextmanager
4+
from statsig import statsig
5+
from statsig.statsig_user import StatsigUser
6+
from random import random
7+
from unittest.mock import Mock
8+
9+
import pytest
10+
11+
import sentry_sdk
12+
from sentry_sdk.integrations.statsig import StatsigIntegration
13+
14+
15+
@contextmanager
16+
def mock_statsig(gate_dict):
17+
old_check_gate = statsig.check_gate
18+
19+
def mock_check_gate(user, gate, *args, **kwargs):
20+
return gate_dict.get(gate, False)
21+
22+
statsig.check_gate = Mock(side_effect=mock_check_gate)
23+
24+
yield
25+
26+
statsig.check_gate = old_check_gate
27+
28+
29+
def test_check_gate(sentry_init, capture_events, uninstall_integration):
30+
uninstall_integration(StatsigIntegration.identifier)
31+
32+
with mock_statsig({"hello": True, "world": False}):
33+
sentry_init(integrations=[StatsigIntegration()])
34+
events = capture_events()
35+
user = StatsigUser(user_id="user-id")
36+
37+
statsig.check_gate(user, "hello")
38+
statsig.check_gate(user, "world")
39+
statsig.check_gate(user, "other") # unknown gates default to False.
40+
41+
sentry_sdk.capture_exception(Exception("something wrong!"))
42+
43+
assert len(events) == 1
44+
assert events[0]["contexts"]["flags"] == {
45+
"values": [
46+
{"flag": "hello", "result": True},
47+
{"flag": "world", "result": False},
48+
{"flag": "other", "result": False},
49+
]
50+
}
51+
52+
53+
def test_check_gate_threaded(sentry_init, capture_events, uninstall_integration):
54+
uninstall_integration(StatsigIntegration.identifier)
55+
56+
with mock_statsig({"hello": True, "world": False}):
57+
sentry_init(integrations=[StatsigIntegration()])
58+
events = capture_events()
59+
user = StatsigUser(user_id="user-id")
60+
61+
# Capture an eval before we split isolation scopes.
62+
statsig.check_gate(user, "hello")
63+
64+
def task(flag_key):
65+
# Creates a new isolation scope for the thread.
66+
# This means the evaluations in each task are captured separately.
67+
with sentry_sdk.isolation_scope():
68+
statsig.check_gate(user, flag_key)
69+
# use a tag to identify to identify events later on
70+
sentry_sdk.set_tag("task_id", flag_key)
71+
sentry_sdk.capture_exception(Exception("something wrong!"))
72+
73+
with cf.ThreadPoolExecutor(max_workers=2) as pool:
74+
pool.map(task, ["world", "other"])
75+
76+
# Capture error in original scope
77+
sentry_sdk.set_tag("task_id", "0")
78+
sentry_sdk.capture_exception(Exception("something wrong!"))
79+
80+
assert len(events) == 3
81+
events.sort(key=lambda e: e["tags"]["task_id"])
82+
83+
assert events[0]["contexts"]["flags"] == {
84+
"values": [
85+
{"flag": "hello", "result": True},
86+
]
87+
}
88+
assert events[1]["contexts"]["flags"] == {
89+
"values": [
90+
{"flag": "hello", "result": True},
91+
{"flag": "other", "result": False},
92+
]
93+
}
94+
assert events[2]["contexts"]["flags"] == {
95+
"values": [
96+
{"flag": "hello", "result": True},
97+
{"flag": "world", "result": False},
98+
]
99+
}
100+
101+
102+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
103+
def test_check_gate_asyncio(sentry_init, capture_events, uninstall_integration):
104+
asyncio = pytest.importorskip("asyncio")
105+
uninstall_integration(StatsigIntegration.identifier)
106+
107+
with mock_statsig({"hello": True, "world": False}):
108+
sentry_init(integrations=[StatsigIntegration()])
109+
events = capture_events()
110+
user = StatsigUser(user_id="user-id")
111+
112+
# Capture an eval before we split isolation scopes.
113+
statsig.check_gate(user, "hello")
114+
115+
async def task(flag_key):
116+
with sentry_sdk.isolation_scope():
117+
statsig.check_gate(user, flag_key)
118+
# use a tag to identify to identify events later on
119+
sentry_sdk.set_tag("task_id", flag_key)
120+
sentry_sdk.capture_exception(Exception("something wrong!"))
121+
122+
async def runner():
123+
return asyncio.gather(task("world"), task("other"))
124+
125+
asyncio.run(runner())
126+
127+
# Capture error in original scope
128+
sentry_sdk.set_tag("task_id", "0")
129+
sentry_sdk.capture_exception(Exception("something wrong!"))
130+
131+
assert len(events) == 3
132+
events.sort(key=lambda e: e["tags"]["task_id"])
133+
134+
assert events[0]["contexts"]["flags"] == {
135+
"values": [
136+
{"flag": "hello", "result": True},
137+
]
138+
}
139+
assert events[1]["contexts"]["flags"] == {
140+
"values": [
141+
{"flag": "hello", "result": True},
142+
{"flag": "other", "result": False},
143+
]
144+
}
145+
assert events[2]["contexts"]["flags"] == {
146+
"values": [
147+
{"flag": "hello", "result": True},
148+
{"flag": "world", "result": False},
149+
]
150+
}
151+
152+
153+
def test_wraps_original(sentry_init, uninstall_integration):
154+
uninstall_integration(StatsigIntegration.identifier)
155+
flag_value = random() < 0.5
156+
157+
with mock_statsig(
158+
{"test-flag": flag_value}
159+
): # patches check_gate with a Mock object.
160+
mock_check_gate = statsig.check_gate
161+
sentry_init(integrations=[StatsigIntegration()]) # wraps check_gate.
162+
user = StatsigUser(user_id="user-id")
163+
164+
res = statsig.check_gate(user, "test-flag", "extra-arg", kwarg=1) # type: ignore[arg-type]
165+
166+
assert res == flag_value
167+
assert mock_check_gate.call_args == ( # type: ignore[attr-defined]
168+
(user, "test-flag", "extra-arg"),
169+
{"kwarg": 1},
170+
)
171+
172+
173+
def test_wrapper_attributes(sentry_init, uninstall_integration):
174+
uninstall_integration(StatsigIntegration.identifier)
175+
original_check_gate = statsig.check_gate
176+
sentry_init(integrations=[StatsigIntegration()])
177+
178+
# Methods have not lost their qualified names after decoration.
179+
assert statsig.check_gate.__name__ == "check_gate"
180+
assert statsig.check_gate.__qualname__ == original_check_gate.__qualname__
181+
182+
# Clean up
183+
statsig.check_gate = original_check_gate

tox.ini

+10
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ envlist =
259259
{py3.8,py3.11}-starlite-v{1.48,1.51}
260260
# 1.51.14 is the last starlite version; the project continues as litestar
261261

262+
# Statsig
263+
{py3.8,py3.12,py3.13}-statsig-v0.55.3
264+
{py3.8,py3.12,py3.13}-statsig-latest
265+
262266
# SQL Alchemy
263267
{py3.6,py3.9}-sqlalchemy-v{1.2,1.4}
264268
{py3.7,py3.11}-sqlalchemy-v{2.0}
@@ -697,6 +701,11 @@ deps =
697701
starlite-v{1.48}: starlite~=1.48.0
698702
starlite-v{1.51}: starlite~=1.51.0
699703

704+
# Statsig
705+
statsig: typing_extensions
706+
statsig-v0.55.3: statsig~=0.55.3
707+
statsig-latest: statsig
708+
700709
# SQLAlchemy
701710
sqlalchemy-v1.2: sqlalchemy~=1.2.0
702711
sqlalchemy-v1.4: sqlalchemy~=1.4.0
@@ -815,6 +824,7 @@ setenv =
815824
starlette: TESTPATH=tests/integrations/starlette
816825
starlite: TESTPATH=tests/integrations/starlite
817826
sqlalchemy: TESTPATH=tests/integrations/sqlalchemy
827+
statsig: TESTPATH=tests/integrations/statsig
818828
strawberry: TESTPATH=tests/integrations/strawberry
819829
tornado: TESTPATH=tests/integrations/tornado
820830
trytond: TESTPATH=tests/integrations/trytond

0 commit comments

Comments
 (0)