Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/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
39 changes: 39 additions & 0 deletions sentry_sdk/integrations/statsig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from functools import wraps
from typing import Any, TYPE_CHECKING

import sentry_sdk
from sentry_sdk.integrations import Integration, DidNotEnable

import importlib

try:
# The statsig package has the same name as this file. We use importlib to avoid conflicts.
statsig = importlib.import_module("statsig.statsig")
except ImportError:
raise DidNotEnable("statsig is not installed")

if TYPE_CHECKING:
statsig_user = importlib.import_module("statsig.statsig_user")
StatsigUser = statsig_user.StatsigUser


class StatsigIntegration(Integration):
identifier = "statsig"

@staticmethod
def setup_once():
# type: () -> None
# Wrap and patch evaluation method(s) in the statsig module
old_check_gate = statsig.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)

flags = sentry_sdk.get_current_scope().flags
flags.set(gate, enabled)

return enabled

statsig.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
9 changes: 9 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-v6.0.1
{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,10 @@ deps =
starlite-v{1.48}: starlite~=1.48.0
starlite-v{1.51}: starlite~=1.51.0

# Statsig
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 +822,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