Skip to content

Commit dd1117d

Browse files
cmanallenantonpirkersentrivanaaliu39
authored
Add LaunchDarkly and OpenFeature integration (#3648)
Adds LaunchDarkly and OpenFeature integration and extends the `Scope` with a `flags` property. As flags are evaluated by an application they are stored within the Sentry SDK (lru cache). When an error occurs we fetch the flags stored in the SDK and serialize them on the event. --------- Co-authored-by: Anton Pirker <[email protected]> Co-authored-by: Ivana Kellyer <[email protected]> Co-authored-by: Andrew Liu <[email protected]>
1 parent d06a189 commit dd1117d

18 files changed

+498
-0
lines changed

.github/workflows/test-integrations-miscellaneous.yml

+16
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,18 @@ jobs:
4545
- name: Erase coverage
4646
run: |
4747
coverage erase
48+
- name: Test launchdarkly latest
49+
run: |
50+
set -x # print commands that are executed
51+
./scripts/runtox.sh "py${{ matrix.python-version }}-launchdarkly-latest"
4852
- name: Test loguru latest
4953
run: |
5054
set -x # print commands that are executed
5155
./scripts/runtox.sh "py${{ matrix.python-version }}-loguru-latest"
56+
- name: Test openfeature latest
57+
run: |
58+
set -x # print commands that are executed
59+
./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature-latest"
5260
- name: Test opentelemetry latest
5361
run: |
5462
set -x # print commands that are executed
@@ -117,10 +125,18 @@ jobs:
117125
- name: Erase coverage
118126
run: |
119127
coverage erase
128+
- name: Test launchdarkly pinned
129+
run: |
130+
set -x # print commands that are executed
131+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-launchdarkly"
120132
- name: Test loguru pinned
121133
run: |
122134
set -x # print commands that are executed
123135
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-loguru"
136+
- name: Test openfeature pinned
137+
run: |
138+
set -x # print commands that are executed
139+
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openfeature"
124140
- name: Test opentelemetry pinned
125141
run: |
126142
set -x # print commands that are executed

mypy.ini

+2
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ ignore_missing_imports = True
7474
ignore_missing_imports = True
7575
[mypy-openai.*]
7676
ignore_missing_imports = True
77+
[mypy-openfeature.*]
78+
ignore_missing_imports = True
7779
[mypy-huggingface_hub.*]
7880
ignore_missing_imports = True
7981
[mypy-arq.*]

requirements-linting.txt

+2
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ flake8-bugbear
1515
pep8-naming
1616
pre-commit # local linting
1717
httpcore
18+
openfeature-sdk
19+
launchdarkly-server-sdk

scripts/split-tox-gh-actions/split-tox-gh-actions.py

+2
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@
125125
"tornado",
126126
],
127127
"Miscellaneous": [
128+
"launchdarkly",
128129
"loguru",
130+
"openfeature",
129131
"opentelemetry",
130132
"potel",
131133
"pure_eval",

sentry_sdk/_lru_cache.py

+17
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
6363
"""
6464

65+
from copy import copy
66+
6567
SENTINEL = object()
6668

6769

@@ -89,6 +91,13 @@ def __init__(self, max_size):
8991

9092
self.hits = self.misses = 0
9193

94+
def __copy__(self):
95+
cache = LRUCache(self.max_size)
96+
cache.full = self.full
97+
cache.cache = copy(self.cache)
98+
cache.root = copy(self.root)
99+
return cache
100+
92101
def set(self, key, value):
93102
link = self.cache.get(key, SENTINEL)
94103

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

156165
return link[VALUE]
166+
167+
def get_all(self):
168+
nodes = []
169+
node = self.root[NEXT]
170+
while node is not self.root:
171+
nodes.append((node[KEY], node[VALUE]))
172+
node = node[NEXT]
173+
return nodes

sentry_sdk/consts.py

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class CompressionAlgo(Enum):
5959
"Experiments",
6060
{
6161
"max_spans": Optional[int],
62+
"max_flags": Optional[int],
6263
"record_sql_params": Optional[bool],
6364
"continuous_profiling_auto_start": Optional[bool],
6465
"continuous_profiling_mode": Optional[ContinuousProfilerMode],

sentry_sdk/flag_utils.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from copy import copy
2+
from typing import TYPE_CHECKING
3+
4+
import sentry_sdk
5+
from sentry_sdk._lru_cache import LRUCache
6+
7+
if TYPE_CHECKING:
8+
from typing import TypedDict, Optional
9+
from sentry_sdk._types import Event, ExcInfo
10+
11+
FlagData = TypedDict("FlagData", {"flag": str, "result": bool})
12+
13+
14+
DEFAULT_FLAG_CAPACITY = 100
15+
16+
17+
class FlagBuffer:
18+
19+
def __init__(self, capacity):
20+
# type: (int) -> None
21+
self.buffer = LRUCache(capacity)
22+
self.capacity = capacity
23+
24+
def clear(self):
25+
# type: () -> None
26+
self.buffer = LRUCache(self.capacity)
27+
28+
def __copy__(self):
29+
# type: () -> FlagBuffer
30+
buffer = FlagBuffer(capacity=self.capacity)
31+
buffer.buffer = copy(self.buffer)
32+
return buffer
33+
34+
def get(self):
35+
# type: () -> list[FlagData]
36+
return [{"flag": key, "result": value} for key, value in self.buffer.get_all()]
37+
38+
def set(self, flag, result):
39+
# type: (str, bool) -> None
40+
self.buffer.set(flag, result)
41+
42+
43+
def flag_error_processor(event, exc_info):
44+
# type: (Event, ExcInfo) -> Optional[Event]
45+
scope = sentry_sdk.get_current_scope()
46+
event["contexts"]["flags"] = {"values": scope.flags.get()}
47+
return event
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from typing import TYPE_CHECKING
2+
import sentry_sdk
3+
4+
from sentry_sdk.integrations import DidNotEnable, Integration
5+
from sentry_sdk.flag_utils import flag_error_processor
6+
7+
try:
8+
import ldclient
9+
from ldclient.hook import Hook, Metadata
10+
11+
if TYPE_CHECKING:
12+
from ldclient import LDClient
13+
from ldclient.hook import EvaluationSeriesContext
14+
from ldclient.evaluation import EvaluationDetail
15+
16+
from typing import Any
17+
except ImportError:
18+
raise DidNotEnable("LaunchDarkly is not installed")
19+
20+
21+
class LaunchDarklyIntegration(Integration):
22+
identifier = "launchdarkly"
23+
24+
def __init__(self, ld_client=None):
25+
# type: (LDClient | None) -> None
26+
"""
27+
:param client: An initialized LDClient instance. If a client is not provided, this
28+
integration will attempt to use the shared global instance.
29+
"""
30+
try:
31+
client = ld_client or ldclient.get()
32+
except Exception as exc:
33+
raise DidNotEnable("Error getting LaunchDarkly client. " + repr(exc))
34+
35+
if not client.is_initialized():
36+
raise DidNotEnable("LaunchDarkly client is not initialized.")
37+
38+
# Register the flag collection hook with the LD client.
39+
client.add_hook(LaunchDarklyHook())
40+
41+
@staticmethod
42+
def setup_once():
43+
# type: () -> None
44+
scope = sentry_sdk.get_current_scope()
45+
scope.add_error_processor(flag_error_processor)
46+
47+
48+
class LaunchDarklyHook(Hook):
49+
50+
@property
51+
def metadata(self):
52+
# type: () -> Metadata
53+
return Metadata(name="sentry-feature-flag-recorder")
54+
55+
def after_evaluation(self, series_context, data, detail):
56+
# type: (EvaluationSeriesContext, dict[Any, Any], EvaluationDetail) -> dict[Any, Any]
57+
if isinstance(detail.value, bool):
58+
flags = sentry_sdk.get_current_scope().flags
59+
flags.set(series_context.key, detail.value)
60+
return data
61+
62+
def before_evaluation(self, series_context, data):
63+
# type: (EvaluationSeriesContext, dict[Any, Any]) -> dict[Any, Any]
64+
return data # No-op.
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from typing import TYPE_CHECKING
2+
import sentry_sdk
3+
4+
from sentry_sdk.integrations import DidNotEnable, Integration
5+
from sentry_sdk.flag_utils import flag_error_processor
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("OpenFeature is not installed")
16+
17+
18+
class OpenFeatureIntegration(Integration):
19+
identifier = "openfeature"
20+
21+
@staticmethod
22+
def setup_once():
23+
# type: () -> None
24+
scope = sentry_sdk.get_current_scope()
25+
scope.add_error_processor(flag_error_processor)
26+
27+
# Register the hook within the global openfeature hooks list.
28+
api.add_hooks(hooks=[OpenFeatureHook()])
29+
30+
31+
class OpenFeatureHook(Hook):
32+
33+
def after(self, hook_context, details, hints):
34+
# type: (HookContext, FlagEvaluationDetails[bool], HookHints) -> None
35+
if isinstance(details.value, bool):
36+
flags = sentry_sdk.get_current_scope().flags
37+
flags.set(details.flag_key, details.value)
38+
39+
def error(self, hook_context, exception, hints):
40+
# type: (HookContext, Exception, HookHints) -> None
41+
if isinstance(hook_context.default_value, bool):
42+
flags = sentry_sdk.get_current_scope().flags
43+
flags.set(hook_context.flag_key, hook_context.default_value)

sentry_sdk/scope.py

+16
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, DEFAULT_FLAG_CAPACITY
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,8 @@ def __copy__(self):
249251

250252
rv._last_event_id = self._last_event_id
251253

254+
rv._flags = copy(self._flags)
255+
252256
return rv
253257

254258
@classmethod
@@ -685,6 +689,7 @@ def clear(self):
685689

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

689694
@_attr_setter
690695
def level(self, value):
@@ -1546,6 +1551,17 @@ def __repr__(self):
15461551
self._type,
15471552
)
15481553

1554+
@property
1555+
def flags(self):
1556+
# type: () -> FlagBuffer
1557+
if self._flags is None:
1558+
max_flags = (
1559+
self.get_client().options["_experiments"].get("max_flags")
1560+
or DEFAULT_FLAG_CAPACITY
1561+
)
1562+
self._flags = FlagBuffer(capacity=max_flags)
1563+
return self._flags
1564+
15491565

15501566
@contextmanager
15511567
def new_scope():

setup.py

+2
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,11 @@ def get_file_text(file_name):
6363
"huey": ["huey>=2"],
6464
"huggingface_hub": ["huggingface_hub>=0.22"],
6565
"langchain": ["langchain>=0.0.210"],
66+
"launchdarkly": ["launchdarkly-server-sdk>=9.8.0"],
6667
"litestar": ["litestar>=2.0.0"],
6768
"loguru": ["loguru>=0.5"],
6869
"openai": ["openai>=1.0.0", "tiktoken>=0.3.0"],
70+
"openfeature": ["openfeature-sdk>=0.7.1"],
6971
"opentelemetry": ["opentelemetry-distro>=0.35b0"],
7072
"opentelemetry-experimental": ["opentelemetry-distro"],
7173
"pure_eval": ["pure_eval", "executing", "asttokens"],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("ldclient")

0 commit comments

Comments
 (0)