Skip to content

Commit 92b0721

Browse files
committed
Store flags on the scope
1 parent dd9c97e commit 92b0721

File tree

4 files changed

+62
-113
lines changed

4 files changed

+62
-113
lines changed

sentry_sdk/flag_utils.py

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,6 @@
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()
1+
from copy import copy
172

18-
def set_flag(self, flag, result):
19-
# type: (str, bool) -> None
20-
self.buffer.insert(flag, result)
3+
import itertools
214

225

236
class FlagBuffer:
@@ -32,18 +15,18 @@ def __init__(self, capacity):
3215
def index(self):
3316
return self.ip % self.capacity
3417

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_)
18+
def clear(self):
19+
self.buffer = []
20+
self.ip = 0
4321

44-
self.ip += 1
22+
def copy(self):
23+
# type: () -> FlagBuffer
24+
buffer = FlagBuffer(capacity=self.capacity)
25+
buffer.buffer = copy(self.buffer)
26+
buffer.ip = self.ip
27+
return buffer
4528

46-
def serialize(self):
29+
def get(self):
4730
# type: () -> list[dict]
4831
if self.ip >= self.capacity:
4932
iterator = itertools.chain(
@@ -53,6 +36,17 @@ def serialize(self):
5336
else:
5437
return [flag.asdict for flag in self.buffer]
5538

39+
def set(self, flag, result):
40+
# type: (str, bool) -> None
41+
flag_ = Flag(flag, result)
42+
43+
if self.ip >= self.capacity:
44+
self.buffer[self.index] = flag_
45+
else:
46+
self.buffer.append(flag_)
47+
48+
self.ip += 1
49+
5650

5751
class Flag:
5852
__slots__ = ("flag", "result")
Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from typing import TYPE_CHECKING
22
import sentry_sdk
33

4-
from sentry_sdk.flag_utils import FlagManager
54
from sentry_sdk.integrations import DidNotEnable, Integration
65

76
try:
@@ -22,41 +21,28 @@ class OpenFeatureIntegration(Integration):
2221
thread-local state to be serialized and sent off in the error payload.
2322
"""
2423

25-
def __init__(self, capacity):
24+
def __init__(self):
2625
# 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()
26+
# Store the error processor on the current scope. If its forked
27+
# (i.e. threads are spawned) the callback will be copied to the
28+
# new Scope.
29+
scope = sentry_sdk.get_current_scope()
3230
scope.add_error_processor(self.error_processor)
3331

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)])
32+
# Register the hook within the global openfeature hooks list.
33+
api.add_hooks(hooks=[OpenFeatureHook()])
3934

4035
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()}
36+
event["contexts"]["flags"] = {
37+
"values": sentry_sdk.get_current_scope().flags.get()
38+
}
4639
return event
4740

4841

4942
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
5843

5944
def after(self, hook_context, details, hints) -> None:
6045
# type: (HookContext, FlagEvaluationDetails, HookHints) -> None
6146
if isinstance(details.value, bool):
62-
self.flag_manager.set_flag(details.flag_key, details.value)
47+
flags = sentry_sdk.get_current_scope().flags
48+
flags.set(details.flag_key, details.value)

sentry_sdk/scope.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from sentry_sdk.attachments import Attachment
1313
from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER
14+
from sentry_sdk.flag_utils import FlagBuffer
1415
from sentry_sdk.profiler.continuous_profiler import try_autostart_continuous_profiler
1516
from sentry_sdk.profiler.transaction_profiler import Profile
1617
from sentry_sdk.session import Session
@@ -192,6 +193,7 @@ class Scope:
192193
"client",
193194
"_type",
194195
"_last_event_id",
196+
"_flags",
195197
)
196198

197199
def __init__(self, ty=None, client=None):
@@ -249,6 +251,10 @@ def __copy__(self):
249251

250252
rv._last_event_id = self._last_event_id
251253

254+
# TODO: Do I need to target `self.flags`? I don't want to create an instance
255+
# before I copy but I guess theres no harm.
256+
rv._flags = self._flags.copy()
257+
252258
return rv
253259

254260
@classmethod
@@ -685,6 +691,7 @@ def clear(self):
685691

686692
# self._last_event_id is only applicable to isolation scopes
687693
self._last_event_id = None # type: Optional[str]
694+
self._flags = None
688695

689696
@_attr_setter
690697
def level(self, value):
@@ -1546,6 +1553,13 @@ def __repr__(self):
15461553
self._type,
15471554
)
15481555

1556+
@property
1557+
def flags(self):
1558+
# type: () -> FlagBuffer
1559+
if self._flags is None:
1560+
self._flags = FlagBuffer(capacity=50)
1561+
self._flags
1562+
15491563

15501564
@contextmanager
15511565
def new_scope():

tests/test_flag_utils.py

Lines changed: 13 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,43 @@
1-
# import asyncio
2-
# import pytest
3-
# import concurrent.futures as cf
4-
5-
from sentry_sdk.flag_utils import get_flags, set_flag
1+
from sentry_sdk.flag_utils import FlagBuffer
62

73

84
def test_flag_tracking():
95
"""Assert the ring buffer works."""
10-
set_flag("a", True)
11-
flags = get_flags()
6+
buffer = FlagBuffer(capacity=3)
7+
buffer.set("a", True)
8+
flags = buffer.get()
129
assert len(flags) == 1
1310
assert flags == [{"flag": "a", "result": True}]
1411

15-
set_flag("b", True)
16-
flags = get_flags()
12+
buffer.set("b", True)
13+
flags = buffer.get()
1714
assert len(flags) == 2
1815
assert flags == [{"flag": "a", "result": True}, {"flag": "b", "result": True}]
1916

20-
set_flag("c", True)
21-
flags = get_flags()
17+
buffer.set("c", True)
18+
flags = buffer.get()
2219
assert len(flags) == 3
2320
assert flags == [
2421
{"flag": "a", "result": True},
2522
{"flag": "b", "result": True},
2623
{"flag": "c", "result": True},
2724
]
2825

29-
set_flag("d", False)
30-
flags = get_flags()
26+
buffer.set("d", False)
27+
flags = buffer.get()
3128
assert len(flags) == 3
3229
assert flags == [
3330
{"flag": "b", "result": True},
3431
{"flag": "c", "result": True},
3532
{"flag": "d", "result": False},
3633
]
3734

38-
set_flag("e", False)
39-
set_flag("f", False)
40-
flags = get_flags()
35+
buffer.set("e", False)
36+
buffer.set("f", False)
37+
flags = buffer.get()
4138
assert len(flags) == 3
4239
assert flags == [
4340
{"flag": "d", "result": False},
4441
{"flag": "e", "result": False},
4542
{"flag": "f", "result": False},
4643
]
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)