-
Notifications
You must be signed in to change notification settings - Fork 43
feat: Migrate MultiProvider from js-sdk-contrib #1234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jonathannorris
wants to merge
11
commits into
open-feature:main
Choose a base branch
from
DevCycleHQ-Sandbox:feat-migrate-multi-provider
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+4,020
−5
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
b84a16f
feat: copy multi-provider and multi-provider-web from contrib repo
jonathannorris 07b0a62
feat: copy over multi-provider README's
jonathannorris be38f1d
feat: migrate multi-provider into js-sdk with normalized imports and …
jonathannorris 8cf2f4a
fix: resolve eslint errors in multi-provider migration
jonathannorris 3f3c49f
fix: address PR review comments for WebMultiProvider naming and docum…
jonathannorris a48befd
fix: address remaining PR review comments for server SDK
jonathannorris 1c3d1bd
fix: add missing hookData property to multi-provider test files
jonathannorris 18a7043
fix: address Copilot PR review comments
jonathannorris 0061595
fix: update @typescript-eslint/no-unused-vars eslint rules, cleanup c…
jonathannorris 0bf8f8e
fix: WebMultiProvider test
jonathannorris 03c26d7
chore: add ProviderStatus.RECONCILING to web status-tracker.ts
jonathannorris File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from './provider'; | ||
export * from './no-op-provider'; | ||
export * from './in-memory-provider'; | ||
export * from './multi-provider'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,185 @@ | ||
# OpenFeature Multi-Provider | ||
|
||
The Multi-Provider allows you to use multiple underlying providers as sources of flag data for the OpenFeature server SDK. | ||
When a flag is being evaluated, the Multi-Provider will consult each underlying provider it is managing in order to determine | ||
the final result. Different evaluation strategies can be defined to control which providers get evaluated and which result is used. | ||
|
||
The Multi-Provider is a powerful tool for performing migrations between flag providers, or combining multiple providers into a single | ||
feature flagging interface. For example: | ||
|
||
- *Migration*: When migrating between two providers, you can run both in parallel under a unified flagging interface. As flags are added to the | ||
new provider, the Multi-Provider will automatically find and return them, falling back to the old provider if the new provider does not have | ||
- *Multiple Data Sources*: The Multi-Provider allows you to seamlessly combine many sources of flagging data, such as environment variables, | ||
local files, database values and SaaS hosted feature management systems. | ||
|
||
## Usage | ||
|
||
The Multi-Provider is initialized with an array of providers it should evaluate: | ||
|
||
```typescript | ||
import { MultiProvider } from '@openfeature/server-sdk' | ||
import { OpenFeature } from '@openfeature/server-sdk' | ||
|
||
const multiProvider = new MultiProvider([ | ||
{ provider: new ProviderA() }, | ||
{ provider: new ProviderB() } | ||
]) | ||
|
||
await OpenFeature.setProviderAndWait(multiProvider) | ||
|
||
const client = OpenFeature.getClient() | ||
|
||
console.log("Evaluating flag") | ||
console.log(await client.getBooleanDetails("my-flag", false)); | ||
``` | ||
|
||
By default, the Multi-Provider will evaluate all underlying providers in order and return the first successful result. If a provider indicates | ||
it does not have a flag (FLAG_NOT_FOUND error code), then it will be skipped and the next provider will be evaluated. If any provider throws | ||
or returns an error result, the operation will fail and the error will be thrown. If no provider returns a successful result, the operation | ||
will fail with a FLAG_NOT_FOUND error code. | ||
|
||
To change this behaviour, a different "strategy" can be provided: | ||
|
||
```typescript | ||
import { MultiProvider, FirstSuccessfulStrategy } from '@openfeature/server-sdk' | ||
|
||
const multiProvider = new MultiProvider( | ||
[ | ||
{ provider: new ProviderA() }, | ||
{ provider: new ProviderB() } | ||
], | ||
new FirstSuccessfulStrategy() | ||
) | ||
``` | ||
|
||
## Strategies | ||
|
||
The Multi-Provider comes with three strategies out of the box: | ||
|
||
- `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. | ||
- `FirstSuccessfulStrategy`: Evaluates all providers in order and returns the first successful result. Any error will cause that provider to be skipped. | ||
If no successful result is returned, the set of errors will be thrown. | ||
- `ComparisonStrategy`: Evaluates all providers in parallel. If every provider returns a successful result with the same value, then that result is returned. | ||
Otherwise, the result returned by the configured "fallback provider" will be used. When values do not agree, an optional callback will be executed to notify | ||
you of the mismatch. This can be useful when migrating between providers that are expected to contain identical configuration. You can easily spot mismatches | ||
in configuration without affecting flag behaviour. | ||
|
||
This strategy accepts several arguments during initialization: | ||
|
||
```typescript | ||
import { MultiProvider, ComparisonStrategy } from '@openfeature/server-sdk' | ||
|
||
const providerA = new ProviderA() | ||
const multiProvider = new MultiProvider( | ||
[ | ||
{ provider: providerA }, | ||
{ provider: new ProviderB() } | ||
], | ||
new ComparisonStrategy(providerA, (details) => { | ||
console.log("Mismatch detected", details) | ||
}) | ||
) | ||
``` | ||
|
||
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. | ||
|
||
## Tracking Support | ||
|
||
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. | ||
|
||
```typescript | ||
import { OpenFeature } from '@openfeature/server-sdk' | ||
import { MultiProvider } from '@openfeature/server-sdk' | ||
|
||
const multiProvider = new MultiProvider([ | ||
{ provider: new ProviderA() }, | ||
{ provider: new ProviderB() } | ||
]) | ||
|
||
await OpenFeature.setProviderAndWait(multiProvider) | ||
const client = OpenFeature.getClient() | ||
|
||
// Tracked events will be sent to all providers by default | ||
client.track('purchase', { targetingKey: 'user123' }, { value: 99.99, currency: 'USD' }) | ||
``` | ||
|
||
### Tracking Behavior | ||
|
||
- **Default**: All providers receive tracking calls by default | ||
- **Error Handling**: If one provider fails to track, others continue normally and errors are logged | ||
- **Provider Status**: Providers in `NOT_READY` or `FATAL` status are automatically skipped | ||
- **Optional Method**: Providers without a `track` method are gracefully skipped | ||
|
||
### Customizing Tracking with Strategies | ||
|
||
You can customize which providers receive tracking calls by overriding the `shouldTrackWithThisProvider` method in your custom strategy: | ||
|
||
```typescript | ||
import { BaseEvaluationStrategy, StrategyPerProviderContext } from '@openfeature/server-sdk' | ||
|
||
class CustomTrackingStrategy extends BaseEvaluationStrategy { | ||
shouldTrackWithThisProvider( | ||
strategyContext: StrategyPerProviderContext, | ||
context: EvaluationContext, | ||
trackingEventName: string, | ||
trackingEventDetails: TrackingEventDetails, | ||
): boolean { | ||
// Only track with the primary provider | ||
if (strategyContext.providerName === 'primary-provider') { | ||
return true; | ||
} | ||
|
||
// Skip tracking for analytics events on backup providers | ||
if (trackingEventName.startsWith('analytics.')) { | ||
return false; | ||
} | ||
|
||
return super.shouldTrackWithThisProvider(strategyContext, context, trackingEventName, trackingEventDetails); | ||
} | ||
} | ||
``` | ||
|
||
## Custom Strategies | ||
|
||
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": | ||
|
||
```typescript | ||
export abstract class BaseEvaluationStrategy { | ||
public runMode: 'parallel' | 'sequential' = 'sequential'; | ||
|
||
abstract shouldEvaluateThisProvider(strategyContext: StrategyPerProviderContext, evalContext: EvaluationContext): boolean; | ||
|
||
abstract shouldEvaluateNextProvider<T extends FlagValue>( | ||
strategyContext: StrategyPerProviderContext, | ||
context: EvaluationContext, | ||
result: ProviderResolutionResult<T>, | ||
): boolean; | ||
|
||
abstract shouldTrackWithThisProvider( | ||
strategyContext: StrategyPerProviderContext, | ||
context: EvaluationContext, | ||
trackingEventName: string, | ||
trackingEventDetails: TrackingEventDetails, | ||
): boolean; | ||
|
||
abstract determineFinalResult<T extends FlagValue>( | ||
strategyContext: StrategyEvaluationContext, | ||
context: EvaluationContext, | ||
resolutions: ProviderResolutionResult<T>[], | ||
): FinalResult<T>; | ||
} | ||
``` | ||
|
||
The `runMode` property determines whether the list of providers will be evaluated sequentially or in parallel. | ||
|
||
The `shouldEvaluateThisProvider` method is called just before a provider is evaluated by the Multi-Provider. If the function returns `false`, then | ||
the provider will be skipped instead of being evaluated. The function is called with details about the evaluation including the flag key and type. | ||
Check the type definitions for the full list. | ||
|
||
The `shouldEvaluateNextProvider` function is called after a provider is evaluated. If it returns `true`, the next provider in the sequence will be called, | ||
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`. | ||
|
||
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. | ||
|
||
The `determineFinalResult` function is called after all providers have been called, or the `shouldEvaluateNextProvider` function returned false. It is called | ||
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import type { ErrorCode } from '@openfeature/core'; | ||
import { GeneralError, OpenFeatureError } from '@openfeature/core'; | ||
import type { RegisteredProvider } from './types'; | ||
|
||
export class ErrorWithCode extends OpenFeatureError { | ||
constructor( | ||
public code: ErrorCode, | ||
message: string, | ||
) { | ||
super(message); | ||
} | ||
} | ||
|
||
export class AggregateError extends GeneralError { | ||
constructor( | ||
message: string, | ||
public originalErrors: { source: string; error: unknown }[], | ||
) { | ||
super(message); | ||
} | ||
} | ||
|
||
export const constructAggregateError = (providerErrors: { error: unknown; providerName: string }[]) => { | ||
const errorsWithSource = providerErrors | ||
.map(({ providerName, error }) => { | ||
return { source: providerName, error }; | ||
}) | ||
.flat(); | ||
|
||
// log first error in the message for convenience, but include all errors in the error object for completeness | ||
return new AggregateError( | ||
`Provider errors occurred: ${errorsWithSource[0].source}: ${errorsWithSource[0].error}`, | ||
errorsWithSource, | ||
); | ||
}; | ||
|
||
export const throwAggregateErrorFromPromiseResults = ( | ||
result: PromiseSettledResult<unknown>[], | ||
providerEntries: RegisteredProvider[], | ||
) => { | ||
const errors = result | ||
.map((r, i) => { | ||
if (r.status === 'rejected') { | ||
return { error: r.reason, providerName: providerEntries[i].name }; | ||
} | ||
return null; | ||
}) | ||
.filter((val): val is { error: unknown; providerName: string } => Boolean(val)); | ||
|
||
if (errors.length) { | ||
throw constructAggregateError(errors); | ||
} | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.