Skip to content

Commit dd9c97e

Browse files
committed
Initial commit
1 parent ce604f9 commit dd9c97e

File tree

3 files changed

+218
-0
lines changed

3 files changed

+218
-0
lines changed

sentry_sdk/flag_utils.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import itertools
2+
3+
4+
class FlagManager:
5+
"""
6+
Right now this is just an interface for the buffer but it might contain
7+
thread-local state handling in the future.
8+
"""
9+
10+
def __init__(self, capacity):
11+
# type: (int) -> None
12+
self.buffer = FlagBuffer(capacity)
13+
14+
def get_flags(self):
15+
# type: () -> list[dict]
16+
return self.buffer.serialize()
17+
18+
def set_flag(self, flag, result):
19+
# type: (str, bool) -> None
20+
self.buffer.insert(flag, result)
21+
22+
23+
class FlagBuffer:
24+
25+
def __init__(self, capacity):
26+
# type: (int) -> None
27+
self.buffer = [] # type: list[Flag]
28+
self.capacity = capacity
29+
self.ip = 0
30+
31+
@property
32+
def index(self):
33+
return self.ip % self.capacity
34+
35+
def insert(self, flag, result):
36+
# type: (str, bool) -> None
37+
flag_ = Flag(flag, result)
38+
39+
if self.ip >= self.capacity:
40+
self.buffer[self.index] = flag_
41+
else:
42+
self.buffer.append(flag_)
43+
44+
self.ip += 1
45+
46+
def serialize(self):
47+
# type: () -> list[dict]
48+
if self.ip >= self.capacity:
49+
iterator = itertools.chain(
50+
range(self.index, self.capacity), range(0, self.index)
51+
)
52+
return [self.buffer[i].asdict for i in iterator]
53+
else:
54+
return [flag.asdict for flag in self.buffer]
55+
56+
57+
class Flag:
58+
__slots__ = ("flag", "result")
59+
60+
def __init__(self, flag, result):
61+
# type: (str, bool) -> None
62+
self.flag = flag
63+
self.result = result
64+
65+
@property
66+
def asdict(self):
67+
# type: () -> dict
68+
return {"flag": self.flag, "result": self.result}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import TYPE_CHECKING
2+
import sentry_sdk
3+
4+
from sentry_sdk.flag_utils import FlagManager
5+
from sentry_sdk.integrations import DidNotEnable, Integration
6+
7+
try:
8+
from openfeature import api
9+
from openfeature.hook import Hook
10+
11+
if TYPE_CHECKING:
12+
from openfeature.flag_evaluation import FlagEvaluationDetails
13+
from openfeature.hook import HookContext, HookHints
14+
except ImportError:
15+
raise DidNotEnable("Starlette is not installed")
16+
17+
18+
class OpenFeatureIntegration(Integration):
19+
"""
20+
Bridges the sentry and openfeature sdks. Thread-local data is expected to
21+
flow from openfeature to the integration before the sentry-sdk requests the
22+
thread-local state to be serialized and sent off in the error payload.
23+
"""
24+
25+
def __init__(self, capacity):
26+
# type: (int) -> None
27+
self.flag_manager = FlagManager(capacity=capacity)
28+
29+
# Get or create a new isolation scope and register the integration's
30+
# error processing hook on it.
31+
scope = sentry_sdk.get_isolation_scope()
32+
scope.add_error_processor(self.error_processor)
33+
34+
# This is a globally registered hook (its a list singleton). FlagManager
35+
# expects itself to be in a THREAD-LOCAL context. Whatever hooks are
36+
# triggered will not be THREAD-LOCAL unless we seed the open feature hook
37+
# class with thread-local context.
38+
api.add_hooks(hooks=[OpenFeatureHook(self.flag_manager)])
39+
40+
def error_processor(self, event, exc_info):
41+
"""
42+
On error Sentry will call this hook. This needs to serialize the flags
43+
from the THREAD-LOCAL context and put the result into the error event.
44+
"""
45+
event["contexts"]["flags"] = {"values": self.flag_manager.get_flags()}
46+
return event
47+
48+
49+
class OpenFeatureHook(Hook):
50+
"""
51+
OpenFeature will call the `after` method after each flag evaluation. We need to
52+
accept the method call and push the result into our THREAD-LOCAL buffer.
53+
"""
54+
55+
def __init__(self, flag_manager):
56+
# type: (FlagManager) -> None
57+
self.flag_manager = flag_manager
58+
59+
def after(self, hook_context, details, hints) -> None:
60+
# type: (HookContext, FlagEvaluationDetails, HookHints) -> None
61+
if isinstance(details.value, bool):
62+
self.flag_manager.set_flag(details.flag_key, details.value)

tests/test_flag_utils.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# import asyncio
2+
# import pytest
3+
# import concurrent.futures as cf
4+
5+
from sentry_sdk.flag_utils import get_flags, set_flag
6+
7+
8+
def test_flag_tracking():
9+
"""Assert the ring buffer works."""
10+
set_flag("a", True)
11+
flags = get_flags()
12+
assert len(flags) == 1
13+
assert flags == [{"flag": "a", "result": True}]
14+
15+
set_flag("b", True)
16+
flags = get_flags()
17+
assert len(flags) == 2
18+
assert flags == [{"flag": "a", "result": True}, {"flag": "b", "result": True}]
19+
20+
set_flag("c", True)
21+
flags = get_flags()
22+
assert len(flags) == 3
23+
assert flags == [
24+
{"flag": "a", "result": True},
25+
{"flag": "b", "result": True},
26+
{"flag": "c", "result": True},
27+
]
28+
29+
set_flag("d", False)
30+
flags = get_flags()
31+
assert len(flags) == 3
32+
assert flags == [
33+
{"flag": "b", "result": True},
34+
{"flag": "c", "result": True},
35+
{"flag": "d", "result": False},
36+
]
37+
38+
set_flag("e", False)
39+
set_flag("f", False)
40+
flags = get_flags()
41+
assert len(flags) == 3
42+
assert flags == [
43+
{"flag": "d", "result": False},
44+
{"flag": "e", "result": False},
45+
{"flag": "f", "result": False},
46+
]
47+
48+
49+
# Not applicable right now. Thread-specific testing might be moved to another
50+
# module depending on who eventually managees it.
51+
52+
53+
# def test_flag_manager_asyncio_isolation(i):
54+
# """Assert concurrently evaluated flags do not pollute one another."""
55+
56+
# async def task(chars: str):
57+
# for char in chars:
58+
# set_flag(char, True)
59+
# return [f["flag"] for f in get_flags()]
60+
61+
# async def runner():
62+
# return asyncio.gather(
63+
# task("abc"),
64+
# task("de"),
65+
# task("fghijk"),
66+
# )
67+
68+
# results = asyncio.run(runner()).result()
69+
70+
# assert results[0] == ["a", "b", "c"]
71+
# assert results[1] == ["d", "e"]
72+
# assert results[2] == ["i", "j", "k"]
73+
74+
75+
# def test_flag_manager_thread_isolation(i):
76+
# """Assert concurrently evaluated flags do not pollute one another."""
77+
78+
# def task(chars: str):
79+
# for char in chars:
80+
# set_flag(char, True)
81+
# return [f["flag"] for f in get_flags()]
82+
83+
# with cf.ThreadPoolExecutor(max_workers=3) as pool:
84+
# results = list(pool.map(task, ["abc", "de", "fghijk"]))
85+
86+
# assert results[0] == ["a", "b", "c"]
87+
# assert results[1] == ["d", "e"]
88+
# assert results[2] == ["i", "j", "k"]

0 commit comments

Comments
 (0)