From 79b09c05df39c970e6f36619b99b978cee71a5a3 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Wed, 19 Aug 2026 16:23:56 +0000 Subject: [PATCH 1/2] feat: Support OpenFeature tracking --- ld_openfeature/provider.py | 35 ++++++++++++++++++++++++ tests/test_provider.py | 56 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/ld_openfeature/provider.py b/ld_openfeature/provider.py index 5865b98..ade4d5d 100644 --- a/ld_openfeature/provider.py +++ b/ld_openfeature/provider.py @@ -1,4 +1,5 @@ import threading +from logging import getLogger from typing import Any, List, Mapping, Optional, Sequence, Union from ldclient.evaluation import EvaluationDetail @@ -11,11 +12,15 @@ from openfeature.provider.metadata import Metadata from openfeature.provider import AbstractProvider from openfeature.event import ProviderEventDetails +from openfeature.track import TrackingEventDetails from ld_openfeature.impl.context_converter import EvaluationContextConverter from ld_openfeature.impl.details_converter import ResolutionDetailsConverter +logger = getLogger("launchdarkly-openfeature-server") + + class LaunchDarklyProvider(AbstractProvider): def __init__(self, config: Config): self.__client = LDClient(config) @@ -94,6 +99,36 @@ def get_metadata(self) -> Metadata: def get_provider_hooks(self) -> List[Hook]: return [] + def track( + self, + tracking_event_name: str, + evaluation_context: Optional[EvaluationContext] = None, + tracking_event_details: Optional[TrackingEventDetails] = None, + ) -> None: + if evaluation_context is None: + logger.info( + "The 'track' method was called without an evaluation context. " + "No 'track' event will be sent to LaunchDarkly. " + "The LaunchDarkly SDK requires a context to associate the event with." + ) + return + + ld_context = self.__context_converter.to_ld_context(evaluation_context) + + if tracking_event_details is None: + self.__client.track(tracking_event_name, ld_context) + return + + data = tracking_event_details.attributes or None + metric_value = tracking_event_details.value + + if metric_value is not None: + self.__client.track(tracking_event_name, ld_context, data, metric_value) + elif data is not None: + self.__client.track(tracking_event_name, ld_context, data) + else: + self.__client.track(tracking_event_name, ld_context) + def resolve_boolean_details( self, flag_key: str, diff --git a/tests/test_provider.py b/tests/test_provider.py index c0e1d5c..6e50215 100644 --- a/tests/test_provider.py +++ b/tests/test_provider.py @@ -11,6 +11,7 @@ from openfeature.event import ProviderEvent, EventDetails from openfeature.exception import ErrorCode from openfeature.flag_evaluation import Reason +from openfeature.track import TrackingEventDetails from openfeature import api from ld_openfeature import LaunchDarklyProvider, Config @@ -150,6 +151,61 @@ def test_logger_changes_should_cascade_to_evaluation_converter(provider: LaunchD assert caplog.records[0].message == "'kind' was set to a non-string value; defaulting to user" +def test_track_without_context_does_not_send_an_event(provider: LaunchDarklyProvider): + with patch.object(LDClient, 'track') as mock_track: + provider.track("metric-key", None, None) + + mock_track.assert_not_called() + + +def test_track_without_details_sends_event_without_data(provider: LaunchDarklyProvider, + evaluation_context: EvaluationContext): + with patch.object(LDClient, 'track') as mock_track: + provider.track("metric-key", evaluation_context, None) + + mock_track.assert_called_once() + name, context = mock_track.call_args.args + assert name == "metric-key" + assert context.key == 'user-key' + + +def test_track_sends_attributes_as_data(provider: LaunchDarklyProvider, + evaluation_context: EvaluationContext): + with patch.object(LDClient, 'track') as mock_track: + provider.track("metric-key", evaluation_context, TrackingEventDetails(attributes={'string': 'value'})) + + mock_track.assert_called_once() + name, context, data = mock_track.call_args.args + assert name == "metric-key" + assert context.key == 'user-key' + assert data == {'string': 'value'} + + +def test_track_sends_value_as_metric_value(provider: LaunchDarklyProvider, + evaluation_context: EvaluationContext): + with patch.object(LDClient, 'track') as mock_track: + provider.track("metric-key", evaluation_context, + TrackingEventDetails(value=17, attributes={'string': 'value'})) + + mock_track.assert_called_once() + name, context, data, metric_value = mock_track.call_args.args + assert name == "metric-key" + assert context.key == 'user-key' + assert data == {'string': 'value'} + assert metric_value == 17 + + +def test_track_without_attributes_sends_metric_value_without_data(provider: LaunchDarklyProvider, + evaluation_context: EvaluationContext): + with patch.object(LDClient, 'track') as mock_track: + provider.track("metric-key", evaluation_context, TrackingEventDetails(value=17)) + + mock_track.assert_called_once() + name, context, data, metric_value = mock_track.call_args.args + assert data is None + assert metric_value == 17 + + def test_provider_emits_ready_event_when_immediately_ready(): emission_count = 0 lock = threading.Lock() From 4042b50744264e9afb01609f1f6a7329f5909a84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:33:26 +0000 Subject: [PATCH 2/2] chore: Require openfeature-sdk 0.9 for tracking support Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 63cd10e..40f0361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.10" -openfeature-sdk = ">=0.8.0,<1" +openfeature-sdk = ">=0.9.0,<1" launchdarkly-server-sdk = "<10"