Skip to content

Commit

Permalink
Add LaunchDarkly and OpenFeature integration (#3648)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
4 people authored Nov 4, 2024
1 parent d06a189 commit dd1117d
Show file tree
Hide file tree
Showing 18 changed files with 498 additions and 0 deletions.
16 changes: 16 additions & 0 deletions .github/workflows/test-integrations-miscellaneous.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,18 @@ jobs:
- name: Erase coverage
run: |
coverage erase
- name: Test launchdarkly latest
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-launchdarkly-latest"
- name: Test loguru latest
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 @@ -117,10 +125,18 @@ jobs:
- name: Erase coverage
run: |
coverage erase
- name: Test launchdarkly pinned
run: |
set -x # print commands that are executed
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-launchdarkly"
- name: Test loguru pinned
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
2 changes: 2 additions & 0 deletions requirements-linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ flake8-bugbear
pep8-naming
pre-commit # local linting
httpcore
openfeature-sdk
launchdarkly-server-sdk
2 changes: 2 additions & 0 deletions scripts/split-tox-gh-actions/split-tox-gh-actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@
"tornado",
],
"Miscellaneous": [
"launchdarkly",
"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
47 changes: 47 additions & 0 deletions sentry_sdk/flag_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from copy import copy
from typing import TYPE_CHECKING

import sentry_sdk
from sentry_sdk._lru_cache import LRUCache

if TYPE_CHECKING:
from typing import TypedDict, Optional
from sentry_sdk._types import Event, ExcInfo

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)


def flag_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
64 changes: 64 additions & 0 deletions sentry_sdk/integrations/launchdarkly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from typing import TYPE_CHECKING
import sentry_sdk

from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.flag_utils import flag_error_processor

try:
import ldclient
from ldclient.hook import Hook, Metadata

if TYPE_CHECKING:
from ldclient import LDClient
from ldclient.hook import EvaluationSeriesContext
from ldclient.evaluation import EvaluationDetail

from typing import Any
except ImportError:
raise DidNotEnable("LaunchDarkly is not installed")


class LaunchDarklyIntegration(Integration):
identifier = "launchdarkly"

def __init__(self, ld_client=None):
# type: (LDClient | None) -> None
"""
:param client: An initialized LDClient instance. If a client is not provided, this
integration will attempt to use the shared global instance.
"""
try:
client = ld_client or ldclient.get()
except Exception as exc:
raise DidNotEnable("Error getting LaunchDarkly client. " + repr(exc))

if not client.is_initialized():
raise DidNotEnable("LaunchDarkly client is not initialized.")

# Register the flag collection hook with the LD client.
client.add_hook(LaunchDarklyHook())

@staticmethod
def setup_once():
# type: () -> None
scope = sentry_sdk.get_current_scope()
scope.add_error_processor(flag_error_processor)


class LaunchDarklyHook(Hook):

@property
def metadata(self):
# type: () -> Metadata
return Metadata(name="sentry-feature-flag-recorder")

def after_evaluation(self, series_context, data, detail):
# type: (EvaluationSeriesContext, dict[Any, Any], EvaluationDetail) -> dict[Any, Any]
if isinstance(detail.value, bool):
flags = sentry_sdk.get_current_scope().flags
flags.set(series_context.key, detail.value)
return data

def before_evaluation(self, series_context, data):
# type: (EvaluationSeriesContext, dict[Any, Any]) -> dict[Any, Any]
return data # No-op.
43 changes: 43 additions & 0 deletions sentry_sdk/integrations/openfeature.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from typing import TYPE_CHECKING
import sentry_sdk

from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.flag_utils import flag_error_processor

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
except ImportError:
raise DidNotEnable("OpenFeature is not installed")


class OpenFeatureIntegration(Integration):
identifier = "openfeature"

@staticmethod
def setup_once():
# type: () -> None
scope = sentry_sdk.get_current_scope()
scope.add_error_processor(flag_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)
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
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ def get_file_text(file_name):
"huey": ["huey>=2"],
"huggingface_hub": ["huggingface_hub>=0.22"],
"langchain": ["langchain>=0.0.210"],
"launchdarkly": ["launchdarkly-server-sdk>=9.8.0"],
"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/launchdarkly/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("ldclient")
Loading

0 comments on commit dd1117d

Please sign in to comment.