Skip to content

Commit 91ba360

Browse files
feat(multi-provider): Add Track Method Support to Multi-Provider (#1323)
Signed-off-by: Jonathan Norris <[email protected]>
1 parent 0ff5c88 commit 91ba360

File tree

8 files changed

+609
-43
lines changed

8 files changed

+609
-43
lines changed

libs/providers/multi-provider-web/README.md

Lines changed: 95 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,32 @@ the final result. Different evaluation strategies can be defined to control whic
66

77
The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers into a single
88
feature flagging interface. For example:
9+
910
- *Migration*: When migrating between two providers, you can run both in parallel under a unified flagging interface. As flags are added to the
1011
new provider, the Multi-Provider will automatically find and return them, falling back to the old provider if the new provider does not have
1112
- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables,
1213
local files, database values and SaaS hosted feature management systems.
1314

1415
## Installation
1516

16-
```
17+
```bash
1718
$ npm install @openfeature/multi-provider-web
1819
```
1920

2021
> [!TIP]
2122
> This provider is designed to be used with the [Web SDK](https://openfeature.dev/docs/reference/technologies/client/web/).
2223
2324
## Usage
25+
2426
The Multi-Provider is initialized with an array of providers it should evaluate:
2527

2628
```typescript
2729
import { WebMultiProvider } from '@openfeature/multi-provider-web'
2830
import { OpenFeature } from '@openfeature/web-sdk'
2931

3032
const multiProvider = new WebMultiProvider([
31-
{
32-
provider: new ProviderA()
33-
},
34-
{
35-
provider: new ProviderB()
36-
}
33+
{ provider: new ProviderA() },
34+
{ provider: new ProviderB() }
3735
])
3836

3937
await OpenFeature.setProviderAndWait(multiProvider)
@@ -56,18 +54,16 @@ import { WebMultiProvider, FirstSuccessfulStrategy } from '@openfeature/multi-pr
5654

5755
const multiProvider = new WebMultiProvider(
5856
[
59-
{
60-
provider: new ProviderA()
61-
},
62-
{
63-
provider: new ProviderB()
64-
}
57+
{ provider: new ProviderA() },
58+
{ provider: new ProviderB() }
6559
],
6660
new FirstSuccessfulStrategy()
6761
)
6862
```
63+
6964
The Multi-Provider comes with three strategies out of the box:
70-
`FirstMatchStrategy` (default): Evaluates all providers in order and returns the first successful result. Providers that indicate FLAG_NOT_FOUND error will be skipped and the next provider will be evaluated. Any other error will cause the operation to fail and the set of errors to be thrown.
65+
66+
- `FirstMatchStrategy` (default): Evaluates all providers in order and returns the first successful result. Providers that indicate FLAG_NOT_FOUND error will be skipped and the next provider will be evaluated. Any other error will cause the operation to fail and the set of errors to be thrown.
7167
- `FirstSuccessfulStrategy`: Evaluates all providers in order and returns the first successful result. Any error will cause that provider to be skipped.
7268
If no successful result is returned, the set of errors will be thrown.
7369
- `ComparisonStrategy`: Evaluates all providers in parallel. If every provider returns a successful result with the same value, then that result is returned.
@@ -83,22 +79,21 @@ import { WebMultiProvider, ComparisonStrategy } from '@openfeature/multi-provide
8379
const providerA = new ProviderA()
8480
const multiProvider = new WebMultiProvider(
8581
[
86-
{
87-
provider: providerA
88-
},
89-
{
90-
provider: new ProviderB()
91-
}
82+
{ provider: providerA },
83+
{ provider: new ProviderB() }
9284
],
9385
new ComparisonStrategy(providerA, (details) => {
9486
console.log("Mismatch detected", details)
9587
})
9688
)
9789
```
90+
9891
The first argument is the "fallback provider" whose value to use in the event that providers do not agree. It should be the same object reference as one of the providers in the list. The second argument is a callback function that will be executed when a mismatch is detected. The callback will be passed an object containing the details of each provider's resolution, including the flag key, the value returned, and any errors that were thrown.
9992

10093
## Custom Strategies
94+
10195
It is also possible to implement your own strategy if the above options do not fit your use case. To do so, create a class which implements the "BaseEvaluationStrategy":
96+
10297
```typescript
10398
export abstract class BaseEvaluationStrategy {
10499
public runMode: 'parallel' | 'sequential' = 'sequential';
@@ -111,13 +106,21 @@ export abstract class BaseEvaluationStrategy {
111106
result: ProviderResolutionResult<T>,
112107
): boolean;
113108

109+
abstract shouldTrackWithThisProvider(
110+
strategyContext: StrategyProviderContext,
111+
context: EvaluationContext,
112+
trackingEventName: string,
113+
trackingEventDetails: TrackingEventDetails,
114+
): boolean;
115+
114116
abstract determineFinalResult<T extends FlagValue>(
115117
strategyContext: StrategyEvaluationContext,
116118
context: EvaluationContext,
117119
resolutions: ProviderResolutionResult<T>[],
118120
): FinalResult<T>;
119121
}
120122
```
123+
121124
The `runMode` property determines whether the list of providers will be evaluated sequentially or in parallel.
122125

123126
The `shouldEvaluateThisProvider` method is called just before a provider is evaluated by the Multi-Provider. If the function returns `false`, then
@@ -127,9 +130,81 @@ Check the type definitions for the full list.
127130
The `shouldEvaluateNextProvider` function is called after a provider is evaluated. If it returns `true`, the next provider in the sequence will be called,
128131
otherwise no more providers will be evaluated. It is called with the same data as `shouldEvaluateThisProvider` as well as the details about the evaluation result. This function is not called when the `runMode` is `parallel`.
129132

133+
The `shouldTrackWithThisProvider` method is called before tracking an event with each provider. If the function returns `false`, then
134+
the provider will be skipped for that tracking event. The method includes the tracking event name and details,
135+
allowing for fine-grained control over which providers receive which events. By default, providers in `NOT_READY` or `FATAL` status are skipped.
136+
130137
The `determineFinalResult` function is called after all providers have been called, or the `shouldEvaluateNextProvider` function returned false. It is called
131138
with a list of results from all the individual providers' evaluations. It returns the final decision for evaluation result, or throws an error if needed.
132139

140+
## Tracking Support
141+
142+
The Multi-Provider supports tracking events across multiple providers, allowing you to send analytics events to all configured providers simultaneously.
143+
144+
### Basic Tracking Usage
145+
146+
```typescript
147+
import { WebMultiProvider } from '@openfeature/multi-provider-web'
148+
import { OpenFeature } from '@openfeature/web-sdk'
149+
150+
const multiProvider = new WebMultiProvider([
151+
{ provider: new ProviderA() },
152+
{ provider: new ProviderB() }
153+
])
154+
155+
await OpenFeature.setProviderAndWait(multiProvider)
156+
const client = OpenFeature.getClient()
157+
158+
// Tracked events will be sent to all providers by default
159+
client.track('user-conversion', {
160+
value: 99.99,
161+
currency: 'USD',
162+
conversionType: 'purchase'
163+
})
164+
165+
client.track('page-view', {
166+
page: '/checkout',
167+
source: 'direct'
168+
})
169+
```
170+
171+
### Tracking Behavior
172+
173+
- **Default**: All providers receive tracking calls by default
174+
- **Error Handling**: If one provider fails to track, others continue normally and errors are logged
175+
- **Provider Status**: Providers in `NOT_READY` or `FATAL` status are automatically skipped
176+
- **Optional Method**: Providers without a `track` method are gracefully skipped
177+
178+
### Customizing Tracking with Strategies
179+
180+
You can customize which providers receive tracking calls by overriding the `shouldTrackWithThisProvider` method in your custom strategy:
181+
182+
```typescript
183+
import { BaseEvaluationStrategy, StrategyProviderContext } from '@openfeature/multi-provider-web'
184+
185+
class CustomTrackingStrategy extends BaseEvaluationStrategy {
186+
// Override tracking behavior
187+
shouldTrackWithThisProvider(
188+
strategyContext: StrategyProviderContext,
189+
context: EvaluationContext,
190+
trackingEventName: string,
191+
trackingEventDetails: TrackingEventDetails,
192+
): boolean {
193+
// Only track with the primary provider
194+
if (strategyContext.providerName === 'primary-provider') {
195+
return true;
196+
}
197+
198+
// Skip tracking for analytics events on backup providers
199+
if (trackingEventName.startsWith('analytics.')) {
200+
return false;
201+
}
202+
203+
return super.shouldTrackWithThisProvider(strategyContext, context, trackingEventName, trackingEventDetails);
204+
}
205+
}
206+
```
207+
133208
## Building
134209

135210
Run `nx package providers-multi-provider` to build the library.

libs/providers/multi-provider-web/src/lib/multi-provider-web.spec.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
Provider,
99
ProviderEmittableEvents,
1010
ProviderMetadata,
11+
TrackingEventDetails,
1112
} from '@openfeature/web-sdk';
1213
import {
1314
DefaultLogger,
@@ -20,13 +21,15 @@ import {
2021
import { FirstMatchStrategy } from './strategies/FirstMatchStrategy';
2122
import { FirstSuccessfulStrategy } from './strategies/FirstSuccessfulStrategy';
2223
import { ComparisonStrategy } from './strategies/ComparisonStrategy';
24+
import type { BaseEvaluationStrategy } from './strategies/BaseEvaluationStrategy';
2325

2426
class TestProvider implements Provider {
2527
public metadata: ProviderMetadata = {
2628
name: 'TestProvider',
2729
};
2830
public events = new OpenFeatureEventEmitter();
2931
public hooks: Hook[] = [];
32+
public track = jest.fn();
3033
constructor(
3134
public resolveBooleanEvaluation = jest.fn().mockReturnValue({ value: false }),
3235
public resolveStringEvaluation = jest.fn().mockReturnValue({ value: 'default' }),
@@ -718,5 +721,170 @@ describe('MultiProvider', () => {
718721
});
719722
});
720723
});
724+
725+
describe('tracking', () => {
726+
const context: EvaluationContext = { targetingKey: 'user123' };
727+
const trackingEventDetails: TrackingEventDetails = { value: 100, currency: 'USD' };
728+
729+
it('calls track on all providers by default', () => {
730+
const provider1 = new TestProvider();
731+
const provider2 = new TestProvider();
732+
const provider3 = new TestProvider();
733+
734+
const multiProvider = new WebMultiProvider([
735+
{ provider: provider1 },
736+
{ provider: provider2 },
737+
{ provider: provider3 },
738+
]);
739+
740+
multiProvider.track('purchase', context, trackingEventDetails);
741+
742+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
743+
expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
744+
expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
745+
});
746+
747+
it('skips providers without track method', () => {
748+
const provider1 = new TestProvider();
749+
const provider2 = new InMemoryProvider(); // Doesn't have track method
750+
const provider3 = new TestProvider();
751+
752+
const multiProvider = new WebMultiProvider([
753+
{ provider: provider1 },
754+
{ provider: provider2 },
755+
{ provider: provider3 },
756+
]);
757+
758+
expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow();
759+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
760+
expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
761+
});
762+
763+
it('continues tracking with other providers when one throws an error', () => {
764+
const provider1 = new TestProvider();
765+
const provider2 = new TestProvider();
766+
const provider3 = new TestProvider();
767+
768+
provider2.track.mockImplementation(() => {
769+
throw new Error('Tracking failed');
770+
});
771+
772+
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
773+
const multiProvider = new WebMultiProvider(
774+
[{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }],
775+
undefined,
776+
mockLogger,
777+
);
778+
779+
expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow();
780+
781+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
782+
expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
783+
expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
784+
expect(mockLogger.error).toHaveBeenCalledWith(
785+
'Error tracking event "purchase" with provider "TestProvider-2":',
786+
expect.any(Error),
787+
);
788+
});
789+
790+
it('respects strategy shouldTrackWithThisProvider decision', () => {
791+
const provider1 = new TestProvider();
792+
const provider2 = new TestProvider();
793+
const provider3 = new TestProvider();
794+
795+
// Create a custom strategy that only allows the second provider to track
796+
class MockStrategy extends FirstMatchStrategy {
797+
override shouldTrackWithThisProvider = jest.fn().mockImplementation((strategyContext) => {
798+
return strategyContext.providerName === 'TestProvider-2';
799+
});
800+
}
801+
802+
const mockStrategy = new MockStrategy();
803+
804+
const multiProvider = new WebMultiProvider(
805+
[{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }],
806+
mockStrategy,
807+
);
808+
809+
multiProvider.track('purchase', context, trackingEventDetails);
810+
811+
expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledTimes(3);
812+
expect(provider1.track).not.toHaveBeenCalled();
813+
expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
814+
expect(provider3.track).not.toHaveBeenCalled();
815+
});
816+
817+
it('does not track with providers in NOT_READY or FATAL status by default', () => {
818+
const provider1 = new TestProvider();
819+
const provider2 = new TestProvider();
820+
const provider3 = new TestProvider();
821+
822+
const multiProvider = new WebMultiProvider([
823+
{ provider: provider1 },
824+
{ provider: provider2 },
825+
{ provider: provider3 },
826+
]);
827+
828+
// Mock the status tracker to return different statuses
829+
const mockStatusTracker = {
830+
providerStatus: jest.fn().mockImplementation((name) => {
831+
if (name === 'TestProvider-1') return 'NOT_READY';
832+
if (name === 'TestProvider-2') return 'READY';
833+
if (name === 'TestProvider-3') return 'FATAL';
834+
return 'READY'; // Default fallback
835+
}),
836+
};
837+
(multiProvider as any).statusTracker = mockStatusTracker;
838+
839+
multiProvider.track('purchase', context, trackingEventDetails);
840+
841+
expect(provider1.track).not.toHaveBeenCalled();
842+
expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
843+
expect(provider3.track).not.toHaveBeenCalled();
844+
});
845+
846+
it('passes correct strategy context to shouldTrackWithThisProvider', () => {
847+
const provider1 = new TestProvider();
848+
const provider2 = new TestProvider();
849+
850+
class MockStrategy extends FirstMatchStrategy {
851+
override shouldTrackWithThisProvider = jest.fn().mockReturnValue(true);
852+
}
853+
854+
const mockStrategy = new MockStrategy();
855+
856+
const multiProvider = new WebMultiProvider([{ provider: provider1 }, { provider: provider2 }], mockStrategy);
857+
858+
// Mock the status tracker to return READY status
859+
const mockStatusTracker = {
860+
providerStatus: jest.fn().mockReturnValue('READY'),
861+
};
862+
(multiProvider as any).statusTracker = mockStatusTracker;
863+
864+
multiProvider.track('purchase', context, trackingEventDetails);
865+
866+
expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith(
867+
{
868+
provider: provider1,
869+
providerName: 'TestProvider-1',
870+
providerStatus: 'READY',
871+
},
872+
context,
873+
'purchase',
874+
trackingEventDetails,
875+
);
876+
877+
expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith(
878+
{
879+
provider: provider2,
880+
providerName: 'TestProvider-2',
881+
providerStatus: 'READY',
882+
},
883+
context,
884+
'purchase',
885+
trackingEventDetails,
886+
);
887+
});
888+
});
721889
});
722890
});

0 commit comments

Comments
 (0)