Skip to content

Commit 0eb65f7

Browse files
committed
feat(multi): implement Tracker interface and fix event handling
Implements the Tracker interface for the multi-provider to forward tracking calls to all ready providers that support tracking. Signed-off-by: Roman Dmytrenko <[email protected]>
1 parent 7fd7ab6 commit 0eb65f7

File tree

2 files changed

+181
-15
lines changed

2 files changed

+181
-15
lines changed

openfeature/multi/multiprovider.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"log/slog"
10+
"maps"
1011
"slices"
1112
"strings"
1213
"sync"
@@ -89,6 +90,7 @@ var (
8990
_ of.FeatureProvider = (*Provider)(nil)
9091
_ of.EventHandler = (*Provider)(nil)
9192
_ of.StateHandler = (*Provider)(nil)
93+
_ of.Tracker = (*Provider)(nil)
9294
)
9395

9496
// init Initialize "constants" used for event handling priorities and filtering.
@@ -380,10 +382,8 @@ func (p *Provider) Init(evalCtx of.EvaluationContext) error {
380382
if eventer, ok := provider.(of.EventHandler); ok {
381383
l.LogAttrs(context.Background(), slog.LevelDebug, "detected EventHandler implementation")
382384
handlers <- namedEventHandler{eventer, name}
383-
} else {
384-
// Do not yet update providers that need event handling
385-
p.updateProviderState(name, of.ReadyState)
386385
}
386+
p.updateProviderState(name, of.ReadyState)
387387
return nil
388388
})
389389
}
@@ -566,3 +566,32 @@ func (p *Provider) setStatus(state of.State) {
566566
func (p *Provider) EventChannel() <-chan of.Event {
567567
return p.outboundEvents
568568
}
569+
570+
// Track implements the [of.Tracker] interface by forwarding tracking calls to all internal providers that
571+
// are in ready state and implement the [of.Tracker] interface.
572+
func (p *Provider) Track(ctx context.Context, trackingEventName string, evaluationContext of.EvaluationContext, details of.TrackingEventDetails) {
573+
if !p.initialized {
574+
// Don't do anything if we were never initialized
575+
p.logger.LogAttrs(ctx, slog.LevelDebug, "provider not initialized, skipping tracking", slog.String("tracking-event", trackingEventName))
576+
return
577+
}
578+
p.providerStatusLock.Lock()
579+
data := maps.Clone(p.providerStatus)
580+
p.providerStatusLock.Unlock()
581+
for providerID, state := range data {
582+
if state != of.ReadyState {
583+
continue
584+
}
585+
provider, ok := p.providers[providerID]
586+
if !ok {
587+
p.logger.LogAttrs(ctx, slog.LevelWarn, "provider not found during tracking",
588+
slog.String(MetadataProviderName, providerID),
589+
slog.String("tracking-event", trackingEventName),
590+
)
591+
continue
592+
}
593+
if tracker, ok := provider.(of.Tracker); ok {
594+
tracker.Track(ctx, trackingEventName, evaluationContext, details)
595+
}
596+
}
597+
}

openfeature/multi/multiprovider_test.go

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"regexp"
77
"testing"
8-
"time"
98

109
of "github.com/open-feature/go-sdk/openfeature"
1110
imp "github.com/open-feature/go-sdk/openfeature/memprovider"
@@ -359,29 +358,29 @@ func TestMultiProvider_StateUpdateWithSameTypeProviders(t *testing.T) {
359358
"secondary": secondaryProvider,
360359
}
361360

362-
multiProvider, err := NewProvider(providers, StrategyFirstMatch)
361+
mp, err := NewProvider(providers, StrategyFirstMatch)
363362
if err != nil {
364363
t.Fatalf("failed to create multi-provider: %v", err)
365364
}
366-
t.Cleanup(multiProvider.Shutdown)
365+
t.Cleanup(mp.Shutdown)
367366

368367
// Initialize the provider
369368
ctx := of.NewEvaluationContext("test", nil)
370-
if err := multiProvider.Init(ctx); err != nil {
369+
if err := mp.Init(ctx); err != nil {
371370
t.Fatalf("failed to initialize multi-provider: %v", err)
372371
}
373372

374373
primaryProvider.EmitEvent(of.ProviderError, "fail to fetch data")
375374
secondaryProvider.EmitEvent(of.ProviderReady, "rev 1")
376-
377-
time.Sleep(200 * time.Millisecond)
375+
// wait for processing
376+
<-mp.outboundEvents
378377

379378
// Check the state after the error event
380-
multiProvider.providerStatusLock.Lock()
381-
primaryState := multiProvider.providerStatus["primary"]
382-
secondaryState := multiProvider.providerStatus["secondary"]
383-
numProviders := len(multiProvider.providerStatus)
384-
multiProvider.providerStatusLock.Unlock()
379+
mp.providerStatusLock.Lock()
380+
primaryState := mp.providerStatus["primary"]
381+
secondaryState := mp.providerStatus["secondary"]
382+
numProviders := len(mp.providerStatus)
383+
mp.providerStatusLock.Unlock()
385384

386385
if primaryState != of.ErrorState {
387386
t.Errorf("Expected primary-mock state to be ERROR after emitting error event, got %s", primaryState)
@@ -396,19 +395,150 @@ func TestMultiProvider_StateUpdateWithSameTypeProviders(t *testing.T) {
396395
}
397396
}
398397

398+
func TestMultiProvider_Track(t *testing.T) {
399+
t.Run("forwards tracking to all ready providers that implement Tracker", func(t *testing.T) {
400+
ctrl := gomock.NewController(t)
401+
t.Cleanup(ctrl.Finish)
402+
403+
provider1 := newMockProviderWithEvents(ctrl, "provider1")
404+
provider2 := newMockProviderWithEvents(ctrl, "provider2")
405+
provider3 := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{}) // Does not implement Tracker
406+
407+
providers := make(ProviderMap)
408+
providers["provider1"] = provider1
409+
providers["provider2"] = provider2
410+
providers["provider3"] = provider3
411+
412+
mp, err := NewProvider(providers, StrategyFirstSuccess)
413+
require.NoError(t, err)
414+
t.Cleanup(mp.Shutdown)
415+
416+
evalCtx := of.NewEvaluationContext("user-123", map[string]any{"plan": "premium"})
417+
err = mp.Init(evalCtx)
418+
require.NoError(t, err)
419+
420+
trackingEventName := "button-clicked"
421+
details := of.NewTrackingEventDetails(42.0).Add("currency", "USD")
422+
423+
ctx := t.Context()
424+
// Expect Track to be called on providers that implement Tracker
425+
provider1.MockTracker.EXPECT().Track(ctx, trackingEventName, evalCtx, details).Times(1)
426+
provider2.MockTracker.EXPECT().Track(ctx, trackingEventName, evalCtx, details).Times(1)
427+
428+
mp.Track(ctx, trackingEventName, evalCtx, details)
429+
})
430+
431+
t.Run("does not track when provider is not initialized", func(t *testing.T) {
432+
ctrl := gomock.NewController(t)
433+
t.Cleanup(ctrl.Finish)
434+
435+
provider1 := newMockProviderWithEvents(ctrl, "provider1")
436+
// manual shutdown on cleanup because multi-provider won't be initialized
437+
t.Cleanup(provider1.Shutdown)
438+
439+
providers := make(ProviderMap)
440+
providers["provider1"] = provider1
441+
442+
mp, err := NewProvider(providers, StrategyFirstSuccess)
443+
require.NoError(t, err)
444+
t.Cleanup(mp.Shutdown)
445+
446+
// Don't initialize the multi-provider
447+
ctx := context.Background()
448+
trackingEventName := "button-clicked"
449+
evalCtx := of.NewEvaluationContext("user-123", map[string]any{})
450+
details := of.TrackingEventDetails{}
451+
452+
// Should not call Track on provider
453+
provider1.MockTracker.EXPECT().Track(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
454+
455+
mp.Track(ctx, trackingEventName, evalCtx, details)
456+
})
457+
458+
t.Run("only tracks on providers in ready state", func(t *testing.T) {
459+
ctrl := gomock.NewController(t)
460+
t.Cleanup(ctrl.Finish)
461+
462+
readyProvider := newMockProviderWithEvents(ctrl, "ready-provider")
463+
errorProvider := newMockProviderWithEvents(ctrl, "error-provider")
464+
465+
providers := make(ProviderMap)
466+
providers["ready-provider"] = readyProvider
467+
providers["error-provider"] = errorProvider
468+
469+
mp, err := NewProvider(providers, StrategyFirstSuccess)
470+
require.NoError(t, err)
471+
t.Cleanup(mp.Shutdown)
472+
473+
evalCtx := of.NewEvaluationContext("user-456", map[string]any{})
474+
err = mp.Init(evalCtx)
475+
require.NoError(t, err)
476+
477+
// Simulate error state for one provider
478+
errorProvider.eventChannel <- of.Event{
479+
ProviderName: "error-provider",
480+
EventType: of.ProviderError,
481+
ProviderEventDetails: of.ProviderEventDetails{
482+
Message: "error",
483+
EventMetadata: make(map[string]any),
484+
},
485+
}
486+
// wait for event processing
487+
<-mp.outboundEvents
488+
489+
trackingEventName := "page-view"
490+
details := of.TrackingEventDetails{}
491+
492+
ctx := t.Context()
493+
readyProvider.MockTracker.EXPECT().Track(ctx, trackingEventName, evalCtx, details).Times(1)
494+
errorProvider.MockTracker.EXPECT().Track(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0)
495+
496+
mp.Track(ctx, trackingEventName, evalCtx, details)
497+
})
498+
499+
t.Run("handles providers that don't implement Tracker", func(t *testing.T) {
500+
ctrl := gomock.NewController(t)
501+
t.Cleanup(ctrl.Finish)
502+
503+
trackerProvider := newMockProviderWithEvents(ctrl, "tracker-provider")
504+
nonTrackerProvider := imp.NewInMemoryProvider(map[string]imp.InMemoryFlag{})
505+
506+
providers := make(ProviderMap)
507+
providers["tracker-provider"] = trackerProvider
508+
providers["non-tracker"] = nonTrackerProvider
509+
510+
mp, err := NewProvider(providers, StrategyFirstSuccess)
511+
require.NoError(t, err)
512+
t.Cleanup(mp.Shutdown)
513+
514+
evalCtx := of.NewEvaluationContext("user-789", map[string]any{})
515+
err = mp.Init(evalCtx)
516+
require.NoError(t, err)
517+
518+
trackingEventName := "conversion"
519+
details := of.NewTrackingEventDetails(99.99)
520+
521+
ctx := t.Context()
522+
trackerProvider.MockTracker.EXPECT().Track(ctx, trackingEventName, evalCtx, details).Times(1)
523+
mp.Track(ctx, trackingEventName, evalCtx, details)
524+
})
525+
}
526+
399527
var _ of.StateHandler = (*mockProviderWithEvents)(nil)
400528

401-
// mockProviderWithEvents wraps a mock provider to add EventHandler capability
529+
// mockProviderWithEvents wraps a mock provider to add EventHandler and optional Tracker capability
402530
type mockProviderWithEvents struct {
403531
*of.MockFeatureProvider
404532
*of.MockStateHandler
533+
*of.MockTracker
405534
eventChannel chan of.Event
406535
metadata of.Metadata
407536
}
408537

409538
func newMockProviderWithEvents(ctrl *gomock.Controller, name string) *mockProviderWithEvents {
410539
mockProvider := of.NewMockFeatureProvider(ctrl)
411540
mockStateHandler := of.NewMockStateHandler(ctrl)
541+
mockTracker := of.NewMockTracker(ctrl)
412542
eventChan := make(chan of.Event, 10)
413543

414544
metadata := of.Metadata{Name: name}
@@ -434,6 +564,7 @@ func newMockProviderWithEvents(ctrl *gomock.Controller, name string) *mockProvid
434564
MockStateHandler: mockStateHandler,
435565
eventChannel: eventChan,
436566
metadata: metadata,
567+
MockTracker: mockTracker,
437568
}
438569
}
439570

@@ -460,3 +591,9 @@ func (m *mockProviderWithEvents) EmitEvent(eventType of.EventType, message strin
460591
},
461592
}
462593
}
594+
595+
func (m *mockProviderWithEvents) Track(ctx context.Context, trackingEventName string, evaluationContext of.EvaluationContext, details of.TrackingEventDetails) {
596+
if m.MockTracker != nil {
597+
m.MockTracker.Track(ctx, trackingEventName, evaluationContext, details)
598+
}
599+
}

0 commit comments

Comments
 (0)