Skip to content

Commit 018cb0f

Browse files
aliu39cmanallen
andauthored
feat(flags): Add LaunchDarkly Integration (#3679)
Co-authored-by: Colton Allen <[email protected]>
1 parent 6e34299 commit 018cb0f

File tree

10 files changed

+215
-10
lines changed

10 files changed

+215
-10
lines changed

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ 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
@@ -121,6 +125,10 @@ jobs:
121125
- name: Erase coverage
122126
run: |
123127
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"
124132
- name: Test loguru pinned
125133
run: |
126134
set -x # print commands that are executed

requirements-linting.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ pep8-naming
1616
pre-commit # local linting
1717
httpcore
1818
openfeature-sdk
19+
launchdarkly-server-sdk

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@
125125
"tornado",
126126
],
127127
"Miscellaneous": [
128+
"launchdarkly",
128129
"loguru",
129130
"openfeature",
130131
"opentelemetry",

sentry_sdk/flag_utils.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from copy import copy
22
from typing import TYPE_CHECKING
33

4+
import sentry_sdk
45
from sentry_sdk._lru_cache import LRUCache
56

67
if TYPE_CHECKING:
7-
from typing import TypedDict
8+
from typing import TypedDict, Optional
9+
from sentry_sdk._types import Event, ExcInfo
810

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

@@ -36,3 +38,10 @@ def get(self):
3638
def set(self, flag, result):
3739
# type: (str, bool) -> None
3840
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
Lines changed: 64 additions & 0 deletions
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.

sentry_sdk/integrations/openfeature.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import sentry_sdk
33

44
from sentry_sdk.integrations import DidNotEnable, Integration
5+
from sentry_sdk.flag_utils import flag_error_processor
56

67
try:
78
from openfeature import api
@@ -10,8 +11,6 @@
1011
if TYPE_CHECKING:
1112
from openfeature.flag_evaluation import FlagEvaluationDetails
1213
from openfeature.hook import HookContext, HookHints
13-
from sentry_sdk._types import Event, ExcInfo
14-
from typing import Optional
1514
except ImportError:
1615
raise DidNotEnable("OpenFeature is not installed")
1716

@@ -22,14 +21,8 @@ class OpenFeatureIntegration(Integration):
2221
@staticmethod
2322
def setup_once():
2423
# type: () -> None
25-
def error_processor(event, exc_info):
26-
# type: (Event, ExcInfo) -> Optional[Event]
27-
scope = sentry_sdk.get_current_scope()
28-
event["contexts"]["flags"] = {"values": scope.flags.get()}
29-
return event
30-
3124
scope = sentry_sdk.get_current_scope()
32-
scope.add_error_processor(error_processor)
25+
scope.add_error_processor(flag_error_processor)
3326

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

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ 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"],
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("ldclient")
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import asyncio
2+
import concurrent.futures as cf
3+
4+
import ldclient
5+
6+
import sentry_sdk
7+
import pytest
8+
9+
from ldclient import LDClient
10+
from ldclient.config import Config
11+
from ldclient.context import Context
12+
from ldclient.integrations.test_data import TestData
13+
14+
from sentry_sdk.integrations import DidNotEnable
15+
from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration
16+
17+
18+
@pytest.mark.parametrize(
19+
"use_global_client",
20+
(False, True),
21+
)
22+
def test_launchdarkly_integration(sentry_init, use_global_client):
23+
td = TestData.data_source()
24+
config = Config("sdk-key", update_processor_class=td)
25+
if use_global_client:
26+
ldclient.set_config(config)
27+
sentry_init(integrations=[LaunchDarklyIntegration()])
28+
client = ldclient.get()
29+
else:
30+
client = LDClient(config=config)
31+
sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
32+
33+
# Set test values
34+
td.update(td.flag("hello").variation_for_all(True))
35+
td.update(td.flag("world").variation_for_all(True))
36+
37+
# Evaluate
38+
client.variation("hello", Context.create("my-org", "organization"), False)
39+
client.variation("world", Context.create("user1", "user"), False)
40+
client.variation("other", Context.create("user2", "user"), False)
41+
42+
assert sentry_sdk.get_current_scope().flags.get() == [
43+
{"flag": "hello", "result": True},
44+
{"flag": "world", "result": True},
45+
{"flag": "other", "result": False},
46+
]
47+
48+
49+
def test_launchdarkly_integration_threaded(sentry_init):
50+
td = TestData.data_source()
51+
client = LDClient(config=Config("sdk-key", update_processor_class=td))
52+
sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
53+
context = Context.create("user1")
54+
55+
def task(flag_key):
56+
# Creates a new isolation scope for the thread.
57+
# This means the evaluations in each task are captured separately.
58+
with sentry_sdk.isolation_scope():
59+
client.variation(flag_key, context, False)
60+
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
61+
62+
td.update(td.flag("hello").variation_for_all(True))
63+
td.update(td.flag("world").variation_for_all(False))
64+
# Capture an eval before we split isolation scopes.
65+
client.variation("hello", context, False)
66+
67+
with cf.ThreadPoolExecutor(max_workers=2) as pool:
68+
results = list(pool.map(task, ["world", "other"]))
69+
70+
assert results[0] == ["hello", "world"]
71+
assert results[1] == ["hello", "other"]
72+
73+
74+
def test_launchdarkly_integration_asyncio(sentry_init):
75+
"""Assert concurrently evaluated flags do not pollute one another."""
76+
td = TestData.data_source()
77+
client = LDClient(config=Config("sdk-key", update_processor_class=td))
78+
sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)])
79+
context = Context.create("user1")
80+
81+
async def task(flag_key):
82+
with sentry_sdk.isolation_scope():
83+
client.variation(flag_key, context, False)
84+
return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()]
85+
86+
async def runner():
87+
return asyncio.gather(task("world"), task("other"))
88+
89+
td.update(td.flag("hello").variation_for_all(True))
90+
td.update(td.flag("world").variation_for_all(False))
91+
client.variation("hello", context, False)
92+
93+
results = asyncio.run(runner()).result()
94+
assert results[0] == ["hello", "world"]
95+
assert results[1] == ["hello", "other"]
96+
97+
98+
def test_launchdarkly_integration_did_not_enable(monkeypatch):
99+
# Client is not passed in and set_config wasn't called.
100+
# TODO: Bad practice to access internals like this. We can skip this test, or remove this
101+
# case entirely (force user to pass in a client instance).
102+
ldclient._reset_client()
103+
try:
104+
ldclient.__lock.lock()
105+
ldclient.__config = None
106+
finally:
107+
ldclient.__lock.unlock()
108+
109+
with pytest.raises(DidNotEnable):
110+
LaunchDarklyIntegration()
111+
112+
# Client not initialized.
113+
client = LDClient(config=Config("sdk-key"))
114+
monkeypatch.setattr(client, "is_initialized", lambda: False)
115+
with pytest.raises(DidNotEnable):
116+
LaunchDarklyIntegration(ld_client=client)

tox.ini

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,10 @@ envlist =
188188
{py3.8,py3.12,py3.13}-openfeature-v0.7
189189
{py3.8,py3.12,py3.13}-openfeature-latest
190190

191+
# LaunchDarkly
192+
{py3.8,py3.12,py3.13}-launchdarkly-v9.8.0
193+
{py3.8,py3.12,py3.13}-launchdarkly-latest
194+
191195
# OpenTelemetry (OTel)
192196
{py3.7,py3.9,py3.12,py3.13}-opentelemetry
193197

@@ -547,6 +551,10 @@ deps =
547551
openfeature-v0.7: openfeature-sdk~=0.7.1
548552
openfeature-latest: openfeature-sdk
549553

554+
# LaunchDarkly
555+
launchdarkly-v9.8.0: launchdarkly-server-sdk~=9.8.0
556+
launchdarkly-latest: launchdarkly-server-sdk
557+
550558
# OpenTelemetry (OTel)
551559
opentelemetry: opentelemetry-distro
552560

@@ -735,6 +743,7 @@ setenv =
735743
huey: TESTPATH=tests/integrations/huey
736744
huggingface_hub: TESTPATH=tests/integrations/huggingface_hub
737745
langchain: TESTPATH=tests/integrations/langchain
746+
launchdarkly: TESTPATH=tests/integrations/launchdarkly
738747
litestar: TESTPATH=tests/integrations/litestar
739748
loguru: TESTPATH=tests/integrations/loguru
740749
openai: TESTPATH=tests/integrations/openai

0 commit comments

Comments
 (0)