Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 `nil`; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. |
| ✅ | 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
86 changes: 79 additions & 7 deletions lib/ldclient-openfeature/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,43 +33,94 @@ class Provider
#
# @param sdk_key [String]
# @param config [LaunchDarkly::Config]
# @param wait_for_seconds [Float]
# @param wait_for_seconds [Float, nil] the number of seconds to wait for the client to become ready, or nil 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)
@client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds.nil? ? 0 : wait_for_seconds)

@wait_for_seconds = 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. A nil wait time 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_for_seconds.nil?

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 +221,27 @@ 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?
return if status_provider.status.state == ::LaunchDarkly::Interfaces::DataSource::Status::OFF

outcome.pop
ensure
status_provider.remove_listener(listener)
end
end

#
# @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
92 changes: 88 additions & 4 deletions spec/provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
let(:config) { LaunchDarkly::Config.new(data_source: td, send_events: false) }
let(:provider) { described_class.new("example-key", config) }

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

it "metadata is set correctly" do
expect(provider.metadata.name).to eq("launchdarkly-openfeature-server")
end
Expand Down Expand Up @@ -48,18 +52,98 @@
end

it "init raises when the client failed to initialize" do
status = LaunchDarkly::Interfaces::DataSource::Status.new(LaunchDarkly::Interfaces::DataSource::Status::OFF, Time.now, nil)
status_provider = double(status: status)
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF))
allow(provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)

expect { provider.init(evaluation_context) }.to raise_error(/unable to initialize/)
end

it "shutdown closes the client" do
allow(provider.client).to receive(:close)
it "init without a deadline waits for the data source to become valid" do
indefinite_provider = described_class.new("example-key", config, nil)

expect { indefinite_provider.init(evaluation_context) }.not_to raise_error
expect(indefinite_provider.client.initialized?).to be(true)
end

it "init without a deadline waits for a data source outcome which arrives later" do
indefinite_provider = described_class.new("example-key", config, nil)
listeners = []
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING))
allow(status_provider).to receive(:add_listener) { |listener| listeners << listener }
allow(status_provider).to receive(:remove_listener)
allow(indefinite_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)

reporter = Thread.new do
sleep(0.01) while listeners.empty?
listeners.each { |listener| listener.update(data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF)) }
end

expect { indefinite_provider.init(evaluation_context) }.to raise_error(/unable to initialize/)

reporter.join
end

it "init with a wait time of zero does not wait" do
zero_wait_provider = described_class.new("example-key", config, 0)
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING))
allow(status_provider).to receive(:add_listener)
allow(zero_wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)

expect { zero_wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/)
expect(status_provider).not_to have_received(:add_listener)
end

it "init with a wait time does not wait again" do
wait_provider = described_class.new("example-key", config, 5)
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING))
allow(status_provider).to receive(:add_listener)
allow(wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)

expect { wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/)
expect(status_provider).not_to have_received(:add_listener)
end

it "a status change before initialization completes is not emitted" do
allow(provider).to receive(:emit_event)

provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)

expect(provider).not_to have_received(:emit_event)
end

it "a status change after initialization failed is emitted" do
status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF))
allow(provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider)
expect { provider.init(evaluation_context) }.to raise_error(/unable to initialize/)
allow(provider).to receive(:emit_event)

provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)

expect(provider).to have_received(:emit_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
end

it "a status which does not change is not emitted" do
provider.init(evaluation_context)
allow(provider).to receive(:emit_event)

provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY)
provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: "interrupted")
provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: "interrupted again")

expect(provider).to have_received(:emit_event).once
expect(provider).to have_received(:emit_event)
.with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message))
end

it "shutdown unsubscribes the listeners and closes the client" do
status_provider = double(remove_listener: nil)
flag_tracker = double(remove_listener: nil)
allow(provider.client).to receive_messages(close: nil, data_source_status_provider: status_provider, flag_tracker: flag_tracker)

provider.shutdown

expect(status_provider).to have_received(:remove_listener).with(instance_of(LaunchDarkly::OpenFeature::Impl::DataSourceStatusListener))
expect(flag_tracker).to have_received(:remove_listener).with(instance_of(LaunchDarkly::OpenFeature::Impl::FlagChangeListener))
expect(provider.client).to have_received(:close)
end

Expand Down
Loading