Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Ruby](https:/
| ✅ | Logging | The provider logs through the logger of the `LaunchDarkly::Config` it is given. |
| ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. |
| ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. |
| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. |
| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time, and waits without a deadline when that wait time is `LaunchDarkly::OpenFeature::Provider::WAIT_INDEFINITELY`; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. Since LaunchDarkly Ruby SDK 8.19.0 a rejected SDK key is retried rather than failing permanently, so `WAIT_INDEFINITELY` can wait indefinitely. |
| ✅ | Shutdown | `shutdown` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. |
| ✅ | Tracking | `track` sends a LaunchDarkly custom event for the evaluation context, with the tracking event value and remaining details attached. |
| ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. |
Expand Down
31 changes: 28 additions & 3 deletions lib/ldclient-openfeature/impl/event_listeners.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ def initialize(provider)
def update(status)
case status.state
when ::LaunchDarkly::Interfaces::DataSource::Status::VALID
@provider.emit_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
@provider.emit_status_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
when ::LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED
@provider.emit_event(
@provider.emit_status_event(
::OpenFeature::SDK::ProviderEvent::PROVIDER_STALE,
message: message(status, "the data source has been interrupted")
)
when ::LaunchDarkly::Interfaces::DataSource::Status::OFF
@provider.emit_event(
@provider.emit_status_event(
::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR,
error_code: ::OpenFeature::SDK::Provider::ErrorCode::GENERAL,
message: message(status, "the data source has been permanently shut down")
Expand All @@ -54,6 +54,31 @@ def update(status)
end
end

#
# Reports the first data source state which decides the outcome of the client's initial connection attempt.
#
class DataSourceOutcomeListener
#
# @param outcome [Queue]
#
def initialize(outcome)
@outcome = outcome
end

#
# @param status [LaunchDarkly::Interfaces::DataSource::Status]
#
# @return [void]
#
def update(status)
case status.state
when ::LaunchDarkly::Interfaces::DataSource::Status::VALID,
::LaunchDarkly::Interfaces::DataSource::Status::OFF
@outcome.push(status.state)
end
end
end

#
# Translates LaunchDarkly flag change events into OpenFeature configuration changed events.
#
Expand Down
95 changes: 88 additions & 7 deletions lib/ldclient-openfeature/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,46 +30,105 @@ class Provider
WRAPPER_NAME = "open-feature-ruby-server"
private_constant :WRAPPER_NAME

#
# A wait time which asks the provider to wait for the LaunchDarkly client to initialize without a deadline.
#
WAIT_INDEFINITELY = Float::INFINITY

#
# @param sdk_key [String]
# @param config [LaunchDarkly::Config]
# @param wait_for_seconds [Float]
# @param wait_for_seconds [Float] the number of seconds to wait for the client to become ready, or
# {WAIT_INDEFINITELY} to wait for it without a deadline
#
def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds = 5)
@client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds)
@wait_indefinitely = wait_for_seconds == WAIT_INDEFINITELY

# The LaunchDarkly client has no indefinite wait time, so init waits instead of the constructor.
client_wait_for_seconds = @wait_indefinitely ? 0 : wait_for_seconds
@client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), client_wait_for_seconds)

@status_lock = Mutex.new
@status_event = nil
@initialization_complete = false
@logger = config.logger
@context_converter = Impl::EvaluationContextConverter.new(config.logger)
@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))
@status_listener = Impl::DataSourceStatusListener.new(self)
@flag_change_listener = Impl::FlagChangeListener.new(self)

@client.data_source_status_provider.add_listener(@status_listener)
@client.flag_tracker.add_listener(@flag_change_listener)
end

#
# Called by the OpenFeature SDK when this provider is set. The LaunchDarkly client has already been given the
# opportunity to initialize, so this only reports whether that succeeded.
# Called by the OpenFeature SDK when this provider is set.
#
# A wait time has already been applied by the LaunchDarkly client constructor, so this reports whether that
# succeeded. {WAIT_INDEFINITELY} asks for no deadline, so this waits for the data source to become valid or to fail
# permanently.
#
# @param _evaluation_context [::OpenFeature::SDK::EvaluationContext, nil]
#
# @return [void]
#
def init(_evaluation_context = nil)
return if @client.initialized?
wait_for_data_source_outcome if @wait_indefinitely

initialized = @client.initialized?

@status_lock.synchronize do
# The OpenFeature SDK emits its own event for the outcome of initialization, so the status is recorded
# here without emitting an event.
@status_event = if initialized
::OpenFeature::SDK::ProviderEvent::PROVIDER_READY
else
::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR
end
@initialization_complete = true
end

return if initialized

state = @client.data_source_status_provider.status.state
raise "the LaunchDarkly client was unable to initialize; the data source state is #{state}"
end

#
# Emit a provider event for a data source state change.
#
# A state which does not change the provider status is not emitted, and neither is the change which completes
# initialization, because the OpenFeature SDK emits its own event for that one. Changes after initialization has
# completed, including after it has failed, are emitted.
#
# @param event [String]
# @param details [Hash]
#
# @return [void]
#
def emit_status_event(event, **details)
@status_lock.synchronize do
return if event == @status_event

@status_event = event
return unless @initialization_complete
end

emit_event(event, **details)
end

#
# Called by the OpenFeature SDK when this provider is replaced or the SDK is shut down. The LaunchDarkly client
# cannot be restarted, so a new provider instance is required afterward.
#
# @return [void]
#
def shutdown
@client.data_source_status_provider.remove_listener(@status_listener)
@client.flag_tracker.remove_listener(@flag_change_listener)
@client.close
end

Expand Down Expand Up @@ -170,6 +229,28 @@ def track(tracking_event_name, evaluation_context: nil, tracking_event_details:
@details_converter.to_resolution_details(evaluation_detail)
end

#
# Block until the data source reports an outcome for the client's first connection attempt.
#
# @return [void]
#
private def wait_for_data_source_outcome
outcome = Queue.new
listener = Impl::DataSourceOutcomeListener.new(outcome)
status_provider = @client.data_source_status_provider
status_provider.add_listener(listener)

begin
return if @client.initialized?

# An outcome reached before the listener was added is not reported to it.
listener.update(status_provider.status)
outcome.pop
ensure
status_provider.remove_listener(listener)
end
end
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved

#
# @param default_value [any]
#
Expand Down
29 changes: 29 additions & 0 deletions spec/impl/data_source_outcome_listener_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

RSpec.describe LaunchDarkly::OpenFeature::Impl::DataSourceOutcomeListener do
let(:outcome) { Queue.new }
let(:listener) { described_class.new(outcome) }

def status(state)
LaunchDarkly::Interfaces::DataSource::Status.new(state, Time.now, nil)
end

it "reports a valid data source" do
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::VALID))

expect(outcome.pop).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID)
end

it "reports a shut down data source" do
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::OFF))

expect(outcome.pop).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF)
end

it "does not report a state which leaves the outcome undecided" do
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING))
listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED))

expect(outcome).to be_empty
end
end
10 changes: 5 additions & 5 deletions spec/impl/data_source_status_listener_spec.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

RSpec.describe LaunchDarkly::OpenFeature::Impl::DataSourceStatusListener do
let(:provider) { double(emit_event: nil) }
let(:provider) { double(emit_status_event: nil) }
let(:listener) { described_class.new(provider) }

def status(state, error = nil)
Expand All @@ -11,13 +11,13 @@ def status(state, error = nil)
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)
expect(provider).to have_received(:emit_status_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)
expect(provider).to have_received(:emit_status_event)
.with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message))
end

Expand All @@ -28,7 +28,7 @@ def status(state, error = nil)

listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error))

expect(provider).to have_received(:emit_event).with(
expect(provider).to have_received(:emit_status_event).with(
OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR,
hash_including(error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: /401/)
)
Expand All @@ -37,6 +37,6 @@ def status(state, error = nil)
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)
expect(provider).not_to have_received(:emit_status_event)
end
end
Loading
Loading