diff --git a/.github/workflows/test-integrations-flags.yml b/.github/workflows/test-integrations-flags.yml index 096da8d672..f56e1a082a 100644 --- a/.github/workflows/test-integrations-flags.yml +++ b/.github/workflows/test-integrations-flags.yml @@ -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 @@ -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 diff --git a/requirements-linting.txt b/requirements-linting.txt index 014e177793..4255685b5e 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -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 diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index 83db87bd35..63e93738b1 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -103,6 +103,7 @@ "spark", "starlette", "starlite", + "statsig", "sqlalchemy", "strawberry", "tornado", diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 43307c3093..5218b0675f 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -87,6 +87,7 @@ "Flags": [ "launchdarkly", "openfeature", + "statsig", "unleash", ], "Gevent": [ diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 45235a41c4..f2b02e8b19 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -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), diff --git a/sentry_sdk/integrations/statsig.py b/sentry_sdk/integrations/statsig.py new file mode 100644 index 0000000000..1d84eb8aa2 --- /dev/null +++ b/sentry_sdk/integrations/statsig.py @@ -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 diff --git a/sentry_sdk/integrations/unleash.py b/sentry_sdk/integrations/unleash.py index c7108394d0..873f36c68b 100644 --- a/sentry_sdk/integrations/unleash.py +++ b/sentry_sdk/integrations/unleash.py @@ -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) diff --git a/setup.py b/setup.py index 760ce2d60f..21793220d4 100644 --- a/setup.py +++ b/setup.py @@ -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"], }, diff --git a/tests/integrations/statsig/__init__.py b/tests/integrations/statsig/__init__.py new file mode 100644 index 0000000000..6abc08235b --- /dev/null +++ b/tests/integrations/statsig/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("statsig") diff --git a/tests/integrations/statsig/test_statsig.py b/tests/integrations/statsig/test_statsig.py new file mode 100644 index 0000000000..c1666bde4d --- /dev/null +++ b/tests/integrations/statsig/test_statsig.py @@ -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 diff --git a/tox.ini b/tox.ini index c82d7d9159..48b2e6ea40 100644 --- a/tox.ini +++ b/tox.ini @@ -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} @@ -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 @@ -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