Skip to content

feat(flags): add Statsig integration #4022

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 11 commits into from
Feb 12, 2025
Merged
8 changes: 8 additions & 0 deletions .github/workflows/test-integrations-flags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature-latest"
- name: Test statsig latest
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-statsig-latest"
- name: Test unleash latest
run: |
set -x # print commands that are executed
Expand Down Expand Up @@ -119,6 +123,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openfeature"
- name: Test statsig pinned
run: |
set -x # print commands that are executed
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-statsig"
- name: Test unleash pinned
run: |
set -x # print commands that are executed
Expand Down
3 changes: 2 additions & 1 deletion requirements-linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ flake8-bugbear
pep8-naming
pre-commit # local linting
httpcore
openfeature-sdk
launchdarkly-server-sdk
openfeature-sdk
statsig
UnleashClient
typer
strawberry-graphql
1 change: 1 addition & 0 deletions scripts/populate_tox/populate_tox.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
"spark",
"starlette",
"starlite",
"statsig",
"sqlalchemy",
"strawberry",
"tornado",
Expand Down
1 change: 1 addition & 0 deletions scripts/split_tox_gh_actions/split_tox_gh_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"Flags": [
"launchdarkly",
"openfeature",
"statsig",
"unleash",
],
"Gevent": [
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ def iter_default_integrations(with_auto_enabling_integrations):
"sanic": (0, 8),
"sqlalchemy": (1, 2),
"starlite": (1, 48),
"statsig": (0, 55, 3),
"strawberry": (0, 209, 5),
"tornado": (6, 0),
"typer": (0, 15),
Expand Down
37 changes: 37 additions & 0 deletions sentry_sdk/integrations/statsig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from functools import wraps
from typing import Any, TYPE_CHECKING

from sentry_sdk.feature_flags import add_feature_flag
from sentry_sdk.integrations import Integration, DidNotEnable, _check_minimum_version
from sentry_sdk.utils import parse_version

try:
from statsig import statsig as statsig_module
from statsig.version import __version__ as STATSIG_VERSION
except ImportError:
raise DidNotEnable("statsig is not installed")

if TYPE_CHECKING:
from statsig.statsig_user import StatsigUser


class StatsigIntegration(Integration):
identifier = "statsig"

@staticmethod
def setup_once():
# type: () -> None
version = parse_version(STATSIG_VERSION)
_check_minimum_version(StatsigIntegration, version, "statsig")

# Wrap and patch evaluation method(s) in the statsig module
old_check_gate = statsig_module.check_gate

@wraps(old_check_gate)
def sentry_check_gate(user, gate, *args, **kwargs):
# type: (StatsigUser, str, *Any, **Any) -> Any
enabled = old_check_gate(user, gate, *args, **kwargs)
add_feature_flag(gate, enabled)
return enabled

statsig_module.check_gate = sentry_check_gate
2 changes: 1 addition & 1 deletion sentry_sdk/integrations/unleash.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class UnleashIntegration(Integration):
@staticmethod
def setup_once():
# type: () -> None
# Wrap and patch evaluation methods (instance methods)
# Wrap and patch evaluation methods (class methods)
old_is_enabled = UnleashClient.is_enabled

@wraps(old_is_enabled)
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def get_file_text(file_name):
"sqlalchemy": ["sqlalchemy>=1.2"],
"starlette": ["starlette>=0.19.1"],
"starlite": ["starlite>=1.48"],
"statsig": ["statsig>=0.55.3"],
"tornado": ["tornado>=6"],
"unleash": ["UnleashClient>=6.0.1"],
},
Expand Down
3 changes: 3 additions & 0 deletions tests/integrations/statsig/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("statsig")
183 changes: 183 additions & 0 deletions tests/integrations/statsig/test_statsig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import concurrent.futures as cf
import sys
from contextlib import contextmanager
from statsig import statsig
from statsig.statsig_user import StatsigUser
from random import random
from unittest.mock import Mock

import pytest

import sentry_sdk
from sentry_sdk.integrations.statsig import StatsigIntegration


@contextmanager
def mock_statsig(gate_dict):
old_check_gate = statsig.check_gate

def mock_check_gate(user, gate, *args, **kwargs):
return gate_dict.get(gate, False)

statsig.check_gate = Mock(side_effect=mock_check_gate)

yield

statsig.check_gate = old_check_gate


def test_check_gate(sentry_init, capture_events, uninstall_integration):
uninstall_integration(StatsigIntegration.identifier)

with mock_statsig({"hello": True, "world": False}):
sentry_init(integrations=[StatsigIntegration()])
events = capture_events()
user = StatsigUser(user_id="user-id")

statsig.check_gate(user, "hello")
statsig.check_gate(user, "world")
statsig.check_gate(user, "other") # unknown gates default to False.

sentry_sdk.capture_exception(Exception("something wrong!"))

assert len(events) == 1
assert events[0]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
{"flag": "other", "result": False},
]
}


def test_check_gate_threaded(sentry_init, capture_events, uninstall_integration):
uninstall_integration(StatsigIntegration.identifier)

with mock_statsig({"hello": True, "world": False}):
sentry_init(integrations=[StatsigIntegration()])
events = capture_events()
user = StatsigUser(user_id="user-id")

# Capture an eval before we split isolation scopes.
statsig.check_gate(user, "hello")

def task(flag_key):
# Creates a new isolation scope for the thread.
# This means the evaluations in each task are captured separately.
with sentry_sdk.isolation_scope():
statsig.check_gate(user, flag_key)
# use a tag to identify to identify events later on
sentry_sdk.set_tag("task_id", flag_key)
sentry_sdk.capture_exception(Exception("something wrong!"))

with cf.ThreadPoolExecutor(max_workers=2) as pool:
pool.map(task, ["world", "other"])

# Capture error in original scope
sentry_sdk.set_tag("task_id", "0")
sentry_sdk.capture_exception(Exception("something wrong!"))

assert len(events) == 3
events.sort(key=lambda e: e["tags"]["task_id"])

assert events[0]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": True},
]
}
assert events[1]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]
}
assert events[2]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
}


@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
def test_check_gate_asyncio(sentry_init, capture_events, uninstall_integration):
asyncio = pytest.importorskip("asyncio")
uninstall_integration(StatsigIntegration.identifier)

with mock_statsig({"hello": True, "world": False}):
sentry_init(integrations=[StatsigIntegration()])
events = capture_events()
user = StatsigUser(user_id="user-id")

# Capture an eval before we split isolation scopes.
statsig.check_gate(user, "hello")

async def task(flag_key):
with sentry_sdk.isolation_scope():
statsig.check_gate(user, flag_key)
# use a tag to identify to identify events later on
sentry_sdk.set_tag("task_id", flag_key)
sentry_sdk.capture_exception(Exception("something wrong!"))

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

asyncio.run(runner())

# Capture error in original scope
sentry_sdk.set_tag("task_id", "0")
sentry_sdk.capture_exception(Exception("something wrong!"))

assert len(events) == 3
events.sort(key=lambda e: e["tags"]["task_id"])

assert events[0]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": True},
]
}
assert events[1]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]
}
assert events[2]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
}


def test_wraps_original(sentry_init, uninstall_integration):
uninstall_integration(StatsigIntegration.identifier)
flag_value = random() < 0.5

with mock_statsig(
{"test-flag": flag_value}
): # patches check_gate with a Mock object.
mock_check_gate = statsig.check_gate
sentry_init(integrations=[StatsigIntegration()]) # wraps check_gate.
user = StatsigUser(user_id="user-id")

res = statsig.check_gate(user, "test-flag", "extra-arg", kwarg=1) # type: ignore[arg-type]

assert res == flag_value
assert mock_check_gate.call_args == ( # type: ignore[attr-defined]
(user, "test-flag", "extra-arg"),
{"kwarg": 1},
)


def test_wrapper_attributes(sentry_init, uninstall_integration):
uninstall_integration(StatsigIntegration.identifier)
original_check_gate = statsig.check_gate
sentry_init(integrations=[StatsigIntegration()])

# Methods have not lost their qualified names after decoration.
assert statsig.check_gate.__name__ == "check_gate"
assert statsig.check_gate.__qualname__ == original_check_gate.__qualname__

# Clean up
statsig.check_gate = original_check_gate
10 changes: 10 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ envlist =
{py3.8,py3.11}-starlite-v{1.48,1.51}
# 1.51.14 is the last starlite version; the project continues as litestar

# Statsig
{py3.8,py3.12,py3.13}-statsig-v0.55.3
{py3.8,py3.12,py3.13}-statsig-latest

# SQL Alchemy
{py3.6,py3.9}-sqlalchemy-v{1.2,1.4}
{py3.7,py3.11}-sqlalchemy-v{2.0}
Expand Down Expand Up @@ -715,6 +719,11 @@ deps =
starlite-v{1.48}: starlite~=1.48.0
starlite-v{1.51}: starlite~=1.51.0

# Statsig
statsig: typing_extensions
statsig-v0.55.3: statsig~=0.55.3
statsig-latest: statsig

# SQLAlchemy
sqlalchemy-v1.2: sqlalchemy~=1.2.0
sqlalchemy-v1.4: sqlalchemy~=1.4.0
Expand Down Expand Up @@ -814,6 +823,7 @@ setenv =
starlette: TESTPATH=tests/integrations/starlette
starlite: TESTPATH=tests/integrations/starlite
sqlalchemy: TESTPATH=tests/integrations/sqlalchemy
statsig: TESTPATH=tests/integrations/statsig
strawberry: TESTPATH=tests/integrations/strawberry
tornado: TESTPATH=tests/integrations/tornado
trytond: TESTPATH=tests/integrations/trytond
Expand Down
Loading