-
Notifications
You must be signed in to change notification settings - Fork 571
feat(flags): Add LaunchDarkly Integration #3679
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
Changes from 13 commits
c25b802
e60e1a4
597004b
75a3442
43332d2
a2e3383
5165ffb
c9daf17
d7ae9f5
7740f43
0309b82
cec37dc
22d1024
d9775b8
91eb352
2f59b47
a9d5099
50d2dae
44aebf3
144e064
13434c3
77d4055
8a1a20e
2dab8c3
c97e102
ead840f
bb678c2
08289c2
a3d90bd
711fe55
5218c7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,3 +16,4 @@ pep8-naming | |
| pre-commit # local linting | ||
| httpcore | ||
| openfeature-sdk | ||
| launchdarkly-server-sdk | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,3 +14,4 @@ socksio | |
| httpcore[http2] | ||
| setuptools | ||
| Brotli | ||
| launchdarkly-server-sdk | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -125,6 +125,7 @@ | |
| "tornado", | ||
| ], | ||
| "Miscellaneous": [ | ||
| "launchdarkly", | ||
| "loguru", | ||
| "openfeature", | ||
| "opentelemetry", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| from typing import TYPE_CHECKING | ||
| import sentry_sdk | ||
|
|
||
| from sentry_sdk.integrations import DidNotEnable, Integration | ||
|
|
||
| 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 sentry_sdk._types import Event, ExcInfo | ||
| from typing import Any, Optional | ||
| except ImportError: | ||
| raise DidNotEnable("LaunchDarkly is not installed") | ||
|
|
||
|
|
||
| class LaunchDarklyIntegration(Integration): | ||
| identifier = "launchdarkly" | ||
|
|
||
| def __init__(self, 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. This will fail if ldclient.set_config() hasn't been called. | ||
|
|
||
| Docs reference: https://docs.launchdarkly.com/sdk/server-side/python | ||
aliu39 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| """ | ||
| if client is None: | ||
| try: | ||
| client = ldclient.get() # global singleton. | ||
| 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 given client. | ||
| client.add_hook(LaunchDarklyHook()) | ||
|
||
|
|
||
| @staticmethod | ||
| def setup_once(): | ||
| # type: () -> None | ||
| 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) | ||
|
|
||
|
|
||
| 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 | ||
aliu39 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| def before_evaluation(self, _series_context, data): | ||
aliu39 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| # type: (EvaluationSeriesContext, dict[Any, Any]) -> dict[Any, Any] | ||
| return data # No-op. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import pytest | ||
|
|
||
| pytest.importorskip("ldclient") | ||
aliu39 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import asyncio | ||
| import concurrent.futures as cf | ||
|
|
||
| import ldclient | ||
|
|
||
| import sentry_sdk | ||
| import pytest | ||
|
|
||
| from ldclient import LDClient | ||
| from ldclient.config import Config | ||
| from ldclient.context import Context | ||
| from ldclient.integrations.test_data import TestData | ||
|
|
||
| from sentry_sdk.integrations import DidNotEnable | ||
| from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration | ||
|
|
||
| # Docs reference: https://launchdarkly-python-sdk.readthedocs.io/en/latest/api-testing.html#ldclient.integrations.test_data.TestData | ||
aliu39 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "use_global_client", | ||
| (False, True), | ||
| ) | ||
| def test_launchdarkly_integration(sentry_init, use_global_client): | ||
| td = TestData.data_source() | ||
| config = Config("sdk-key", update_processor_class=td) | ||
| if use_global_client: | ||
| ldclient.set_config(config) | ||
| sentry_init(integrations=[LaunchDarklyIntegration()]) | ||
| client = ldclient.get() | ||
| else: | ||
| client = LDClient(config=config) | ||
| sentry_init(integrations=[LaunchDarklyIntegration(client=client)]) | ||
|
|
||
| # Set test values | ||
| td.update(td.flag("hello").variation_for_all(True)) | ||
| td.update(td.flag("world").variation_for_all(True)) | ||
|
|
||
| # Evaluate | ||
| client.variation("hello", Context.create("my-org", "organization"), False) | ||
| client.variation("world", Context.create("user1", "user"), False) | ||
| client.variation("other", Context.create("user2", "user"), False) | ||
|
|
||
| assert sentry_sdk.get_current_scope().flags.get() == [ | ||
| {"flag": "hello", "result": True}, | ||
| {"flag": "world", "result": True}, | ||
| {"flag": "other", "result": False}, | ||
| ] | ||
|
|
||
|
|
||
| def test_launchdarkly_integration_threaded(sentry_init): | ||
| td = TestData.data_source() | ||
| client = LDClient(config=Config("sdk-key", update_processor_class=td)) | ||
| sentry_init(integrations=[LaunchDarklyIntegration(client=client)]) | ||
| context = Context.create("user1") | ||
|
|
||
| def task(flag_key): | ||
| # Create a new isolation scope for the thread. This means the evaluations in each task are captured separately. | ||
aliu39 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| with sentry_sdk.isolation_scope(): | ||
| client.variation(flag_key, context, False) | ||
| return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()] | ||
|
|
||
| td.update(td.flag("hello").variation_for_all(True)) | ||
| td.update(td.flag("world").variation_for_all(False)) | ||
| client.variation( | ||
| "hello", context, False | ||
| ) # Captured before splitting isolation scopes. | ||
|
|
||
| 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_launchdarkly_integration_asyncio(sentry_init): | ||
| """Assert concurrently evaluated flags do not pollute one another.""" | ||
| td = TestData.data_source() | ||
| client = LDClient(config=Config("sdk-key", update_processor_class=td)) | ||
| sentry_init(integrations=[LaunchDarklyIntegration(client=client)]) | ||
| context = Context.create("user1") | ||
|
|
||
| async def task(flag_key): | ||
| with sentry_sdk.isolation_scope(): | ||
| client.variation(flag_key, context, False) | ||
| return [f["flag"] for f in sentry_sdk.get_current_scope().flags.get()] | ||
|
|
||
| async def runner(): | ||
| return asyncio.gather(task("world"), task("other")) | ||
|
|
||
| td.update(td.flag("hello").variation_for_all(True)) | ||
| td.update(td.flag("world").variation_for_all(False)) | ||
| client.variation("hello", context, False) | ||
|
|
||
| results = asyncio.run(runner()).result() | ||
| assert results[0] == ["hello", "world"] | ||
| assert results[1] == ["hello", "other"] | ||
|
|
||
|
|
||
| def test_launchdarkly_integration_did_not_enable(monkeypatch): | ||
| # Client is not passed in and set_config wasn't called. | ||
| # Bad practice to access internals like this. TODO: can skip this test, or remove this case entirely (force user to pass in a client instance). | ||
aliu39 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| ldclient._reset_client() | ||
| try: | ||
| ldclient.__lock.lock() | ||
| ldclient.__config = None | ||
| finally: | ||
| ldclient.__lock.unlock() | ||
|
|
||
| with pytest.raises(DidNotEnable): | ||
| LaunchDarklyIntegration() | ||
|
|
||
| # Client not initialized. | ||
| client = LDClient(config=Config("sdk-key")) | ||
| monkeypatch.setattr(client, "is_initialized", lambda: False) | ||
| with pytest.raises(DidNotEnable): | ||
| LaunchDarklyIntegration(client=client) | ||
Uh oh!
There was an error while loading. Please reload this page.