From 8bd30781176e917f342a210484b39d3a2c10f9eb Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 18 Feb 2026 14:21:07 -0800 Subject: [PATCH 1/3] feat(testservice): add flag change listener servicedef types Add command constants, parameter structs, and the ListenerNotification payload type to support the three new flag change listener commands: registerFlagChangeListener, registerFlagValueChangeListener, and unregisterListener. Also adds the CapabilityFlagChangeListeners constant to service_params.go. No runtime behavior is introduced in this commit; these are pure type definitions that subsequent commits will build on. --- testservice/servicedef/command_params.go | 87 +++++++++++++++++------- testservice/servicedef/service_params.go | 1 + 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/testservice/servicedef/command_params.go b/testservice/servicedef/command_params.go index 80b363d9..d6d1688b 100644 --- a/testservice/servicedef/command_params.go +++ b/testservice/servicedef/command_params.go @@ -8,18 +8,21 @@ import ( ) const ( - CommandEvaluateFlag = "evaluate" - CommandEvaluateAllFlags = "evaluateAll" - CommandIdentifyEvent = "identifyEvent" - CommandCustomEvent = "customEvent" - CommandAliasEvent = "aliasEvent" - CommandFlushEvents = "flushEvents" - CommandGetBigSegmentStoreStatus = "getBigSegmentStoreStatus" - CommandContextBuild = "contextBuild" - CommandContextConvert = "contextConvert" - CommandSecureModeHash = "secureModeHash" - CommandMigrationVariation = "migrationVariation" - CommandMigrationOperation = "migrationOperation" + CommandEvaluateFlag = "evaluate" + CommandEvaluateAllFlags = "evaluateAll" + CommandIdentifyEvent = "identifyEvent" + CommandCustomEvent = "customEvent" + CommandAliasEvent = "aliasEvent" + CommandFlushEvents = "flushEvents" + CommandGetBigSegmentStoreStatus = "getBigSegmentStoreStatus" + CommandContextBuild = "contextBuild" + CommandContextConvert = "contextConvert" + CommandSecureModeHash = "secureModeHash" + CommandMigrationVariation = "migrationVariation" + CommandMigrationOperation = "migrationOperation" + CommandRegisterFlagChangeListener = "registerFlagChangeListener" + CommandRegisterFlagValueChangeListener = "registerFlagValueChangeListener" + CommandUnregisterListener = "unregisterListener" ) type ValueType string @@ -33,16 +36,19 @@ const ( ) type CommandParams struct { - Command string `json:"command"` - Evaluate *EvaluateFlagParams `json:"evaluate,omitempty"` - EvaluateAll *EvaluateAllFlagsParams `json:"evaluateAll,omitempty"` - CustomEvent *CustomEventParams `json:"customEvent,omitempty"` - IdentifyEvent *IdentifyEventParams `json:"identifyEvent,omitempty"` - ContextBuild *ContextBuildParams `json:"contextBuild,omitempty"` - ContextConvert *ContextConvertParams `json:"contextConvert,omitempty"` - SecureModeHash *SecureModeHashParams `json:"secureModeHash,omitempty"` - MigrationVariation *MigrationVariationParams `json:"migrationVariation,omitempty"` - MigrationOperation *MigrationOperationParams `json:"migrationOperation,omitempty"` + Command string `json:"command"` + Evaluate *EvaluateFlagParams `json:"evaluate,omitempty"` + EvaluateAll *EvaluateAllFlagsParams `json:"evaluateAll,omitempty"` + CustomEvent *CustomEventParams `json:"customEvent,omitempty"` + IdentifyEvent *IdentifyEventParams `json:"identifyEvent,omitempty"` + ContextBuild *ContextBuildParams `json:"contextBuild,omitempty"` + ContextConvert *ContextConvertParams `json:"contextConvert,omitempty"` + SecureModeHash *SecureModeHashParams `json:"secureModeHash,omitempty"` + MigrationVariation *MigrationVariationParams `json:"migrationVariation,omitempty"` + MigrationOperation *MigrationOperationParams `json:"migrationOperation,omitempty"` + RegisterFlagChangeListener *RegisterFlagChangeListenerParams `json:"registerFlagChangeListener,omitempty"` //nolint:lll + RegisterFlagValueChangeListener *RegisterFlagValueChangeListenerParams `json:"registerFlagValueChangeListener,omitempty"` //nolint:lll + UnregisterListener *UnregisterListenerParams `json:"unregisterListener,omitempty"` } type EvaluateFlagParams struct { @@ -180,5 +186,40 @@ type HookExecutionEvaluationPayload struct { type HookExecutionTrackPayload struct { TrackSeriesContext TrackSeriesContext `json:"trackSeriesContext,omitempty"` - Stage HookStage `json:"stage,omitempty"` + Stage HookStage `json:"stage,omitempty"` +} + +// RegisterFlagChangeListenerParams defines parameters for registering a general flag change listener. +// FlagKey may be empty to listen for changes to any flag, or non-empty to filter to a specific flag. +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 fires when the evaluated value of FlagKey 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. +// Works for both flag change and flag value change listeners. +type UnregisterListenerParams struct { + ListenerID string `json:"listenerId"` +} + +// ListenerNotification is the JSON payload POSTed by the test service to a callback URI when a +// listener fires. OldValue and NewValue are only present for value-change notifications +// (registerFlagValueChangeListener); they are nil for general flag-change notifications +// (registerFlagChangeListener). +type ListenerNotification struct { + ListenerID string `json:"listenerId"` + FlagKey string `json:"flagKey"` + OldValue *ldvalue.Value `json:"oldValue,omitempty"` + NewValue *ldvalue.Value `json:"newValue,omitempty"` } diff --git a/testservice/servicedef/service_params.go b/testservice/servicedef/service_params.go index 3e93334a..e0d08e2f 100644 --- a/testservice/servicedef/service_params.go +++ b/testservice/servicedef/service_params.go @@ -29,6 +29,7 @@ const ( CapabilityPersistentDataStoreRedis = "persistent-data-store-redis" CapabilityPersistentDataStoreConsul = "persistent-data-store-consul" CapabilityPersistentDataStoreDynamoDB = "persistent-data-store-dynamodb" + CapabilityFlagChangeListeners = "flag-change-listeners" ) type StatusRep struct { From 4d26a3224748207e07ff2553681e2a1236d383d5 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 18 Feb 2026 14:52:04 -0800 Subject: [PATCH 2/3] feat(testservice): implement flag change listener command handlers Add flag_change_listener.go with a listenerRegistry that manages goroutine-based subscriptions to the SDK's FlagTracker channels. Each registered listener spawns a goroutine that consumes SDK events and POSTs a ListenerNotification JSON payload to the provided callback URI. General flag change listeners support optional key filtering; value change listeners include old and new values in the payload. Wire the three new commands into SDKClientEntity.DoCommand: registerFlagChangeListener, registerFlagValueChangeListener, and unregisterListener. Also calls closeAll() in Close() to cancel all listener goroutines before the SDK client shuts down. The flag-change-listeners capability is not yet advertised, so no test harness tests are run yet. --- testservice/flag_change_listener.go | 132 ++++++++++++++++++++++++++++ testservice/sdk_client_entity.go | 21 ++++- 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 testservice/flag_change_listener.go diff --git a/testservice/flag_change_listener.go b/testservice/flag_change_listener.go new file mode 100644 index 00000000..772c83b7 --- /dev/null +++ b/testservice/flag_change_listener.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "sync" + + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" + "github.com/launchdarkly/go-sdk-common/v3/ldvalue" + "github.com/launchdarkly/go-server-sdk/v7/interfaces" + "github.com/launchdarkly/go-server-sdk/v7/testservice/servicedef" +) + +// listenerEntry holds the cancellation handle for one registered listener goroutine. +type listenerEntry struct { + cancel context.CancelFunc +} + +// listenerRegistry manages all active flag change listener registrations for a single +// SDK client entity. It is safe to use from multiple goroutines. +type listenerRegistry struct { + mu sync.Mutex + entries map[string]*listenerEntry // keyed by listenerId + tracker interfaces.FlagTracker +} + +func newListenerRegistry(tracker interfaces.FlagTracker) *listenerRegistry { + return &listenerRegistry{ + entries: make(map[string]*listenerEntry), + tracker: tracker, + } +} + +// registerFlagChangeListener subscribes to general flag configuration changes. If flagKey +// is non-empty, only events for that specific flag are forwarded to the callback URI. +func (r *listenerRegistry) registerFlagChangeListener(listenerID, flagKey, callbackURI string) { + ch := r.tracker.AddFlagChangeListener() + ctx, cancel := context.WithCancel(context.Background()) + + r.mu.Lock() + r.entries[listenerID] = &listenerEntry{cancel: cancel} + r.mu.Unlock() + + svc := callbackService{baseURL: callbackURI} + go func() { + defer r.tracker.RemoveFlagChangeListener(ch) + for { + select { + case <-ctx.Done(): + return + case event, ok := <-ch: + if !ok { + return + } + if flagKey != "" && event.Key != flagKey { + continue + } + _ = svc.post("", servicedef.ListenerNotification{ + ListenerID: listenerID, + FlagKey: event.Key, + }, nil) + } + } + }() +} + +// registerFlagValueChangeListener subscribes to value changes for a specific flag and +// evaluation context. The callback is invoked only when the evaluated value actually +// changes; configuration changes that leave the value unchanged are suppressed by the SDK. +func (r *listenerRegistry) registerFlagValueChangeListener( + listenerID, flagKey string, + evalCtx ldcontext.Context, + defaultValue ldvalue.Value, + callbackURI string, +) { + ch := r.tracker.AddFlagValueChangeListener(flagKey, evalCtx, defaultValue) + ctx, cancel := context.WithCancel(context.Background()) + + r.mu.Lock() + r.entries[listenerID] = &listenerEntry{cancel: cancel} + r.mu.Unlock() + + svc := callbackService{baseURL: callbackURI} + go func() { + defer r.tracker.RemoveFlagValueChangeListener(ch) + for { + select { + case <-ctx.Done(): + return + case event, ok := <-ch: + if !ok { + return + } + oldVal := event.OldValue + newVal := event.NewValue + _ = svc.post("", servicedef.ListenerNotification{ + ListenerID: listenerID, + FlagKey: event.Key, + OldValue: &oldVal, + NewValue: &newVal, + }, nil) + } + } + }() +} + +// unregister stops the listener goroutine for the given ID and removes it from the +// registry. Returns false if no listener with that ID was found. +func (r *listenerRegistry) unregister(listenerID string) bool { + r.mu.Lock() + entry, ok := r.entries[listenerID] + if ok { + delete(r.entries, listenerID) + } + r.mu.Unlock() + + if ok { + entry.cancel() + } + return ok +} + +// closeAll stops all active listener goroutines. Called when the SDK client entity closes. +func (r *listenerRegistry) closeAll() { + r.mu.Lock() + entries := r.entries + r.entries = make(map[string]*listenerEntry) + r.mu.Unlock() + + for _, entry := range entries { + entry.cancel() + } +} diff --git a/testservice/sdk_client_entity.go b/testservice/sdk_client_entity.go index 95416968..6cb18dfe 100644 --- a/testservice/sdk_client_entity.go +++ b/testservice/sdk_client_entity.go @@ -39,8 +39,9 @@ import ( const defaultStartWaitTime = 5 * time.Second type SDKClientEntity struct { - sdk *ld.LDClient - logger *log.Logger + sdk *ld.LDClient + logger *log.Logger + listeners *listenerRegistry } func NewSDKClientEntity(params servicedef.CreateInstanceParams) (*SDKClientEntity, error) { @@ -71,11 +72,13 @@ func NewSDKClientEntity(params servicedef.CreateInstanceParams) (*SDKClientEntit return nil, err } c.sdk = sdk + c.listeners = newListenerRegistry(sdk.GetFlagTracker()) return c, nil } func (c *SDKClientEntity) Close() { + c.listeners.closeAll() _ = c.sdk.Close() c.logger.Println("Test ended") c.logger.SetOutput(io.Discard) @@ -130,6 +133,20 @@ func (c *SDKClientEntity) DoCommand(params servicedef.CommandParams) (interface{ return servicedef.MigrationVariationResponse{Result: string(stage)}, nil case servicedef.CommandMigrationOperation: return c.migrationOperation(*params.MigrationOperation) + case servicedef.CommandRegisterFlagChangeListener: + p := params.RegisterFlagChangeListener + c.listeners.registerFlagChangeListener(p.ListenerID, p.FlagKey, p.CallbackURI) + return nil, nil + case servicedef.CommandRegisterFlagValueChangeListener: + p := params.RegisterFlagValueChangeListener + c.listeners.registerFlagValueChangeListener(p.ListenerID, p.FlagKey, p.Context, p.DefaultValue, p.CallbackURI) + return nil, nil + case servicedef.CommandUnregisterListener: + p := params.UnregisterListener + if !c.listeners.unregister(p.ListenerID) { + return nil, BadRequestError{Message: fmt.Sprintf("no listener with id %q", p.ListenerID)} + } + return nil, nil default: return nil, BadRequestError{Message: fmt.Sprintf("unknown command %q", params.Command)} } From 6f3b218d2fd15e796f91b577756bc6ed59074a85 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 18 Feb 2026 15:57:30 -0800 Subject: [PATCH 3/3] feat(testservice): advertise flag-change-listeners capability Add CapabilityFlagChangeListeners to the test service capabilities list. All 8 flag change listener tests pass against the sdk-test-harness. --- testservice/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/testservice/service.go b/testservice/service.go index 8d536fad..0741a38b 100644 --- a/testservice/service.go +++ b/testservice/service.go @@ -48,6 +48,7 @@ var capabilities = []string{ servicedef.CapabilityPersistentDataStoreRedis, servicedef.CapabilityPersistentDataStoreConsul, servicedef.CapabilityPersistentDataStoreDynamoDB, + servicedef.CapabilityFlagChangeListeners, } // gets the specified environment variable, or the default if not set