Skip to content
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

feat(flags): Add integration for custom tracking of flag evaluations #3860

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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
7 changes: 5 additions & 2 deletions sentry_sdk/_lru_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@

"""

from copy import copy
from copy import copy, deepcopy

SENTINEL = object()

Expand Down Expand Up @@ -92,10 +92,13 @@ def __init__(self, max_size):
self.hits = self.misses = 0

def __copy__(self):
"""
Cache keys and values are shallow copied.
"""
cache = LRUCache(self.max_size)
cache.full = self.full
cache.cache = copy(self.cache)
cache.root = copy(self.root)
cache.root = deepcopy(self.root)
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
return cache

def set(self, key, value):
Expand Down
18 changes: 18 additions & 0 deletions sentry_sdk/integrations/featureflags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from sentry_sdk.flag_utils import flag_error_processor

import sentry_sdk
from sentry_sdk.integrations import Integration


class FeatureFlagsIntegration(Integration):
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
identifier = "featureflags"

@staticmethod
def setup_once():
scope = sentry_sdk.get_current_scope()
scope.add_error_processor(flag_error_processor)

@staticmethod
def set_flag(flag: str, result: bool):
flags = sentry_sdk.get_current_scope().flags
flags.set(flag, result)
Empty file.
117 changes: 117 additions & 0 deletions tests/integrations/featureflags/test_featureflags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import asyncio
import concurrent.futures as cf

import sentry_sdk
from sentry_sdk.integrations import _processed_integrations
from sentry_sdk.integrations.featureflags import FeatureFlagsIntegration


def test_featureflags_integration(sentry_init, capture_events):
_processed_integrations.discard(
FeatureFlagsIntegration.identifier
) # force reinstall
sentry_init(integrations=[FeatureFlagsIntegration()])
flags_integration = sentry_sdk.get_client().get_integration(FeatureFlagsIntegration)

flags_integration.set_flag("hello", False)
aliu39 marked this conversation as resolved.
Show resolved Hide resolved
flags_integration.set_flag("world", True)
flags_integration.set_flag("other", False)

events = capture_events()
sentry_sdk.capture_exception(Exception("something wrong!"))
[event] = events
aliu39 marked this conversation as resolved.
Show resolved Hide resolved

assert event["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "world", "result": True},
{"flag": "other", "result": False},
]
}


def test_featureflags_integration_threaded(sentry_init, capture_events):
_processed_integrations.discard(
FeatureFlagsIntegration.identifier
) # force reinstall
sentry_init(integrations=[FeatureFlagsIntegration()])
events = capture_events()

# Capture an eval before we split isolation scopes.
flags_integration = sentry_sdk.get_client().get_integration(FeatureFlagsIntegration)
flags_integration.set_flag("hello", False)

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():
flags_integration = sentry_sdk.get_client().get_integration(
FeatureFlagsIntegration
)
flags_integration.set_flag(flag_key, False)
# use a tag to identify to identify events later on
sentry_sdk.set_tag("flag_key", flag_key)
sentry_sdk.capture_exception(Exception("something wrong!"))

# Run tasks in separate threads
with cf.ThreadPoolExecutor(max_workers=2) as pool:
pool.map(task, ["world", "other"])

assert len(events) == 2
events.sort(key=lambda e: e["tags"]["flag_key"])
assert events[0]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "other", "result": False},
]
}
assert events[1]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "world", "result": False},
]
}


def test_featureflags_integration_asyncio(sentry_init, capture_events):
_processed_integrations.discard(
FeatureFlagsIntegration.identifier
) # force reinstall
sentry_init(integrations=[FeatureFlagsIntegration()])
events = capture_events()

# Capture an eval before we split isolation scopes.
flags_integration = sentry_sdk.get_client().get_integration(FeatureFlagsIntegration)
flags_integration.set_flag("hello", False)

async 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():
flags_integration = sentry_sdk.get_client().get_integration(
FeatureFlagsIntegration
)
flags_integration.set_flag(flag_key, False)
# use a tag to identify to identify events later on
sentry_sdk.set_tag("flag_key", flag_key)
sentry_sdk.capture_exception(Exception("something wrong!"))

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

asyncio.run(runner())

assert len(events) == 2
events.sort(key=lambda e: e["tags"]["flag_key"])
assert events[0]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "other", "result": False},
]
}
assert events[1]["contexts"]["flags"] == {
"values": [
{"flag": "hello", "result": False},
{"flag": "world", "result": False},
]
}
24 changes: 18 additions & 6 deletions tests/integrations/launchdarkly/test_launchdarkly.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def task(flag_key):
# This means the evaluations in each task are captured separately.
with sentry_sdk.isolation_scope():
client.variation(flag_key, context, False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

td.update(td.flag("hello").variation_for_all(True))
td.update(td.flag("world").variation_for_all(False))
Expand All @@ -67,8 +67,14 @@ def task(flag_key):
with cf.ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(task, ["world", "other"]))

assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]


def test_launchdarkly_integration_asyncio(sentry_init):
Expand All @@ -81,7 +87,7 @@ def test_launchdarkly_integration_asyncio(sentry_init):
async def task(flag_key):
with sentry_sdk.isolation_scope():
client.variation(flag_key, context, False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

async def runner():
return asyncio.gather(task("world"), task("other"))
Expand All @@ -91,8 +97,14 @@ async def runner():
client.variation("hello", context, False)

results = asyncio.run(runner()).result()
assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]


def test_launchdarkly_integration_did_not_enable(monkeypatch):
Expand Down
24 changes: 18 additions & 6 deletions tests/integrations/openfeature/test_openfeature.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,19 @@ def task(flag):
# Create a new isolation scope for the thread. This means the flags
with sentry_sdk.isolation_scope():
client.get_boolean_value(flag, default_value=False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

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

assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]


def test_openfeature_integration_asyncio(sentry_init):
Expand All @@ -59,7 +65,7 @@ def test_openfeature_integration_asyncio(sentry_init):
async def task(flag):
with sentry_sdk.isolation_scope():
client.get_boolean_value(flag, default_value=False)
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
return sentry_sdk.get_current_scope().flags.get()

async def runner():
return asyncio.gather(task("world"), task("other"))
Expand All @@ -76,5 +82,11 @@ async def runner():
client.get_boolean_value("hello", default_value=False)

results = asyncio.run(runner()).result()
assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
assert results[0] == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
]
assert results[1] == [
{"flag": "hello", "result": True},
{"flag": "other", "result": False},
]
19 changes: 19 additions & 0 deletions tests/test_lru_cache.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from copy import copy

import pytest

from sentry_sdk._lru_cache import LRUCache
Expand Down Expand Up @@ -58,3 +60,20 @@ def test_cache_get_all():
assert cache.get_all() == [(1, 1), (2, 2), (3, 3)]
cache.get(1)
assert cache.get_all() == [(2, 2), (3, 3), (1, 1)]


def test_cache_copy():
cache = LRUCache(3)
cache.set(0, 0)
cache.set(1, 1)

copied = copy(cache)
cache.set(2, 2)
cache.set(3, 3)
assert copied.get_all() == [(0, 0), (1, 1)]
assert cache.get_all() == [(1, 1), (2, 2), (3, 3)]

copied = copy(cache)
cache.get(1)
assert copied.get_all() == [(1, 1), (2, 2), (3, 3)]
assert cache.get_all() == [(2, 2), (3, 3), (1, 1)]
cmanallen marked this conversation as resolved.
Show resolved Hide resolved
Loading