Skip to content

Commit 809e60e

Browse files
feat(multi-provider): add track method support with strategy customization
Signed-off-by: Jonathan Norris <[email protected]>
1 parent 4e12029 commit 809e60e

File tree

4 files changed

+281
-4
lines changed

4 files changed

+281
-4
lines changed

libs/providers/multi-provider/README.md

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,23 @@ 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
11-
- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables,
12+
- *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
1819
```
1920

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

2628
```typescript
@@ -66,8 +68,10 @@ const multiProvider = new MultiProvider(
6668
new FirstSuccessfulStrategy()
6769
)
6870
```
71+
6972
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.
73+
74+
- `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.
7175
- `FirstSuccessfulStrategy`: Evaluates all providers in order and returns the first successful result. Any error will cause that provider to be skipped.
7276
If no successful result is returned, the set of errors will be thrown.
7377
- `ComparisonStrategy`: Evaluates all providers in parallel. If every provider returns a successful result with the same value, then that result is returned.
@@ -97,8 +101,59 @@ const multiProvider = new MultiProvider(
97101
```
98102
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.
99103

104+
## Tracking Support
105+
106+
The Multi-Provider supports tracking events across multiple providers. When you call the `track` method, it will by default send the tracking event to all underlying providers that implement the `track` method.
107+
108+
```typescript
109+
import { OpenFeature } from '@openfeature/server-sdk'
110+
111+
const client = OpenFeature.getClient()
112+
113+
// Track an event - this will be sent to all providers
114+
client.track('purchase', { targetingKey: 'user123' }, { value: 99.99, currency: 'USD' })
115+
```
116+
117+
### Tracking Behavior
118+
119+
- **Default**: All providers receive tracking calls
120+
- **Error Handling**: If one provider fails to track, others continue normally and errors are logged
121+
- **Provider Status**: Providers in `NOT_READY` or `FATAL` status are automatically skipped
122+
- **Optional Method**: Providers without a `track` method are gracefully skipped
123+
124+
### Customizing Tracking with Strategies
125+
126+
You can customize which providers receive tracking calls by overriding the `shouldTrackWithThisProvider` method in your custom strategy:
127+
128+
```typescript
129+
import { BaseEvaluationStrategy, StrategyPerProviderContext } from '@openfeature/multi-provider'
130+
131+
class CustomTrackingStrategy extends BaseEvaluationStrategy {
132+
shouldTrackWithThisProvider(
133+
strategyContext: StrategyPerProviderContext,
134+
context: EvaluationContext,
135+
trackingEventName: string,
136+
trackingEventDetails: TrackingEventDetails,
137+
): boolean {
138+
// Only track with the primary provider
139+
if (strategyContext.providerName === 'primary-provider') {
140+
return true;
141+
}
142+
143+
// Skip tracking for analytics events on backup providers
144+
if (trackingEventName.startsWith('analytics.')) {
145+
return false;
146+
}
147+
148+
return super.shouldTrackWithThisProvider(strategyContext, context, trackingEventName, trackingEventDetails);
149+
}
150+
}
151+
```
152+
100153
## Custom Strategies
154+
101155
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":
156+
102157
```typescript
103158
export abstract class BaseEvaluationStrategy {
104159
public runMode: 'parallel' | 'sequential' = 'sequential';
@@ -111,13 +166,21 @@ export abstract class BaseEvaluationStrategy {
111166
result: ProviderResolutionResult<T>,
112167
): boolean;
113168

169+
abstract shouldTrackWithThisProvider(
170+
strategyContext: StrategyPerProviderContext,
171+
context: EvaluationContext,
172+
trackingEventName: string,
173+
trackingEventDetails: TrackingEventDetails,
174+
): boolean;
175+
114176
abstract determineFinalResult<T extends FlagValue>(
115177
strategyContext: StrategyEvaluationContext,
116178
context: EvaluationContext,
117179
resolutions: ProviderResolutionResult<T>[],
118180
): FinalResult<T>;
119181
}
120182
```
183+
121184
The `runMode` property determines whether the list of providers will be evaluated sequentially or in parallel.
122185

123186
The `shouldEvaluateThisProvider` method is called just before a provider is evaluated by the Multi-Provider. If the function returns `false`, then
@@ -127,6 +190,8 @@ Check the type definitions for the full list.
127190
The `shouldEvaluateNextProvider` function is called after a provider is evaluated. If it returns `true`, the next provider in the sequence will be called,
128191
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`.
129192

193+
The `shouldTrackWithThisProvider` method is called before sending a tracking event to each provider. Return `false` to skip tracking with that provider. By default, it only tracks with providers that are in a ready state (not `NOT_READY` or `FATAL`). Override this method to implement custom tracking logic based on the tracking event name, details, or provider characteristics.
194+
130195
The `determineFinalResult` function is called after all providers have been called, or the `shouldEvaluateNextProvider` function returned false. It is called
131196
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.
132197

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

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
Logger,
88
Provider,
99
ProviderMetadata,
10+
TrackingEventDetails,
1011
} from '@openfeature/server-sdk';
1112
import {
1213
DefaultLogger,
@@ -26,6 +27,8 @@ class TestProvider implements Provider {
2627
};
2728
public events = new OpenFeatureEventEmitter();
2829
public hooks: Hook[] = [];
30+
public track = jest.fn();
31+
2932
constructor(
3033
public resolveBooleanEvaluation = jest.fn().mockResolvedValue({ value: false }),
3134
public resolveStringEvaluation = jest.fn().mockResolvedValue({ value: 'default' }),
@@ -769,4 +772,161 @@ describe('MultiProvider', () => {
769772
});
770773
});
771774
});
775+
776+
describe('tracking', () => {
777+
const context: EvaluationContext = { targetingKey: 'user123' };
778+
const trackingEventDetails: TrackingEventDetails = { value: 100, currency: 'USD' };
779+
780+
it('calls track on all providers by default', () => {
781+
const provider1 = new TestProvider();
782+
const provider2 = new TestProvider();
783+
const provider3 = new TestProvider();
784+
785+
const multiProvider = new MultiProvider([
786+
{ provider: provider1 },
787+
{ provider: provider2 },
788+
{ provider: provider3 },
789+
]);
790+
791+
multiProvider.track('purchase', context, trackingEventDetails);
792+
793+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
794+
expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
795+
expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
796+
});
797+
798+
it('skips providers without track method', () => {
799+
const provider1 = new TestProvider();
800+
const provider2 = new InMemoryProvider(); // Doesn't have track method
801+
const provider3 = new TestProvider();
802+
803+
const multiProvider = new MultiProvider([
804+
{ provider: provider1 },
805+
{ provider: provider2 },
806+
{ provider: provider3 },
807+
]);
808+
809+
expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow();
810+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
811+
expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
812+
});
813+
814+
it('continues tracking with other providers when one throws an error', () => {
815+
const provider1 = new TestProvider();
816+
const provider2 = new TestProvider();
817+
const provider3 = new TestProvider();
818+
819+
provider2.track.mockImplementation(() => {
820+
throw new Error('Tracking failed');
821+
});
822+
823+
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
824+
const multiProvider = new MultiProvider(
825+
[{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }],
826+
undefined,
827+
mockLogger,
828+
);
829+
830+
expect(() => multiProvider.track('purchase', context, trackingEventDetails)).not.toThrow();
831+
832+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
833+
expect(provider2.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
834+
expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
835+
expect(mockLogger.error).toHaveBeenCalledWith(
836+
'Error tracking event "purchase" with provider "TestProvider-2":',
837+
expect.any(Error),
838+
);
839+
});
840+
841+
it('respects strategy shouldTrackWithThisProvider decision', () => {
842+
const provider1 = new TestProvider();
843+
const provider2 = new TestProvider();
844+
const provider3 = new TestProvider();
845+
846+
const mockStrategy = new FirstMatchStrategy();
847+
mockStrategy.shouldTrackWithThisProvider = jest
848+
.fn()
849+
.mockReturnValueOnce(true) // provider1: should track
850+
.mockReturnValueOnce(false) // provider2: should not track
851+
.mockReturnValueOnce(true); // provider3: should track
852+
853+
const multiProvider = new MultiProvider(
854+
[{ provider: provider1 }, { provider: provider2 }, { provider: provider3 }],
855+
mockStrategy,
856+
);
857+
858+
multiProvider.track('purchase', context, trackingEventDetails);
859+
860+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
861+
expect(provider2.track).not.toHaveBeenCalled();
862+
expect(provider3.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
863+
864+
expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledTimes(3);
865+
expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith(
866+
expect.objectContaining({
867+
provider: provider1,
868+
providerName: 'TestProvider-1',
869+
}),
870+
context,
871+
'purchase',
872+
trackingEventDetails,
873+
);
874+
});
875+
876+
it('does not track with providers in NOT_READY or FATAL status by default', () => {
877+
const provider1 = new TestProvider();
878+
const provider2 = new TestProvider();
879+
const provider3 = new TestProvider();
880+
881+
const multiProvider = new MultiProvider([
882+
{ provider: provider1 },
883+
{ provider: provider2 },
884+
{ provider: provider3 },
885+
]);
886+
887+
// Simulate providers with different statuses
888+
const mockStatusTracker = {
889+
providerStatus: jest
890+
.fn()
891+
.mockReturnValueOnce('READY') // provider1: ready
892+
.mockReturnValueOnce('NOT_READY') // provider2: not ready
893+
.mockReturnValueOnce('FATAL'), // provider3: fatal
894+
};
895+
(multiProvider as any).statusTracker = mockStatusTracker;
896+
897+
multiProvider.track('purchase', context, trackingEventDetails);
898+
899+
expect(provider1.track).toHaveBeenCalledWith('purchase', context, trackingEventDetails);
900+
expect(provider2.track).not.toHaveBeenCalled();
901+
expect(provider3.track).not.toHaveBeenCalled();
902+
});
903+
904+
it('passes correct strategy context to shouldTrackWithThisProvider', () => {
905+
const provider1 = new TestProvider();
906+
907+
const mockStrategy = new FirstMatchStrategy();
908+
mockStrategy.shouldTrackWithThisProvider = jest.fn().mockReturnValue(true);
909+
910+
const multiProvider = new MultiProvider([{ provider: provider1, name: 'custom-name' }], mockStrategy);
911+
912+
// Mock the status tracker to return a proper status
913+
const mockStatusTracker = {
914+
providerStatus: jest.fn().mockReturnValue('READY'),
915+
};
916+
(multiProvider as any).statusTracker = mockStatusTracker;
917+
918+
multiProvider.track('purchase', context, trackingEventDetails);
919+
920+
expect(mockStrategy.shouldTrackWithThisProvider).toHaveBeenCalledWith(
921+
expect.objectContaining({
922+
provider: provider1,
923+
providerName: 'custom-name',
924+
providerStatus: 'READY',
925+
}),
926+
context,
927+
'purchase',
928+
trackingEventDetails,
929+
);
930+
});
931+
});
772932
});

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
Provider,
1515
ProviderMetadata,
1616
ResolutionDetails,
17+
TrackingEventDetails,
1718
} from '@openfeature/server-sdk';
1819
import {
1920
DefaultLogger,
@@ -311,6 +312,40 @@ export class MultiProvider implements Provider {
311312
];
312313
}
313314

315+
track(trackingEventName: string, context: EvaluationContext, trackingEventDetails: TrackingEventDetails): void {
316+
for (const providerEntry of this.providerEntries) {
317+
if (!providerEntry.provider.track) {
318+
continue;
319+
}
320+
321+
const strategyContext = {
322+
provider: providerEntry.provider,
323+
providerName: providerEntry.name,
324+
providerStatus: this.statusTracker.providerStatus(providerEntry.name),
325+
};
326+
327+
if (
328+
this.evaluationStrategy.shouldTrackWithThisProvider(
329+
strategyContext,
330+
context,
331+
trackingEventName,
332+
trackingEventDetails,
333+
)
334+
) {
335+
try {
336+
providerEntry.provider.track?.(trackingEventName, context, trackingEventDetails);
337+
} catch (error) {
338+
// TODO: how should we handle errors?
339+
// Log error but don't throw - tracking shouldn't break application flow
340+
this.logger.error(
341+
`Error tracking event "${trackingEventName}" with provider "${providerEntry.name}":`,
342+
error,
343+
);
344+
}
345+
}
346+
}
347+
}
348+
314349
private getErrorEvaluationDetails<T extends FlagValue>(
315350
flagKey: string,
316351
defaultValue: T,

0 commit comments

Comments
 (0)