Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Ruby](https:/
| ✅ | Hooks | Hooks are registered on the OpenFeature API and client; the provider requires no additional support and its results are visible to hooks, including [flag metadata](#flag-metadata). |
| ✅ | 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. The provider is not domain-scoped, so one instance may also be bound to several domains, which then share its LaunchDarkly client. |
| ⚠️ | 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. Provider gaps: a data source update that does not change the provider status, such as a new error while interrupted, is emitted again ([#36](https://github.com/launchdarkly/openfeature-ruby-server/pull/36)); and the provider relies on the OpenFeature SDK to emit `PROVIDER_READY` or `PROVIDER_ERROR` when `init` completes, which OpenFeature Ruby SDK 0.7.0 deprecates in favor of provider-emitted lifecycle events ([#39](https://github.com/launchdarkly/openfeature-ruby-server/issues/39)). |
| ✅ | 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. Since LaunchDarkly Ruby SDK 8.19.0 a rejected SDK key is retried rather than failing permanently, so the client constructor waits the full wait time before initialization fails. |
| ⚠️ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`; an update that does not change the provider status is not emitted again. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. Provider gap: the provider relies on the OpenFeature SDK to emit `PROVIDER_READY` or `PROVIDER_ERROR` when `init` completes, which OpenFeature Ruby SDK 0.7.0 deprecates in favor of provider-emitted lifecycle events ([#39](https://github.com/launchdarkly/openfeature-ruby-server/issues/39)). |
| ✅ | 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 the client constructor waits the full wait time before initialization fails, and `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