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

Add OpenFeature integration #3648

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dd9c97e
Initial commit
cmanallen Oct 11, 2024
92b0721
Store flags on the scope
cmanallen Oct 11, 2024
514f69f
Use setup_once
cmanallen Oct 11, 2024
f5b2d51
Call copy on the flags property
cmanallen Oct 11, 2024
f5b4d8c
Fix error message
cmanallen Oct 11, 2024
6b4df67
Remove docstring
cmanallen Oct 11, 2024
a8b9126
Add coverage for openfeature
cmanallen Oct 11, 2024
d1b6dd9
Update setup
cmanallen Oct 11, 2024
0066faa
Fix typing
cmanallen Oct 15, 2024
928350a
Add type hints
cmanallen Oct 15, 2024
ecd0a4a
Ignore subclass type error
cmanallen Oct 15, 2024
9910049
Merge branch 'master' into cmanallen/flags-open-feature-integration
cmanallen Oct 15, 2024
7d8a37f
Add openfeature to testing requirements
cmanallen Oct 15, 2024
3bcafed
Constrain type
cmanallen Oct 15, 2024
3bddcf1
Fix imports
cmanallen Oct 15, 2024
83417e2
Add openfeature to tox.ini
cmanallen Oct 16, 2024
fd411a6
Update tox to install openfeature correctly
cmanallen Oct 16, 2024
d97c95e
Add 3.12
cmanallen Oct 16, 2024
fd5ab9a
Add openfeature to linting requirements
cmanallen Oct 16, 2024
1f15e1f
Add openfeature to miscellaneous testing group
cmanallen Oct 16, 2024
e093c14
Use copy function
cmanallen Oct 16, 2024
08fbf27
Update yaml files
cmanallen Oct 16, 2024
8b2ede6
Fix typing
cmanallen Oct 16, 2024
6db318e
Update version
cmanallen Oct 16, 2024
71cb65c
Update version
cmanallen Oct 16, 2024
ed3eac9
Use LRU cache
cmanallen Oct 18, 2024
3f7fdc8
Add more LRU cache coverage
cmanallen Oct 18, 2024
9619c17
Merge branch 'master' into cmanallen/flags-open-feature-integration
antonpirker Oct 22, 2024
38b6521
Set static version
cmanallen Oct 22, 2024
b3e3bf3
Set latest and 0.7 pinned version
cmanallen Oct 22, 2024
8aede7e
Initialize max_flags from experimental init
cmanallen Oct 22, 2024
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-miscellaneous.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-loguru-latest"
- name: Test openfeature latest
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature-latest"
- name: Test opentelemetry latest
run: |
set -x # print commands that are executed
Expand Down Expand Up @@ -121,6 +125,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-loguru"
- name: Test openfeature pinned
run: |
set -x # print commands that are executed
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openfeature"
- name: Test opentelemetry pinned
run: |
set -x # print commands that are executed
Expand Down
2 changes: 2 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ ignore_missing_imports = True
ignore_missing_imports = True
[mypy-openai.*]
ignore_missing_imports = True
[mypy-openfeature.*]
ignore_missing_imports = True
[mypy-huggingface_hub.*]
ignore_missing_imports = True
[mypy-arq.*]
Expand Down
1 change: 1 addition & 0 deletions requirements-linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ flake8-bugbear
pep8-naming
pre-commit # local linting
httpcore
openfeature-sdk
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 @@ -126,6 +126,7 @@
],
"Miscellaneous": [
"loguru",
"openfeature",
"opentelemetry",
"potel",
"pure_eval",
Expand Down
17 changes: 17 additions & 0 deletions sentry_sdk/_lru_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@

"""

from copy import copy

SENTINEL = object()


Expand Down Expand Up @@ -89,6 +91,13 @@ def __init__(self, max_size):

self.hits = self.misses = 0

def __copy__(self):
cache = LRUCache(self.max_size)
cache.full = self.full
cache.cache = copy(self.cache)
cache.root = copy(self.root)
return cache

def set(self, key, value):
link = self.cache.get(key, SENTINEL)

Expand Down Expand Up @@ -154,3 +163,11 @@ def get(self, key, default=None):
self.hits += 1

return link[VALUE]

def get_all(self):
nodes = []
node = self.root[NEXT]
while node is not self.root:
nodes.append((node[KEY], node[VALUE]))
node = node[NEXT]
return nodes
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class CompressionAlgo(Enum):
"Experiments",
{
"max_spans": Optional[int],
"max_flags": Optional[int],
"record_sql_params": Optional[bool],
"continuous_profiling_auto_start": Optional[bool],
"continuous_profiling_mode": Optional[ContinuousProfilerMode],
Expand Down
38 changes: 38 additions & 0 deletions sentry_sdk/flag_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from copy import copy
from typing import TYPE_CHECKING

from sentry_sdk._lru_cache import LRUCache

if TYPE_CHECKING:
from typing import TypedDict

FlagData = TypedDict("FlagData", {"flag": str, "result": bool})


DEFAULT_FLAG_CAPACITY = 100


class FlagBuffer:

def __init__(self, capacity):
# type: (int) -> None
self.buffer = LRUCache(capacity)
self.capacity = capacity

def clear(self):
# type: () -> None
self.buffer = LRUCache(self.capacity)

def __copy__(self):
# type: () -> FlagBuffer
buffer = FlagBuffer(capacity=self.capacity)
buffer.buffer = copy(self.buffer)
return buffer

def get(self):
# type: () -> list[FlagData]
return [{"flag": key, "result": value} for key, value in self.buffer.get_all()]

def set(self, flag, result):
# type: (str, bool) -> None
self.buffer.set(flag, result)
50 changes: 50 additions & 0 deletions sentry_sdk/integrations/openfeature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from typing import TYPE_CHECKING
import sentry_sdk

from sentry_sdk.integrations import DidNotEnable, Integration

try:
from openfeature import api
from openfeature.hook import Hook

if TYPE_CHECKING:
from openfeature.flag_evaluation import FlagEvaluationDetails
from openfeature.hook import HookContext, HookHints
from sentry_sdk._types import Event, ExcInfo
from typing import Optional
except ImportError:
raise DidNotEnable("OpenFeature is not installed")


class OpenFeatureIntegration(Integration):
identifier = "openfeature"

@staticmethod
def setup_once():
# type: () -> None
Copy link
Member

@aliu39 aliu39 Oct 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we using 2 type hints here? First hint is for setup_once

def error_processor(event, exc_info):
# type: (Event, ExcInfo) -> Optional[Event]
scope = sentry_sdk.get_current_scope()
event["contexts"]["flags"] = {"values": scope.flags.get()}
return event

scope = sentry_sdk.get_current_scope()
scope.add_error_processor(error_processor)

# Register the hook within the global openfeature hooks list.
api.add_hooks(hooks=[OpenFeatureHook()])


class OpenFeatureHook(Hook):

def after(self, hook_context, details, hints):
# type: (HookContext, FlagEvaluationDetails[bool], HookHints) -> None
if isinstance(details.value, bool):
flags = sentry_sdk.get_current_scope().flags
flags.set(details.flag_key, details.value)

def error(self, hook_context, exception, hints):
# type: (HookContext, Exception, HookHints) -> None
if isinstance(hook_context.default_value, bool):
flags = sentry_sdk.get_current_scope().flags
flags.set(hook_context.flag_key, hook_context.default_value)
cmanallen marked this conversation as resolved.
Show resolved Hide resolved
16 changes: 16 additions & 0 deletions sentry_sdk/scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from sentry_sdk.attachments import Attachment
from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER
from sentry_sdk.flag_utils import FlagBuffer, DEFAULT_FLAG_CAPACITY
from sentry_sdk.profiler.continuous_profiler import try_autostart_continuous_profiler
from sentry_sdk.profiler.transaction_profiler import Profile
from sentry_sdk.session import Session
Expand Down Expand Up @@ -192,6 +193,7 @@ class Scope:
"client",
"_type",
"_last_event_id",
"_flags",
)

def __init__(self, ty=None, client=None):
Expand Down Expand Up @@ -249,6 +251,8 @@ def __copy__(self):

rv._last_event_id = self._last_event_id

rv._flags = copy(self._flags)

return rv

@classmethod
Expand Down Expand Up @@ -685,6 +689,7 @@ def clear(self):

# self._last_event_id is only applicable to isolation scopes
self._last_event_id = None # type: Optional[str]
self._flags = None # type: Optional[FlagBuffer]

@_attr_setter
def level(self, value):
Expand Down Expand Up @@ -1546,6 +1551,17 @@ def __repr__(self):
self._type,
)

@property
def flags(self):
# type: () -> FlagBuffer
if self._flags is None:
max_flags = (
self.get_client().options["_experiments"].get("max_flags")
or DEFAULT_FLAG_CAPACITY
)
self._flags = FlagBuffer(capacity=max_flags)
return self._flags


@contextmanager
def new_scope():
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def get_file_text(file_name):
"litestar": ["litestar>=2.0.0"],
"loguru": ["loguru>=0.5"],
"openai": ["openai>=1.0.0", "tiktoken>=0.3.0"],
"openfeature": ["openfeature-sdk>=0.7.1"],
"opentelemetry": ["opentelemetry-distro>=0.35b0"],
"opentelemetry-experimental": ["opentelemetry-distro"],
"pure_eval": ["pure_eval", "executing", "asttokens"],
Expand Down
3 changes: 3 additions & 0 deletions tests/integrations/openfeature/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("openfeature")
80 changes: 80 additions & 0 deletions tests/integrations/openfeature/test_openfeature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import asyncio
import concurrent.futures as cf
import sentry_sdk

from openfeature import api
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
from sentry_sdk.integrations.openfeature import OpenFeatureIntegration


def test_openfeature_integration(sentry_init):
sentry_init(integrations=[OpenFeatureIntegration()])

flags = {
"hello": InMemoryFlag("on", {"on": True, "off": False}),
"world": InMemoryFlag("off", {"on": True, "off": False}),
}
api.set_provider(InMemoryProvider(flags))

client = api.get_client()
client.get_boolean_value("hello", default_value=False)
client.get_boolean_value("world", default_value=False)
client.get_boolean_value("other", default_value=True)

assert sentry_sdk.get_current_scope().flags.get() == [
{"flag": "hello", "result": True},
{"flag": "world", "result": False},
{"flag": "other", "result": True},
]


def test_openfeature_integration_threaded(sentry_init):
sentry_init(integrations=[OpenFeatureIntegration()])

flags = {
"hello": InMemoryFlag("on", {"on": True, "off": False}),
"world": InMemoryFlag("off", {"on": True, "off": False}),
}
api.set_provider(InMemoryProvider(flags))

client = api.get_client()
client.get_boolean_value("hello", default_value=False)

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()]

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"]


def test_openfeature_integration_asyncio(sentry_init):
"""Assert concurrently evaluated flags do not pollute one another."""

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()]

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

sentry_init(integrations=[OpenFeatureIntegration()])

flags = {
"hello": InMemoryFlag("on", {"on": True, "off": False}),
"world": InMemoryFlag("off", {"on": True, "off": False}),
}
api.set_provider(InMemoryProvider(flags))

client = api.get_client()
client.get_boolean_value("hello", default_value=False)

results = asyncio.run(runner()).result()
assert results[0] == ["hello", "world"]
assert results[1] == ["hello", "other"]
43 changes: 43 additions & 0 deletions tests/test_flag_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from sentry_sdk.flag_utils import FlagBuffer


def test_flag_tracking():
"""Assert the ring buffer works."""
buffer = FlagBuffer(capacity=3)
buffer.set("a", True)
flags = buffer.get()
assert len(flags) == 1
assert flags == [{"flag": "a", "result": True}]

buffer.set("b", True)
flags = buffer.get()
assert len(flags) == 2
assert flags == [{"flag": "a", "result": True}, {"flag": "b", "result": True}]

buffer.set("c", True)
flags = buffer.get()
assert len(flags) == 3
assert flags == [
{"flag": "a", "result": True},
{"flag": "b", "result": True},
{"flag": "c", "result": True},
]

buffer.set("d", False)
flags = buffer.get()
assert len(flags) == 3
assert flags == [
{"flag": "b", "result": True},
{"flag": "c", "result": True},
{"flag": "d", "result": False},
]

buffer.set("e", False)
buffer.set("f", False)
flags = buffer.get()
assert len(flags) == 3
assert flags == [
{"flag": "d", "result": False},
{"flag": "e", "result": False},
{"flag": "f", "result": False},
]
23 changes: 23 additions & 0 deletions tests/test_lru_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,26 @@ def test_cache_eviction():
cache.set(4, 4)
assert cache.get(3) is None
assert cache.get(4) == 4


def test_cache_miss():
cache = LRUCache(1)
assert cache.get(0) is None


def test_cache_set_overwrite():
cache = LRUCache(3)
cache.set(0, 0)
cache.set(0, 1)
assert cache.get(0) == 1


def test_cache_get_all():
cache = LRUCache(3)
cache.set(0, 0)
cache.set(1, 1)
cache.set(2, 2)
cache.set(3, 3)
assert cache.get_all() == [(1, 1), (2, 2), (3, 3)]
cache.get(1)
assert cache.get_all() == [(2, 2), (3, 3), (1, 1)]
Loading
Loading