From dc2ae67688a3150675e3b09a6254161804f3bd01 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:53:29 +0000 Subject: [PATCH] feat: Emit provider events from LaunchDarkly client status Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- lib/ldclient-openfeature.rb | 1 + .../impl/event_listeners.rb | 82 +++++++++++++++++++ lib/ldclient-openfeature/provider.rb | 5 ++ spec/impl/data_source_status_listener_spec.rb | 42 ++++++++++ spec/impl/flag_change_listener_spec.rb | 14 ++++ 5 files changed, 144 insertions(+) create mode 100644 lib/ldclient-openfeature/impl/event_listeners.rb create mode 100644 spec/impl/data_source_status_listener_spec.rb create mode 100644 spec/impl/flag_change_listener_spec.rb diff --git a/lib/ldclient-openfeature.rb b/lib/ldclient-openfeature.rb index a202cc1..c830716 100644 --- a/lib/ldclient-openfeature.rb +++ b/lib/ldclient-openfeature.rb @@ -2,6 +2,7 @@ require_relative "ldclient-openfeature/impl/context_converter" require_relative "ldclient-openfeature/impl/details_converter" +require_relative "ldclient-openfeature/impl/event_listeners" require_relative "ldclient-openfeature/provider" require_relative "ldclient-openfeature/version" diff --git a/lib/ldclient-openfeature/impl/event_listeners.rb b/lib/ldclient-openfeature/impl/event_listeners.rb new file mode 100644 index 0000000..a78fc6d --- /dev/null +++ b/lib/ldclient-openfeature/impl/event_listeners.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'ldclient-rb' +require 'open_feature/sdk' + +module LaunchDarkly + module OpenFeature + module Impl + # + # Translates LaunchDarkly data source status changes into OpenFeature provider events. + # + class DataSourceStatusListener + # + # @param provider [LaunchDarkly::OpenFeature::Provider] + # + def initialize(provider) + @provider = provider + end + + # + # @param status [LaunchDarkly::Interfaces::DataSource::Status] + # + # @return [void] + # + def update(status) + case status.state + when ::LaunchDarkly::Interfaces::DataSource::Status::VALID + @provider.emit_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + when ::LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED + @provider.emit_event( + ::OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, + message: message(status, "the data source has been interrupted") + ) + when ::LaunchDarkly::Interfaces::DataSource::Status::OFF + @provider.emit_event( + ::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + error_code: ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, + message: message(status, "the data source has been permanently shut down") + ) + end + end + + # + # @param status [LaunchDarkly::Interfaces::DataSource::Status] + # @param fallback [String] + # + # @return [String] + # + private def message(status, fallback) + error = status.last_error + return fallback if error.nil? + + "#{fallback}: #{error.kind} #{error.status_code} #{error.message}".strip + end + end + + # + # Translates LaunchDarkly flag change events into OpenFeature configuration changed events. + # + class FlagChangeListener + # + # @param provider [LaunchDarkly::OpenFeature::Provider] + # + def initialize(provider) + @provider = provider + end + + # + # @param flag_change [LaunchDarkly::Interfaces::FlagChange] + # + # @return [void] + # + def update(flag_change) + @provider.emit_event( + ::OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, + flags_changed: [flag_change.key] + ) + end + end + end + end +end diff --git a/lib/ldclient-openfeature/provider.rb b/lib/ldclient-openfeature/provider.rb index f339d43..7cec11a 100644 --- a/lib/ldclient-openfeature/provider.rb +++ b/lib/ldclient-openfeature/provider.rb @@ -6,6 +6,8 @@ module LaunchDarkly module OpenFeature class Provider + include ::OpenFeature::SDK::Provider::EventEmitter + # # Retrieve metadata information describing this provider. # @@ -37,6 +39,9 @@ def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds @details_converter = Impl::ResolutionDetailsConverter.new @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: "launchdarkly-openfeature-server").freeze + + @client.data_source_status_provider.add_listener(Impl::DataSourceStatusListener.new(self)) + @client.flag_tracker.add_listener(Impl::FlagChangeListener.new(self)) end def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) diff --git a/spec/impl/data_source_status_listener_spec.rb b/spec/impl/data_source_status_listener_spec.rb new file mode 100644 index 0000000..a879a30 --- /dev/null +++ b/spec/impl/data_source_status_listener_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.describe LaunchDarkly::OpenFeature::Impl::DataSourceStatusListener do + let(:provider) { double(emit_event: nil) } + let(:listener) { described_class.new(provider) } + + def status(state, error = nil) + LaunchDarkly::Interfaces::DataSource::Status.new(state, Time.now, error) + end + + it "a valid data source is ready" do + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::VALID)) + + expect(provider).to have_received(:emit_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + it "an interrupted data source is stale" do + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED)) + + expect(provider).to have_received(:emit_event) + .with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message)) + end + + it "a shut down data source is an error" do + error = LaunchDarkly::Interfaces::DataSource::ErrorInfo.new( + LaunchDarkly::Interfaces::DataSource::ErrorInfo::ERROR_RESPONSE, 401, "Unauthorized", Time.now + ) + + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error)) + + expect(provider).to have_received(:emit_event).with( + OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, + hash_including(error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: /401/) + ) + end + + it "an initializing data source does not emit an event" do + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) + + expect(provider).not_to have_received(:emit_event) + end +end diff --git a/spec/impl/flag_change_listener_spec.rb b/spec/impl/flag_change_listener_spec.rb new file mode 100644 index 0000000..1c9751d --- /dev/null +++ b/spec/impl/flag_change_listener_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +RSpec.describe LaunchDarkly::OpenFeature::Impl::FlagChangeListener do + let(:provider) { double(emit_event: nil) } + + it "a flag change is a configuration change" do + described_class.new(provider).update(LaunchDarkly::Interfaces::FlagChange.new("flag-key")) + + expect(provider).to have_received(:emit_event).with( + OpenFeature::SDK::ProviderEvent::PROVIDER_CONFIGURATION_CHANGED, + flags_changed: ["flag-key"] + ) + end +end