From 1a7d2d20b0f3ab63aa8168cb36fff0ea58f0686a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:05:50 +0000 Subject: [PATCH 1/5] chore: add flag change listener support to contract tests Co-Authored-By: mkeeler@launchdarkly.com --- contract-tests/client_entity.rb | 22 +++++ contract-tests/flag_change_listener.rb | 129 +++++++++++++++++++++++++ contract-tests/service.rb | 11 +++ 3 files changed, 162 insertions(+) create mode 100644 contract-tests/flag_change_listener.rb diff --git a/contract-tests/client_entity.rb b/contract-tests/client_entity.rb index 1b6570f9..0add362f 100644 --- a/contract-tests/client_entity.rb +++ b/contract-tests/client_entity.rb @@ -3,6 +3,7 @@ require 'net/http' require 'launchdarkly-server-sdk' require './big_segment_store_fixture' +require './flag_change_listener' require './hook' require 'http' @@ -117,6 +118,8 @@ def initialize(log, config) config[:credential], LaunchDarkly::Config.new(opts), startWaitTimeMs / 1_000.0) + + @listeners = ListenerRegistry.new(@client.flag_tracker) end def initialized? @@ -225,7 +228,26 @@ def log @log end + def register_flag_change_listener(params) + @listeners.register_flag_change_listener(params[:listenerId], params[:callbackUri]) + end + + def register_flag_value_change_listener(params) + context = LaunchDarkly::LDContext.create(params[:context]) + @listeners.register_flag_value_change_listener( + params[:listenerId], + params[:flagKey], + context, + params[:callbackUri] + ) + end + + def unregister_listener(params) + @listeners.unregister(params[:listenerId]) + end + def close + @listeners.close_all @client.close @log.info("Test ended") end diff --git a/contract-tests/flag_change_listener.rb b/contract-tests/flag_change_listener.rb new file mode 100644 index 00000000..59e79d67 --- /dev/null +++ b/contract-tests/flag_change_listener.rb @@ -0,0 +1,129 @@ +require 'http' +require 'json' + +# +# A listener that receives FlagChange events and POSTs notifications to a callback URI. +# Implements the #update method expected by the SDK's FlagTracker. +# +class FlagChangeCallbackListener + def initialize(listener_id, callback_uri) + @listener_id = listener_id + @callback_uri = callback_uri + end + + # @param flag_change [LaunchDarkly::Interfaces::FlagChange] + def update(flag_change) + payload = { + listenerId: @listener_id, + flagKey: flag_change.key, + } + HTTP.post(@callback_uri, json: payload) + rescue => e + # Log but don't re-raise; listener errors shouldn't crash the test service + $log.error("FlagChangeCallbackListener POST failed: #{e}") + end +end + +# +# A listener that receives FlagValueChange events and POSTs notifications to a callback URI. +# Implements the #update method expected by the SDK's FlagTracker (via FlagValueChangeAdapter). +# +class FlagValueChangeCallbackListener + def initialize(listener_id, callback_uri) + @listener_id = listener_id + @callback_uri = callback_uri + end + + # @param flag_value_change [LaunchDarkly::Interfaces::FlagValueChange] + def update(flag_value_change) + payload = { + listenerId: @listener_id, + flagKey: flag_value_change.key, + oldValue: flag_value_change.old_value, + newValue: flag_value_change.new_value, + } + HTTP.post(@callback_uri, json: payload) + rescue => e + $log.error("FlagValueChangeCallbackListener POST failed: #{e}") + end +end + +# +# Manages all active flag change listener registrations for a single SDK client entity. +# Thread-safe via a Mutex. +# +class ListenerRegistry + # @param tracker [LaunchDarkly::Interfaces::FlagTracker] + def initialize(tracker) + @tracker = tracker + @mu = Mutex.new + @listeners = {} # listenerId => listener object to pass to remove_listener + end + + # Registers a general flag change listener that fires on any flag configuration change. + # + # @param listener_id [String] + # @param callback_uri [String] + def register_flag_change_listener(listener_id, callback_uri) + listener = FlagChangeCallbackListener.new(listener_id, callback_uri) + store_listener(listener_id, listener) + @tracker.add_listener(listener) + end + + # Registers a flag value change listener that fires when the evaluated value of a + # specific flag changes for a given context. + # + # @param listener_id [String] + # @param flag_key [String] + # @param context [LaunchDarkly::LDContext] + # @param callback_uri [String] + def register_flag_value_change_listener(listener_id, flag_key, context, callback_uri) + inner_listener = FlagValueChangeCallbackListener.new(listener_id, callback_uri) + # add_flag_value_change_listener returns the adapter object that must be passed to + # remove_listener for unregistration. + adapter = @tracker.add_flag_value_change_listener(flag_key, context, inner_listener) + store_listener(listener_id, adapter) + end + + # Unregisters a previously registered listener by its ID. + # + # @param listener_id [String] + # @return [Boolean] true if the listener was found and removed + def unregister(listener_id) + listener = nil + @mu.synchronize do + listener = @listeners.delete(listener_id) + end + + return false if listener.nil? + + @tracker.remove_listener(listener) + true + end + + # Removes all registered listeners. Called when the SDK client entity shuts down. + def close_all + listeners_to_remove = nil + @mu.synchronize do + listeners_to_remove = @listeners.values + @listeners = {} + end + + listeners_to_remove.each do |listener| + @tracker.remove_listener(listener) + end + end + + private + + # Stores a listener, cancelling any previously registered listener with the same ID. + def store_listener(listener_id, listener) + old_listener = nil + @mu.synchronize do + old_listener = @listeners[listener_id] + @listeners[listener_id] = listener + end + + @tracker.remove_listener(old_listener) if old_listener + end +end diff --git a/contract-tests/service.rb b/contract-tests/service.rb index 1d6383c7..4d083696 100644 --- a/contract-tests/service.rb +++ b/contract-tests/service.rb @@ -51,6 +51,8 @@ 'persistent-data-store-consul', 'persistent-data-store-dynamodb', 'persistent-data-store-redis', + 'flag-change-listeners', + 'flag-value-change-listeners', ], }.to_json end @@ -128,6 +130,15 @@ when "contextComparison" response = {:equals => client.context_comparison(params[:contextComparison])} return [200, nil, response.to_json] + when "registerFlagChangeListener" + client.register_flag_change_listener(params[:registerFlagChangeListener]) + return 201 + when "registerFlagValueChangeListener" + client.register_flag_value_change_listener(params[:registerFlagValueChangeListener]) + return 201 + when "unregisterListener" + success = client.unregister_listener(params[:unregisterListener]) + return success ? 201 : [400, nil, {:error => "no listener with that id"}.to_json] end return [400, nil, {:error => "Unknown command requested"}.to_json] From c5b1f2182152745630a109f54867dc35869931d1 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 3 Mar 2026 11:30:15 -0500 Subject: [PATCH 2/5] Update contract-tests/flag_change_listener.rb --- contract-tests/flag_change_listener.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/contract-tests/flag_change_listener.rb b/contract-tests/flag_change_listener.rb index 59e79d67..52a9a82f 100644 --- a/contract-tests/flag_change_listener.rb +++ b/contract-tests/flag_change_listener.rb @@ -114,10 +114,8 @@ def close_all end end - private - # Stores a listener, cancelling any previously registered listener with the same ID. - def store_listener(listener_id, listener) + private def store_listener(listener_id, listener) old_listener = nil @mu.synchronize do old_listener = @listeners[listener_id] From 051ffc0693072781e1de456a86ead4ad8c7aa429 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:30:35 +0000 Subject: [PATCH 3/5] chore: use private def style for store_listener method Co-Authored-By: mkeeler@launchdarkly.com --- contract-tests/flag_change_listener.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/contract-tests/flag_change_listener.rb b/contract-tests/flag_change_listener.rb index 59e79d67..52a9a82f 100644 --- a/contract-tests/flag_change_listener.rb +++ b/contract-tests/flag_change_listener.rb @@ -114,10 +114,8 @@ def close_all end end - private - # Stores a listener, cancelling any previously registered listener with the same ID. - def store_listener(listener_id, listener) + private def store_listener(listener_id, listener) old_listener = nil @mu.synchronize do old_listener = @listeners[listener_id] From 8db9224e212f98c67a42a6ec08e10bfde093188c Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Thu, 5 Mar 2026 16:22:53 -0500 Subject: [PATCH 4/5] bump to alpha.4 --- .github/actions/check/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/check/action.yml b/.github/actions/check/action.yml index a1290210..7f6539df 100644 --- a/.github/actions/check/action.yml +++ b/.github/actions/check/action.yml @@ -51,4 +51,4 @@ runs: test_service_port: 9000 enable_persistence_tests: true token: ${{ inputs.token }} - version: v3.0.0-alpha.3 \ No newline at end of file + version: v3.0.0-alpha.4 From 1b84760313aab2c9c9d0cc4806b26e37b05c4356 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 6 Mar 2026 09:58:10 -0500 Subject: [PATCH 5/5] code review feedback --- contract-tests/flag_change_listener.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contract-tests/flag_change_listener.rb b/contract-tests/flag_change_listener.rb index 52a9a82f..ca87812b 100644 --- a/contract-tests/flag_change_listener.rb +++ b/contract-tests/flag_change_listener.rb @@ -66,8 +66,8 @@ def initialize(tracker) # @param callback_uri [String] def register_flag_change_listener(listener_id, callback_uri) listener = FlagChangeCallbackListener.new(listener_id, callback_uri) - store_listener(listener_id, listener) @tracker.add_listener(listener) + store_listener(listener_id, listener) end # Registers a flag value change listener that fires when the evaluated value of a