From d9b9457aee2d2845c430c348fce72de03d3f8ae5 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Feb 2026 11:28:01 -0800 Subject: [PATCH 1/7] Added flag-change-listeners capability Added the "flag-change-listeners" capability constant to service_params.go and documented it in service_spec.md. This capability indicates that an SDK supports registering listeners for flag changes and can notify the test harness via callbacks. The capability supports testing both general flag change listeners (notified on any flag configuration change) and flag value change listeners (notified when a specific flag's evaluated value changes for a given context). --- docs/service_spec.md | 11 +++++++++++ servicedef/service_params.go | 1 + 2 files changed, 12 insertions(+) diff --git a/docs/service_spec.md b/docs/service_spec.md index c2c6429a..ec871df7 100644 --- a/docs/service_spec.md +++ b/docs/service_spec.md @@ -199,6 +199,17 @@ A test hook must: * `stage` (string, optional): If executing a stage, for example `beforeEvaluation`, this should be the stage. - Return data from the stages as specified via the `data` configuration. For instance the return value from the `beforeEvaluation` hook should be `data['beforeEvaluation']` merged with the input data for the stage. +#### Capability `"flag-change-listeners"` + +This means that the SDK has support for flag change listeners and can notify the test harness when flags change. + +The SDK must support registering listeners that monitor for flag changes. When a flag changes, the SDK test service should POST notification data to the callback URI provided during listener registration. + +The test harness supports testing two types of listeners: +- **General flag change listeners**: Notified when any flag's configuration changes +- **Flag value change listeners**: Notified when a specific flag's evaluated value changes for a given context + +For details on the commands and callback payloads, see the `registerFlagChangeListener`, `registerFlagValueChangeListener`, and `unregisterListener` commands. #### Capability `"tls:verify-peer"` diff --git a/servicedef/service_params.go b/servicedef/service_params.go index 08d92bf4..7697a973 100644 --- a/servicedef/service_params.go +++ b/servicedef/service_params.go @@ -40,6 +40,7 @@ const ( CapabilityAnonymousRedaction = "anonymous-redaction" CapabilityPollingGzip = "polling-gzip" CapabilityEvaluationHooks = "evaluation-hooks" + CapabilityFlagChangeListeners = "flag-change-listeners" CapabilityClientPrereqEvents = "client-prereq-events" CapabilityPersistentDataStoreRedis = "persistent-data-store-redis" CapabilityPersistentDataStoreConsul = "persistent-data-store-consul" From c11a0dca50feeeebf50fcb55b35c84658493c4a8 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Feb 2026 15:17:40 -0800 Subject: [PATCH 2/7] Add flag change listener command structures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the command constants and parameter structs needed for the test harness to instruct SDK test services to register and unregister flag change listeners at runtime. Three new commands are added to servicedef/command_params.go: - registerFlagChangeListener: registers a general flag change listener that notifies when any flag's configuration changes. Params include a listener ID, an optional flag key to filter on, and a callback URI where the test service will POST notifications. - registerFlagValueChangeListener: registers a value-specific listener that notifies when a flag's evaluated value changes for a given context. Params include a listener ID, flag key, evaluation context, default value, and callback URI. - unregisterListener: removes a previously registered listener by ID. Unlike existing commands (evaluate, migrate, etc.), these commands are stateful — the test service must maintain a map of active listeners between commands. This is the first example of dynamic, post-init registration in the test service protocol. Co-authored-by: Cursor --- servicedef/command_params.go | 77 +++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/servicedef/command_params.go b/servicedef/command_params.go index 2164dde5..d953f611 100644 --- a/servicedef/command_params.go +++ b/servicedef/command_params.go @@ -12,19 +12,22 @@ import ( ) const ( - CommandEvaluateFlag = "evaluate" - CommandEvaluateAllFlags = "evaluateAll" - CommandIdentifyEvent = "identifyEvent" - CommandCustomEvent = "customEvent" - CommandAliasEvent = "aliasEvent" - CommandFlushEvents = "flushEvents" - CommandGetBigSegmentStoreStatus = "getBigSegmentStoreStatus" - CommandContextBuild = "contextBuild" - CommandContextConvert = "contextConvert" - CommandContextComparison = "contextComparison" - CommandSecureModeHash = "secureModeHash" - CommandMigrationVariation = "migrationVariation" - CommandMigrationOperation = "migrationOperation" + CommandEvaluateFlag = "evaluate" + CommandEvaluateAllFlags = "evaluateAll" + CommandIdentifyEvent = "identifyEvent" + CommandCustomEvent = "customEvent" + CommandAliasEvent = "aliasEvent" + CommandFlushEvents = "flushEvents" + CommandGetBigSegmentStoreStatus = "getBigSegmentStoreStatus" + CommandContextBuild = "contextBuild" + CommandContextConvert = "contextConvert" + CommandContextComparison = "contextComparison" + CommandSecureModeHash = "secureModeHash" + CommandMigrationVariation = "migrationVariation" + CommandMigrationOperation = "migrationOperation" + CommandRegisterFlagChangeListener = "registerFlagChangeListener" + CommandRegisterFlagValueChangeListener = "registerFlagValueChangeListener" + CommandUnregisterListener = "unregisterListener" ) type ValueType string @@ -38,17 +41,20 @@ const ( ) type CommandParams struct { - Command string `json:"command"` - Evaluate o.Maybe[EvaluateFlagParams] `json:"evaluate,omitempty"` - EvaluateAll o.Maybe[EvaluateAllFlagsParams] `json:"evaluateAll,omitempty"` - CustomEvent o.Maybe[CustomEventParams] `json:"customEvent,omitempty"` - IdentifyEvent o.Maybe[IdentifyEventParams] `json:"identifyEvent,omitempty"` - ContextBuild o.Maybe[ContextBuildParams] `json:"contextBuild,omitempty"` - ContextConvert o.Maybe[ContextConvertParams] `json:"contextConvert,omitempty"` - ContextComparison o.Maybe[ContextComparisonPairParams] `json:"contextComparison,omitempty"` - SecureModeHash o.Maybe[SecureModeHashParams] `json:"secureModeHash,omitempty"` - MigrationVariation o.Maybe[MigrationVariationParams] `json:"migrationVariation,omitempty"` - MigrationOperation o.Maybe[MigrationOperationParams] `json:"migrationOperation,omitempty"` + Command string `json:"command"` + Evaluate o.Maybe[EvaluateFlagParams] `json:"evaluate,omitempty"` + EvaluateAll o.Maybe[EvaluateAllFlagsParams] `json:"evaluateAll,omitempty"` + CustomEvent o.Maybe[CustomEventParams] `json:"customEvent,omitempty"` + IdentifyEvent o.Maybe[IdentifyEventParams] `json:"identifyEvent,omitempty"` + ContextBuild o.Maybe[ContextBuildParams] `json:"contextBuild,omitempty"` + ContextConvert o.Maybe[ContextConvertParams] `json:"contextConvert,omitempty"` + ContextComparison o.Maybe[ContextComparisonPairParams] `json:"contextComparison,omitempty"` + SecureModeHash o.Maybe[SecureModeHashParams] `json:"secureModeHash,omitempty"` + MigrationVariation o.Maybe[MigrationVariationParams] `json:"migrationVariation,omitempty"` + MigrationOperation o.Maybe[MigrationOperationParams] `json:"migrationOperation,omitempty"` + RegisterFlagChangeListener o.Maybe[RegisterFlagChangeListenerParams] `json:"registerFlagChangeListener,omitempty"` //nolint:lll + RegisterFlagValueChangeListener o.Maybe[RegisterFlagValueChangeListenerParams] `json:"registerFlagValueChangeListener,omitempty"` //nolint:lll + UnregisterListener o.Maybe[UnregisterListenerParams] `json:"unregisterListener,omitempty"` } type EvaluateFlagParams struct { @@ -208,3 +214,26 @@ type HookExecutionPayload struct { EvaluationDetail o.Maybe[EvaluateFlagResponse] `json:"evaluationDetail"` Stage o.Maybe[HookStage] `json:"stage"` } + +// RegisterFlagChangeListenerParams defines parameters for registering a general flag change listener. +// The listener will be notified whenever any flag's configuration changes. +type RegisterFlagChangeListenerParams struct { + ListenerID string `json:"listenerId"` + FlagKey string `json:"flagKey"` + CallbackURI string `json:"callbackUri"` +} + +// RegisterFlagValueChangeListenerParams defines parameters for registering a flag value change listener. +// The listener will be notified when the evaluated value of the specified flag changes for the given context. +type RegisterFlagValueChangeListenerParams struct { + ListenerID string `json:"listenerId"` + FlagKey string `json:"flagKey"` + Context ldcontext.Context `json:"context"` + DefaultValue ldvalue.Value `json:"defaultValue"` + CallbackURI string `json:"callbackUri"` +} + +// UnregisterListenerParams defines parameters for unregistering a previously registered listener. +type UnregisterListenerParams struct { + ListenerID string `json:"listenerId"` +} From d5a4a40aebd2802386e8ca58b2d15b37fd2c6618 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Feb 2026 16:03:57 -0800 Subject: [PATCH 3/7] Add ListenerNotification and ListenerCallback types Define the callback infrastructure needed for flag change listener tests: - servicedef/command_params.go: add ListenerNotification, the JSON payload POSTed by the SDK test service to the callback URI when a listener fires. OldValue/NewValue are present only for value change notifications. - mockld/listener_callback_service.go: add ListenerCallbackService, a mock HTTP endpoint that receives POSTed notifications and delivers them to a Go channel for consumption by tests. Mirrors HookCallbackService. - sdktests/testapi_listeners.go: add ListenerCallback, the test API layer wrapping ListenerCallbackService with three assertion helpers: ExpectFlagChangeNotification, ExpectValueChangeNotification, and ExpectNoNotification. Co-authored-by: Cursor --- mockld/listener_callback_service.go | 69 ++++++++++++++++++ sdktests/testapi_listeners.go | 108 ++++++++++++++++++++++++++++ servicedef/command_params.go | 11 +++ 3 files changed, 188 insertions(+) create mode 100644 mockld/listener_callback_service.go create mode 100644 sdktests/testapi_listeners.go diff --git a/mockld/listener_callback_service.go b/mockld/listener_callback_service.go new file mode 100644 index 00000000..f914e45b --- /dev/null +++ b/mockld/listener_callback_service.go @@ -0,0 +1,69 @@ +package mockld + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/launchdarkly/sdk-test-harness/v2/framework" + "github.com/launchdarkly/sdk-test-harness/v2/framework/harness" + "github.com/launchdarkly/sdk-test-harness/v2/servicedef" +) + +// ListenerCallbackService is a mock HTTP server that receives flag change listener notifications +// POSTed by an SDK test service. Each registered listener should have its own instance so that +// notifications can be attributed to the correct listener in test assertions. +type ListenerCallbackService struct { + payloadEndpoint *harness.MockEndpoint + CallChannel chan servicedef.ListenerNotification +} + +// GetURL returns the callback URI to provide when registering a listener. The SDK test service +// will POST a ListenerNotification JSON body to this URL when the listener fires. +func (l *ListenerCallbackService) GetURL() string { + return l.payloadEndpoint.BaseURL() +} + +// Close shuts down the mock HTTP endpoint and releases its resources. +func (l *ListenerCallbackService) Close() { + l.payloadEndpoint.Close() +} + +// NewListenerCallbackService creates a ListenerCallbackService with a mock HTTP endpoint +// ready to receive notifications. Call Close() when done. +func NewListenerCallbackService( + testHarness *harness.TestHarness, + logger framework.Logger, +) *ListenerCallbackService { + l := &ListenerCallbackService{ + CallChannel: make(chan servicedef.ListenerNotification), + } + + endpointHandler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + bytes, err := io.ReadAll(req.Body) + logger.Printf("Received listener notification: %s", string(bytes)) + if err != nil { + logger.Printf("Could not read body from listener callback.") + w.WriteHeader(http.StatusBadRequest) + return + } + var notification servicedef.ListenerNotification + err = json.Unmarshal(bytes, ¬ification) + if err != nil { + logger.Printf("Could not unmarshal listener notification.") + w.WriteHeader(http.StatusBadRequest) + return + } + + go func() { + l.CallChannel <- notification + }() + + w.WriteHeader(http.StatusOK) + }) + + l.payloadEndpoint = testHarness.NewMockEndpoint( + endpointHandler, logger, harness.MockEndpointDescription("listener notification")) + + return l +} diff --git a/sdktests/testapi_listeners.go b/sdktests/testapi_listeners.go new file mode 100644 index 00000000..6bec61a6 --- /dev/null +++ b/sdktests/testapi_listeners.go @@ -0,0 +1,108 @@ +package sdktests + +import ( + "time" + + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/sdk-test-harness/v2/framework" + "github.com/launchdarkly/sdk-test-harness/v2/framework/harness" + "github.com/launchdarkly/sdk-test-harness/v2/framework/helpers" + "github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest" + o "github.com/launchdarkly/sdk-test-harness/v2/framework/opt" + "github.com/launchdarkly/sdk-test-harness/v2/mockld" + "github.com/launchdarkly/sdk-test-harness/v2/servicedef" + + "github.com/stretchr/testify/assert" +) + +const listenerReceiveTimeout = time.Second * 5 +const listenerNoNotificationTimeout = time.Millisecond * 500 + +// ListenerCallback is used in flag change listener tests to receive and assert on listener +// notifications from the SDK test service. Each instance manages a dedicated mock HTTP +// endpoint that the SDK test service POSTs to when the registered listener fires. +// +// The general usage pattern is: +// +// callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) +// defer callback.Close() +// client.RegisterFlagChangeListener(t, listenerID, flagKey, callback.GetURL()) +// // ... trigger a flag change via the streaming service ... +// callback.ExpectFlagChangeNotification(t, flagKey) +type ListenerCallback struct { + service *mockld.ListenerCallbackService +} + +// NewListenerCallback creates a ListenerCallback with a dedicated mock HTTP endpoint ready to +// receive notifications. Call Close() when done to release the endpoint. +func NewListenerCallback( + testHarness *harness.TestHarness, + logger framework.Logger, +) *ListenerCallback { + return &ListenerCallback{ + service: mockld.NewListenerCallbackService(testHarness, logger), + } +} + +// GetURL returns the callback URI to pass as the callbackUri field in a +// registerFlagChangeListener or registerFlagValueChangeListener command. +func (lc *ListenerCallback) GetURL() string { + return lc.service.GetURL() +} + +// Close shuts down the mock HTTP endpoint. +func (lc *ListenerCallback) Close() { + lc.service.Close() +} + +// ExpectFlagChangeNotification waits up to listenerReceiveTimeout for a general flag change +// notification for the given flag key. It fails the test if no notification arrives within the +// timeout, or if the notification's flag key does not match. Returns the notification for +// further inspection. +func (lc *ListenerCallback) ExpectFlagChangeNotification( + t *ldtest.T, + flagKey string, +) servicedef.ListenerNotification { + notification := helpers.RequireValueWithMessage( + t, lc.service.CallChannel, listenerReceiveTimeout, + "timed out waiting for flag change notification for flag %q", flagKey, + ) + + assert.Equal(t, flagKey, notification.FlagKey, + "flag change notification had unexpected flag key") + + return notification +} + +// ExpectValueChangeNotification waits up to listenerReceiveTimeout for a value change +// notification for the given flag key, and asserts that the old and new values match. +// It fails the test if no notification arrives within the timeout, or if any assertion fails. +// Returns the notification for further inspection. +func (lc *ListenerCallback) ExpectValueChangeNotification( + t *ldtest.T, + flagKey string, + oldValue ldvalue.Value, + newValue ldvalue.Value, +) servicedef.ListenerNotification { + notification := helpers.RequireValueWithMessage( + t, lc.service.CallChannel, listenerReceiveTimeout, + "timed out waiting for value change notification for flag %q", flagKey, + ) + + assert.Equal(t, flagKey, notification.FlagKey, + "value change notification had unexpected flag key") + assert.Equal(t, o.Some(oldValue), notification.OldValue, + "value change notification had unexpected old value for flag %q", flagKey) + assert.Equal(t, o.Some(newValue), notification.NewValue, + "value change notification had unexpected new value for flag %q", flagKey) + + return notification +} + +// ExpectNoNotification asserts that no notification arrives within listenerNoNotificationTimeout. +// Use this to verify that a listener did NOT fire (e.g., because the flag value did not change). +// The flagKey parameter is used only in the failure message if a notification unexpectedly arrives. +func (lc *ListenerCallback) ExpectNoNotification(t *ldtest.T, flagKey string) { + helpers.RequireNoMoreValuesWithMessage(t, lc.service.CallChannel, listenerNoNotificationTimeout, + "received unexpected listener notification for flag %q", flagKey) +} diff --git a/servicedef/command_params.go b/servicedef/command_params.go index d953f611..df201f4e 100644 --- a/servicedef/command_params.go +++ b/servicedef/command_params.go @@ -237,3 +237,14 @@ type RegisterFlagValueChangeListenerParams struct { type UnregisterListenerParams struct { ListenerID string `json:"listenerId"` } + +// ListenerNotification is the JSON payload POSTed by the SDK test service to a callback URI +// when a flag change listener fires. OldValue and NewValue are only present for value change +// notifications (registerFlagValueChangeListener), not for general flag change notifications +// (registerFlagChangeListener). +type ListenerNotification struct { + ListenerID string `json:"listenerId"` + FlagKey string `json:"flagKey"` + OldValue o.Maybe[ldvalue.Value] `json:"oldValue,omitempty"` + NewValue o.Maybe[ldvalue.Value] `json:"newValue,omitempty"` +} From 997b50173ca3b53953623e08b43ab8cf68d3bbd8 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Feb 2026 16:08:04 -0800 Subject: [PATCH 4/7] Add listener register/unregister methods to SDKClient Add three methods to SDKClient in sdktests/testapi_sdk_client.go: - RegisterFlagChangeListener: sends a registerFlagChangeListener command to the test service, registering a listener for general flag config changes on an optional specific flag key. - RegisterFlagValueChangeListener: sends a registerFlagValueChangeListener command, registering a listener for evaluated value changes for a specific flag key and context. - UnregisterListener: sends an unregisterListener command to remove a previously registered listener by ID. All three follow the existing SDKClient command pattern: a single params struct argument, SendCommandWithParams, and nil response (registration and unregistration produce no response body). Co-authored-by: Cursor --- sdktests/testapi_sdk_client.go | 53 ++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/sdktests/testapi_sdk_client.go b/sdktests/testapi_sdk_client.go index 537305e7..ed3f4d44 100644 --- a/sdktests/testapi_sdk_client.go +++ b/sdktests/testapi_sdk_client.go @@ -415,6 +415,59 @@ func (c *SDKClient) ContextComparison(t *ldtest.T, params servicedef.ContextComp return resp } +// RegisterFlagChangeListener tells the SDK test service to register a general flag change listener +// for the given flag key. The listener will POST a ListenerNotification to callbackURI whenever +// the flag's configuration changes. Pass an empty flagKey to listen for changes to any flag. +// +// Any error from the test service causes the test to terminate immediately. +func (c *SDKClient) RegisterFlagChangeListener( + t *ldtest.T, + params servicedef.RegisterFlagChangeListenerParams, +) { + require.NoError(t, c.sdkClientEntity.SendCommandWithParams( + servicedef.CommandParams{ + Command: servicedef.CommandRegisterFlagChangeListener, + RegisterFlagChangeListener: o.Some(params), + }, + t.DebugLogger(), + nil, + )) +} + +// RegisterFlagValueChangeListener tells the SDK test service to register a value change listener +// for the given flag key and context. The listener will POST a ListenerNotification to callbackURI +// whenever the evaluated value of the flag changes for that context. +// +// Any error from the test service causes the test to terminate immediately. +func (c *SDKClient) RegisterFlagValueChangeListener( + t *ldtest.T, + params servicedef.RegisterFlagValueChangeListenerParams, +) { + require.NoError(t, c.sdkClientEntity.SendCommandWithParams( + servicedef.CommandParams{ + Command: servicedef.CommandRegisterFlagValueChangeListener, + RegisterFlagValueChangeListener: o.Some(params), + }, + t.DebugLogger(), + nil, + )) +} + +// UnregisterListener tells the SDK test service to unregister a previously registered listener +// by its ID. +// +// Any error from the test service causes the test to terminate immediately. +func (c *SDKClient) UnregisterListener(t *ldtest.T, params servicedef.UnregisterListenerParams) { + require.NoError(t, c.sdkClientEntity.SendCommandWithParams( + servicedef.CommandParams{ + Command: servicedef.CommandUnregisterListener, + UnregisterListener: o.Some(params), + }, + t.DebugLogger(), + nil, + )) +} + // GetSecureModeHash tells the SDK client to calculate a secure mode hash for a context. The test // harness will only call this method if the test service has the "secure-mode-hash" capability. func (c *SDKClient) GetSecureModeHash(t *ldtest.T, context ldcontext.Context) string { From f19469f694653ec0f420aeef657f6a887fe0c1a8 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Feb 2026 16:59:58 -0800 Subject: [PATCH 5/7] Add flag change listener test cases Expand the listener test suite with additional coverage for both listener types. For general flag change listeners: verify that notifications fire on any configuration change (not only value changes), and that registering with an empty flag key subscribes to changes for all flags. For value change listeners: verify that multiple independent listeners for the same flag both receive notifications, and that listener notifications are scoped to the specific evaluation context they were registered with. Co-authored-by: Cursor --- sdktests/common_tests_listeners.go | 287 +++++++++++++++++++++++++++++ sdktests/testsuite_entry_point.go | 1 + 2 files changed, 288 insertions(+) create mode 100644 sdktests/common_tests_listeners.go diff --git a/sdktests/common_tests_listeners.go b/sdktests/common_tests_listeners.go new file mode 100644 index 00000000..7014922c --- /dev/null +++ b/sdktests/common_tests_listeners.go @@ -0,0 +1,287 @@ +package sdktests + +import ( + "github.com/launchdarkly/go-test-helpers/v2/jsonhelpers" + + "github.com/launchdarkly/go-sdk-common/v3/ldattr" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldbuilders" + "github.com/launchdarkly/go-server-sdk-evaluation/v3/ldmodel" + + "github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest" + "github.com/launchdarkly/sdk-test-harness/v2/mockld" + "github.com/launchdarkly/sdk-test-harness/v2/servicedef" +) + +func doCommonListenerTests(t *ldtest.T) { + t.RequireCapability(servicedef.CapabilityFlagChangeListeners) + t.Run("flag change listener", doFlagChangeListenerTests) + t.Run("flag value change listener", doFlagValueChangeListenerTests) +} + +func doFlagChangeListenerTests(t *ldtest.T) { + t.Run("receives notification when flag changes", flagChangeListenerReceivesNotification) + t.Run("fires on config change even when value unchanged", flagChangeListenerFiresOnConfigChange) + t.Run("filters by flag key", flagChangeListenerFiltersByFlagKey) + t.Run("with empty flag key receives all flag changes", flagChangeListenerEmptyKeyReceivesAllFlags) +} + +func doFlagValueChangeListenerTests(t *ldtest.T) { + t.Run("receives notification when value changes", flagValueChangeListenerReceivesNotification) + t.Run("does not notify when value is unchanged", flagValueChangeListenerNoNotificationWhenUnchanged) + t.Run("multiple listeners both receive notification", multipleValueListenersBothNotified) + t.Run("is context specific", valueListenerIsContextSpecific) +} + +// makeListenerFlag builds a server-side feature flag for listener tests. The flag evaluates to +// value as its off-variation, so any context will receive that value. +func makeListenerFlag(key string, version int, value ldvalue.Value) ldmodel.FeatureFlag { + return ldbuilders.NewFlagBuilder(key).Version(version). + On(false).OffVariation(0).Variations(value, ldvalue.String("other")).Build() +} + +// createClientForListeners sets up a client with two flags (flag1 and flag2) pre-loaded via +// streaming, both initially evaluating to "value1". Use dataSystem.Synchronizers[0].streaming +// to push flag updates and trigger listener notifications. +func createClientForListeners(t *ldtest.T) (*SDKClient, *SDKDataSystem) { + flag1 := makeListenerFlag("flag1", 1, ldvalue.String("value1")) + flag2 := makeListenerFlag("flag2", 1, ldvalue.String("value1")) + data := mockld.NewServerSDKDataBuilder().Flag(flag1, flag2).Build() + + dataSystem := NewSDKDataSystem(t, data) + client := NewSDKClient(t, dataSystem) + + return client, dataSystem +} + +// pushFlagUpdate pushes a flag update through the streaming service and signals that the payload +// is complete. version must increase with each call; it is used as both the flag version and the +// payload-transferred sequence number. +func pushFlagUpdate(dataSystem *SDKDataSystem, key string, version int, value ldvalue.Value) { + flag := makeListenerFlag(key, version, value) + + streaming := dataSystem.Synchronizers[0].streaming + streaming.PushUpdate("flag", key, version, jsonhelpers.ToJSON(flag)) + streaming.PushPayloadTransferred("updated", version) +} + +// --- Flag change listener tests --- + +func flagChangeListenerReceivesNotification(t *ldtest.T) { + client, dataSystem := createClientForListeners(t) + + callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback.Close() + + client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + CallbackURI: callback.GetURL(), + }) + + pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("new-value")) + + callback.ExpectFlagChangeNotification(t, "flag1") +} + +func flagChangeListenerFiresOnConfigChange(t *ldtest.T) { + client, dataSystem := createClientForListeners(t) + + callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback.Close() + + client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + CallbackURI: callback.GetURL(), + }) + + // Push an update that changes the flag's version but not its evaluated value. + // The general flag change listener must fire regardless of value changes, because + // it tracks configuration changes (e.g. targeting rule edits), not just value changes. + pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("value1")) + + callback.ExpectFlagChangeNotification(t, "flag1") +} + +func flagChangeListenerEmptyKeyReceivesAllFlags(t *ldtest.T) { + client, dataSystem := createClientForListeners(t) + + callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback.Close() + + // An empty FlagKey means the listener should receive changes for any flag. + client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "", + CallbackURI: callback.GetURL(), + }) + + // Update flag1 — listener should fire. + pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("new-value")) + callback.ExpectFlagChangeNotification(t, "flag1") + + // Update flag2 — listener should fire again. + // Use version 3 so the payload-transferred sequence number also increments. + pushFlagUpdate(dataSystem, "flag2", 3, ldvalue.String("new-value")) + callback.ExpectFlagChangeNotification(t, "flag2") +} + +func flagChangeListenerFiltersByFlagKey(t *ldtest.T) { + client, dataSystem := createClientForListeners(t) + + callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback.Close() + + // Register listener only for flag1. + client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + CallbackURI: callback.GetURL(), + }) + + // Update flag2 — should NOT trigger the listener. + pushFlagUpdate(dataSystem, "flag2", 2, ldvalue.String("new-value")) + callback.ExpectNoNotification(t, "flag1") + + // Update flag1 — SHOULD trigger the listener. + pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("another-value")) + callback.ExpectFlagChangeNotification(t, "flag1") +} + +// --- Flag value change listener tests --- + +func flagValueChangeListenerReceivesNotification(t *ldtest.T) { + client, dataSystem := createClientForListeners(t) + + context := ldcontext.New("user-key") + oldValue := ldvalue.String("value1") + newValue := ldvalue.String("new-value") + defaultValue := ldvalue.String("default") + + callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback.Close() + + client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + Context: context, + DefaultValue: defaultValue, + CallbackURI: callback.GetURL(), + }) + + pushFlagUpdate(dataSystem, "flag1", 2, newValue) + + callback.ExpectValueChangeNotification(t, "flag1", oldValue, newValue) +} + +func flagValueChangeListenerNoNotificationWhenUnchanged(t *ldtest.T) { + client, dataSystem := createClientForListeners(t) + + context := ldcontext.New("user-key") + + callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback.Close() + + client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + Context: context, + DefaultValue: ldvalue.String("default"), + CallbackURI: callback.GetURL(), + }) + + // Update flag1 with a new version but the same evaluated value — should NOT trigger notification. + pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("value1")) + callback.ExpectNoNotification(t, "flag1") +} + +func multipleValueListenersBothNotified(t *ldtest.T) { + client, dataSystem := createClientForListeners(t) + + context := ldcontext.New("user-key") + oldValue := ldvalue.String("value1") + newValue := ldvalue.String("new-value") + defaultValue := ldvalue.String("default") + + callback1 := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback1.Close() + callback2 := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback2.Close() + + // Register two independent listeners for the same flag and context. + client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + Context: context, + DefaultValue: defaultValue, + CallbackURI: callback1.GetURL(), + }) + client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{ + ListenerID: "listener-2", + FlagKey: "flag1", + Context: context, + DefaultValue: defaultValue, + CallbackURI: callback2.GetURL(), + }) + + pushFlagUpdate(dataSystem, "flag1", 2, newValue) + + // Both listeners must receive the notification independently. + callback1.ExpectValueChangeNotification(t, "flag1", oldValue, newValue) + callback2.ExpectValueChangeNotification(t, "flag1", oldValue, newValue) +} + +func valueListenerIsContextSpecific(t *ldtest.T) { + context1 := ldcontext.New("user-1") + context2 := ldcontext.New("user-2") + defaultValue := ldvalue.String("default") + + // Initially both contexts see "value1" (flag is off, returns the same off-variation for all). + flag1 := makeListenerFlag("flag1", 1, ldvalue.String("value1")) + data := mockld.NewServerSDKDataBuilder().Flag(flag1).Build() + dataSystem := NewSDKDataSystem(t, data) + client := NewSDKClient(t, dataSystem) + + callback1 := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback1.Close() + callback2 := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback2.Close() + + client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + Context: context1, + DefaultValue: defaultValue, + CallbackURI: callback1.GetURL(), + }) + client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{ + ListenerID: "listener-2", + FlagKey: "flag1", + Context: context2, + DefaultValue: defaultValue, + CallbackURI: callback2.GetURL(), + }) + + // Push an updated flag that returns "updated-value" for user-1 via a targeting rule, + // and "value1" (unchanged) for everyone else via the fallthrough. + updatedFlag := ldbuilders.NewFlagBuilder("flag1").Version(2). + On(true). + FallthroughVariation(0). + Variations(ldvalue.String("value1"), ldvalue.String("updated-value")). + AddRule(ldbuilders.NewRuleBuilder().ID("target-rule").Variation(1).Clauses( + ldbuilders.Clause(ldattr.KeyAttr, ldmodel.OperatorIn, ldvalue.String("user-1")), + )). + Build() + + streaming := dataSystem.Synchronizers[0].streaming + streaming.PushUpdate("flag", "flag1", 2, jsonhelpers.ToJSON(updatedFlag)) + streaming.PushPayloadTransferred("updated", 2) + + // context1 (user-1): value changed from "value1" to "updated-value" → notification expected. + callback1.ExpectValueChangeNotification(t, "flag1", ldvalue.String("value1"), ldvalue.String("updated-value")) + + // context2 (user-2): value unchanged ("value1" → "value1") → no notification expected. + callback2.ExpectNoNotification(t, "flag1") +} diff --git a/sdktests/testsuite_entry_point.go b/sdktests/testsuite_entry_point.go index 0c2dd673..87a5a978 100644 --- a/sdktests/testsuite_entry_point.go +++ b/sdktests/testsuite_entry_point.go @@ -96,6 +96,7 @@ func doAllServerSideTests(t *ldtest.T) { t.Run("context type", doSDKContextTypeTests) t.Run("migrations", doServerSideMigrationTests) t.Run("hooks", doCommonHooksTests) + t.Run("flag change listeners", doCommonListenerTests) t.Run("wrapper", doServerSideWrapperTests) t.Run("persistent data store", doServerSidePersistentTests) } From 51edfe983e1ae4f84d0d8cea1604e97fdcaf6330 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 26 Feb 2026 16:46:53 -0800 Subject: [PATCH 6/7] Responding to code review comments --- docs/service_spec.md | 12 ++-- sdktests/common_tests_listeners.go | 99 +++++++++++------------------- sdktests/testapi_sdk_client.go | 6 +- servicedef/command_params.go | 1 - servicedef/service_params.go | 1 + 5 files changed, 46 insertions(+), 73 deletions(-) diff --git a/docs/service_spec.md b/docs/service_spec.md index ec871df7..812ad62b 100644 --- a/docs/service_spec.md +++ b/docs/service_spec.md @@ -201,15 +201,15 @@ A test hook must: #### Capability `"flag-change-listeners"` -This means that the SDK has support for flag change listeners and can notify the test harness when flags change. +This means that the SDK has support for general flag change listeners — listeners that are notified when any flag's configuration changes. When a flag changes, the SDK test service should POST notification data to the callback URI provided during listener registration. -The SDK must support registering listeners that monitor for flag changes. When a flag changes, the SDK test service should POST notification data to the callback URI provided during listener registration. +For details on the commands and callback payloads, see the `registerFlagChangeListener` and `unregisterListener` commands. -The test harness supports testing two types of listeners: -- **General flag change listeners**: Notified when any flag's configuration changes -- **Flag value change listeners**: Notified when a specific flag's evaluated value changes for a given context +#### Capability `"flag-value-change-listeners"` -For details on the commands and callback payloads, see the `registerFlagChangeListener`, `registerFlagValueChangeListener`, and `unregisterListener` commands. +This means that the SDK has a native API for flag *value* change listeners — listeners that are notified when a specific flag's evaluated value changes for a given context. Not all SDKs provide this API; for example, the Node.js server SDK only supports general flag change listeners. + +For details on the commands and callback payloads, see the `registerFlagValueChangeListener` and `unregisterListener` commands. #### Capability `"tls:verify-peer"` diff --git a/sdktests/common_tests_listeners.go b/sdktests/common_tests_listeners.go index 7014922c..f350ec9c 100644 --- a/sdktests/common_tests_listeners.go +++ b/sdktests/common_tests_listeners.go @@ -15,38 +15,38 @@ import ( ) func doCommonListenerTests(t *ldtest.T) { - t.RequireCapability(servicedef.CapabilityFlagChangeListeners) t.Run("flag change listener", doFlagChangeListenerTests) t.Run("flag value change listener", doFlagValueChangeListenerTests) } func doFlagChangeListenerTests(t *ldtest.T) { + t.RequireCapability(servicedef.CapabilityFlagChangeListeners) t.Run("receives notification when flag changes", flagChangeListenerReceivesNotification) t.Run("fires on config change even when value unchanged", flagChangeListenerFiresOnConfigChange) - t.Run("filters by flag key", flagChangeListenerFiltersByFlagKey) - t.Run("with empty flag key receives all flag changes", flagChangeListenerEmptyKeyReceivesAllFlags) + t.Run("receives notifications for different flags", flagChangeListenerReceivesDifferentFlags) } func doFlagValueChangeListenerTests(t *ldtest.T) { + t.RequireCapability(servicedef.CapabilityFlagValueChangeListeners) t.Run("receives notification when value changes", flagValueChangeListenerReceivesNotification) t.Run("does not notify when value is unchanged", flagValueChangeListenerNoNotificationWhenUnchanged) - t.Run("multiple listeners both receive notification", multipleValueListenersBothNotified) - t.Run("is context specific", valueListenerIsContextSpecific) + t.Run("multiple listeners both receive notification", flagValueChangeListenerMultipleBothNotified) + t.Run("is context specific", flagValueChangeListenerIsContextSpecific) } -// makeListenerFlag builds a server-side feature flag for listener tests. The flag evaluates to -// value as its off-variation, so any context will receive that value. -func makeListenerFlag(key string, version int, value ldvalue.Value) ldmodel.FeatureFlag { +// makeFlagForFlagChangeListenerTests builds a server-side feature flag for listener tests. The flag +// evaluates to value as its off-variation, so any context will receive that value. +func makeFlagForFlagChangeListenerTests(key string, version int, value ldvalue.Value) ldmodel.FeatureFlag { return ldbuilders.NewFlagBuilder(key).Version(version). On(false).OffVariation(0).Variations(value, ldvalue.String("other")).Build() } -// createClientForListeners sets up a client with two flags (flag1 and flag2) pre-loaded via -// streaming, both initially evaluating to "value1". Use dataSystem.Synchronizers[0].streaming +// createClientForFlagChangeListenerTests sets up a client with two flags (flag1 and flag2) pre-loaded +// via streaming, both initially evaluating to "value1". Use dataSystem.Synchronizers[0].streaming // to push flag updates and trigger listener notifications. -func createClientForListeners(t *ldtest.T) (*SDKClient, *SDKDataSystem) { - flag1 := makeListenerFlag("flag1", 1, ldvalue.String("value1")) - flag2 := makeListenerFlag("flag2", 1, ldvalue.String("value1")) +func createClientForFlagChangeListenerTests(t *ldtest.T) (*SDKClient, *SDKDataSystem) { + flag1 := makeFlagForFlagChangeListenerTests("flag1", 1, ldvalue.String("value1")) + flag2 := makeFlagForFlagChangeListenerTests("flag2", 1, ldvalue.String("value1")) data := mockld.NewServerSDKDataBuilder().Flag(flag1, flag2).Build() dataSystem := NewSDKDataSystem(t, data) @@ -55,11 +55,11 @@ func createClientForListeners(t *ldtest.T) (*SDKClient, *SDKDataSystem) { return client, dataSystem } -// pushFlagUpdate pushes a flag update through the streaming service and signals that the payload -// is complete. version must increase with each call; it is used as both the flag version and the -// payload-transferred sequence number. -func pushFlagUpdate(dataSystem *SDKDataSystem, key string, version int, value ldvalue.Value) { - flag := makeListenerFlag(key, version, value) +// pushFlagUpdateForFlagChangeListenerTests pushes a flag update through the streaming service and +// signals that the payload is complete. version must increase with each call; it is used as both the +// flag version and the payload-transferred sequence number. +func pushFlagUpdateForFlagChangeListenerTests(dataSystem *SDKDataSystem, key string, version int, value ldvalue.Value) { + flag := makeFlagForFlagChangeListenerTests(key, version, value) streaming := dataSystem.Synchronizers[0].streaming streaming.PushUpdate("flag", key, version, jsonhelpers.ToJSON(flag)) @@ -69,91 +69,64 @@ func pushFlagUpdate(dataSystem *SDKDataSystem, key string, version int, value ld // --- Flag change listener tests --- func flagChangeListenerReceivesNotification(t *ldtest.T) { - client, dataSystem := createClientForListeners(t) + client, dataSystem := createClientForFlagChangeListenerTests(t) callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) defer callback.Close() client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ ListenerID: "listener-1", - FlagKey: "flag1", CallbackURI: callback.GetURL(), }) - pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("new-value")) + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag1", 2, ldvalue.String("new-value")) callback.ExpectFlagChangeNotification(t, "flag1") } func flagChangeListenerFiresOnConfigChange(t *ldtest.T) { - client, dataSystem := createClientForListeners(t) + client, dataSystem := createClientForFlagChangeListenerTests(t) callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) defer callback.Close() client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ ListenerID: "listener-1", - FlagKey: "flag1", CallbackURI: callback.GetURL(), }) // Push an update that changes the flag's version but not its evaluated value. // The general flag change listener must fire regardless of value changes, because // it tracks configuration changes (e.g. targeting rule edits), not just value changes. - pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("value1")) + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag1", 2, ldvalue.String("value1")) callback.ExpectFlagChangeNotification(t, "flag1") } -func flagChangeListenerEmptyKeyReceivesAllFlags(t *ldtest.T) { - client, dataSystem := createClientForListeners(t) +func flagChangeListenerReceivesDifferentFlags(t *ldtest.T) { + client, dataSystem := createClientForFlagChangeListenerTests(t) callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) defer callback.Close() - // An empty FlagKey means the listener should receive changes for any flag. client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ ListenerID: "listener-1", - FlagKey: "", CallbackURI: callback.GetURL(), }) // Update flag1 — listener should fire. - pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("new-value")) + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag1", 2, ldvalue.String("new-value")) callback.ExpectFlagChangeNotification(t, "flag1") - // Update flag2 — listener should fire again. - // Use version 3 so the payload-transferred sequence number also increments. - pushFlagUpdate(dataSystem, "flag2", 3, ldvalue.String("new-value")) + // Update flag2 — listener should fire again for the different flag. + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag2", 3, ldvalue.String("new-value")) callback.ExpectFlagChangeNotification(t, "flag2") } -func flagChangeListenerFiltersByFlagKey(t *ldtest.T) { - client, dataSystem := createClientForListeners(t) - - callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) - defer callback.Close() - - // Register listener only for flag1. - client.RegisterFlagChangeListener(t, servicedef.RegisterFlagChangeListenerParams{ - ListenerID: "listener-1", - FlagKey: "flag1", - CallbackURI: callback.GetURL(), - }) - - // Update flag2 — should NOT trigger the listener. - pushFlagUpdate(dataSystem, "flag2", 2, ldvalue.String("new-value")) - callback.ExpectNoNotification(t, "flag1") - - // Update flag1 — SHOULD trigger the listener. - pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("another-value")) - callback.ExpectFlagChangeNotification(t, "flag1") -} - // --- Flag value change listener tests --- func flagValueChangeListenerReceivesNotification(t *ldtest.T) { - client, dataSystem := createClientForListeners(t) + client, dataSystem := createClientForFlagChangeListenerTests(t) context := ldcontext.New("user-key") oldValue := ldvalue.String("value1") @@ -171,13 +144,13 @@ func flagValueChangeListenerReceivesNotification(t *ldtest.T) { CallbackURI: callback.GetURL(), }) - pushFlagUpdate(dataSystem, "flag1", 2, newValue) + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag1", 2, newValue) callback.ExpectValueChangeNotification(t, "flag1", oldValue, newValue) } func flagValueChangeListenerNoNotificationWhenUnchanged(t *ldtest.T) { - client, dataSystem := createClientForListeners(t) + client, dataSystem := createClientForFlagChangeListenerTests(t) context := ldcontext.New("user-key") @@ -193,12 +166,12 @@ func flagValueChangeListenerNoNotificationWhenUnchanged(t *ldtest.T) { }) // Update flag1 with a new version but the same evaluated value — should NOT trigger notification. - pushFlagUpdate(dataSystem, "flag1", 2, ldvalue.String("value1")) + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag1", 2, ldvalue.String("value1")) callback.ExpectNoNotification(t, "flag1") } -func multipleValueListenersBothNotified(t *ldtest.T) { - client, dataSystem := createClientForListeners(t) +func flagValueChangeListenerMultipleBothNotified(t *ldtest.T) { + client, dataSystem := createClientForFlagChangeListenerTests(t) context := ldcontext.New("user-key") oldValue := ldvalue.String("value1") @@ -226,20 +199,20 @@ func multipleValueListenersBothNotified(t *ldtest.T) { CallbackURI: callback2.GetURL(), }) - pushFlagUpdate(dataSystem, "flag1", 2, newValue) + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag1", 2, newValue) // Both listeners must receive the notification independently. callback1.ExpectValueChangeNotification(t, "flag1", oldValue, newValue) callback2.ExpectValueChangeNotification(t, "flag1", oldValue, newValue) } -func valueListenerIsContextSpecific(t *ldtest.T) { +func flagValueChangeListenerIsContextSpecific(t *ldtest.T) { context1 := ldcontext.New("user-1") context2 := ldcontext.New("user-2") defaultValue := ldvalue.String("default") // Initially both contexts see "value1" (flag is off, returns the same off-variation for all). - flag1 := makeListenerFlag("flag1", 1, ldvalue.String("value1")) + flag1 := makeFlagForFlagChangeListenerTests("flag1", 1, ldvalue.String("value1")) data := mockld.NewServerSDKDataBuilder().Flag(flag1).Build() dataSystem := NewSDKDataSystem(t, data) client := NewSDKClient(t, dataSystem) diff --git a/sdktests/testapi_sdk_client.go b/sdktests/testapi_sdk_client.go index ed3f4d44..9eada425 100644 --- a/sdktests/testapi_sdk_client.go +++ b/sdktests/testapi_sdk_client.go @@ -415,9 +415,9 @@ func (c *SDKClient) ContextComparison(t *ldtest.T, params servicedef.ContextComp return resp } -// RegisterFlagChangeListener tells the SDK test service to register a general flag change listener -// for the given flag key. The listener will POST a ListenerNotification to callbackURI whenever -// the flag's configuration changes. Pass an empty flagKey to listen for changes to any flag. +// RegisterFlagChangeListener tells the SDK test service to register a general flag change listener. +// The listener will POST a ListenerNotification to callbackURI whenever any flag's configuration +// changes. // // Any error from the test service causes the test to terminate immediately. func (c *SDKClient) RegisterFlagChangeListener( diff --git a/servicedef/command_params.go b/servicedef/command_params.go index df201f4e..1dcdf2ec 100644 --- a/servicedef/command_params.go +++ b/servicedef/command_params.go @@ -219,7 +219,6 @@ type HookExecutionPayload struct { // The listener will be notified whenever any flag's configuration changes. type RegisterFlagChangeListenerParams struct { ListenerID string `json:"listenerId"` - FlagKey string `json:"flagKey"` CallbackURI string `json:"callbackUri"` } diff --git a/servicedef/service_params.go b/servicedef/service_params.go index 7697a973..94831d0e 100644 --- a/servicedef/service_params.go +++ b/servicedef/service_params.go @@ -41,6 +41,7 @@ const ( CapabilityPollingGzip = "polling-gzip" CapabilityEvaluationHooks = "evaluation-hooks" CapabilityFlagChangeListeners = "flag-change-listeners" + CapabilityFlagValueChangeListeners = "flag-value-change-listeners" CapabilityClientPrereqEvents = "client-prereq-events" CapabilityPersistentDataStoreRedis = "persistent-data-store-redis" CapabilityPersistentDataStoreConsul = "persistent-data-store-consul" From 88b7c7d511844407d1c0f54fb88fa1a349fa12b2 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 27 Feb 2026 09:31:19 -0800 Subject: [PATCH 7/7] Added a test case for a flag with a json value --- sdktests/common_tests_listeners.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/sdktests/common_tests_listeners.go b/sdktests/common_tests_listeners.go index f350ec9c..9157421e 100644 --- a/sdktests/common_tests_listeners.go +++ b/sdktests/common_tests_listeners.go @@ -32,6 +32,7 @@ func doFlagValueChangeListenerTests(t *ldtest.T) { t.Run("does not notify when value is unchanged", flagValueChangeListenerNoNotificationWhenUnchanged) t.Run("multiple listeners both receive notification", flagValueChangeListenerMultipleBothNotified) t.Run("is context specific", flagValueChangeListenerIsContextSpecific) + t.Run("reports correct old and new JSON values", flagValueChangeListenerJSONValues) } // makeFlagForFlagChangeListenerTests builds a server-side feature flag for listener tests. The flag @@ -258,3 +259,29 @@ func flagValueChangeListenerIsContextSpecific(t *ldtest.T) { // context2 (user-2): value unchanged ("value1" → "value1") → no notification expected. callback2.ExpectNoNotification(t, "flag1") } + +func flagValueChangeListenerJSONValues(t *ldtest.T) { + oldValue := ldvalue.ObjectBuild().Set("color", ldvalue.String("red")).Set("count", ldvalue.Int(1)).Build() + newValue := ldvalue.ObjectBuild().Set("color", ldvalue.String("blue")).Set("count", ldvalue.Int(2)).Build() + defaultValue := ldvalue.ObjectBuild().Build() + + flag := makeFlagForFlagChangeListenerTests("flag1", 1, oldValue) + data := mockld.NewServerSDKDataBuilder().Flag(flag).Build() + dataSystem := NewSDKDataSystem(t, data) + client := NewSDKClient(t, dataSystem) + + callback := NewListenerCallback(requireContext(t).harness, t.DebugLogger()) + defer callback.Close() + + client.RegisterFlagValueChangeListener(t, servicedef.RegisterFlagValueChangeListenerParams{ + ListenerID: "listener-1", + FlagKey: "flag1", + Context: ldcontext.New("user-key"), + DefaultValue: defaultValue, + CallbackURI: callback.GetURL(), + }) + + pushFlagUpdateForFlagChangeListenerTests(dataSystem, "flag1", 2, newValue) + + callback.ExpectValueChangeNotification(t, "flag1", oldValue, newValue) +}