Skip to content

Add LaunchDarkly and OpenFeature integration #3648

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

Merged
merged 38 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
b1a0973
Merge branch 'master' into cmanallen/flags-open-feature-integration
antonpirker Oct 24, 2024
430387f
Illustrate moving the flags from the scope to the integration
antonpirker Oct 24, 2024
d78664d
Revert "Illustrate moving the flags from the scope to the integration"
cmanallen Oct 28, 2024
6e34299
Merge branch 'master' into cmanallen/flags-open-feature-integration
cmanallen Oct 30, 2024
018cb0f
feat(flags): Add LaunchDarkly Integration (#3679)
aliu39 Oct 30, 2024
2ee0729
Merge branch 'master' into cmanallen/flags-open-feature-integration
antonpirker Nov 4, 2024
200917f
Merge branch 'master' into cmanallen/flags-open-feature-integration
antonpirker Nov 4, 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
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
Loading