feat(#1873/playground): Return detailed information on feature toggle evaluation (#1839)
* Feat: return reasons why a feature evaluated to true or false
Note: this is very rough and just straight ripped from the nodejs
client. It will need a lot of work, but is a good place to start
* Feat: add suggested shape for new payload
* Chore: minor cleanup
* Wip: make server compile again
* Remove unused schema ref
* Export new schemas
* Chore: fix some tests to use sub property
* Fix: fix some tests
* Refactor: rename some variables, uncomment some stuff
* Add segments type to bootstrap options
* Add segments capability to offline feature evaluator
* Fix function calls after turning params into an option abject
* Feat: test strategy order, etc
* Feat: add test to check that all strats are returned correctly
* Feat: allow you to include strategy ids in clients
* Wip: hook up segments in the offline client.
Note: compared to regular clients, they still fail
* Feat: add segments validation
* Fix: fix test case invariant.
* Chore: revert to returning only `boolean` from strategies.
This _should_ make it work with custom strategies too 🤞
* Feat: make more properties of the returned feature required
* Wip: add some comments and unfinished tests for edge cases
* Feat: add `isEnabledInCurrentEnvironment` prop
* Feat: consider more strategy failure cases
* Feat: test that isenabledinenvironment matches expectations
* Feat: add unknown strategies
* Fix: fix property access typo
* Feat: add unknown strategy for fallback purposes
* Feat: test edge case: all unknown strategies
* Feat: add custom strategy to arbitrary
* Feat: test that features can be true, even if not enabled in env
* Chore: add some comments
* Wip: fix sdk tests
* Remove comments, improve test logging
* Feat: add descriptions and examples to playground feature schema
* Switch `examples` for `example`
* Update schemas with descriptions and examples
* Fix: update snapshot
* Fix: openapi example
* Fix: merge issues
* Fix: fix issue where feature evaluation state was wrong
* Chore: update openapi spec
* Fix: fix broken offline client tests
* Refactor: move schemas into separate files
* Refactor: remove "reason" for incomplete evaluation.
The only instances where evaluation is incomplete is when we don't
know what the strategy is.
* Refactor: move unleash node client into test and dev dependencies
* Wip: further removal of stuff
* Chore: remove a bunch of code that we don't use
* Chore: remove comment
* Chore: remove unused code
* Fix: fix some prettier errors
* Type parameters in strategies to avoid `any`
* Fix: remove commented out code
* Feat: make `id` required on playground strategies
* Chore: remove redundant type
* Fix: remove redundant if and fix fallback evaluation
* Refactor: reduce nesting and remove duplication
* Fix: remove unused helper function
* Refactor: type `parameters` as `unknown`
* Chore: remove redundant comment
* Refactor: move constraint code into a separate file
* Refactor: rename `unleash` -> `feature-evaluator`
* Rename class `Unleash` -> `FeatureEvaluator`
* Refactor: remove this.ready and sync logic from feature evaluator
* Refactor: remove unused code, rename config type
* Refactor: remove event emission from the Unleash client
* Remove unlistened-for events in feature evaluator
* Refactor: make offline client synchronous; remove code
* Fix: update openapi snapshot after adding required strategy ids
* Feat: change `strategies` format.
This commit changes the format of a playground feature's `strategies`
properties from a list of strategies to an object with properties
`result` and `data`. It looks a bit like this:
```ts
type Strategies = {
result: boolean | "unknown",
data: Strategy[]
}
```
The reason is that this allows us to avoid the breaking change that
was previously suggested in the PR:
`feature.isEnabled` used to be a straight boolean. Then, when we found
out we couldn't necessarily evaluate all strategies (custom strats are
hard!) we changed it to `boolean | 'unevaluated'`. However, this is
confusing on a few levels as the playground results are no longer the
same as the SDK would be, nor are they strictly boolean anymore.
This change reverts the `isEnabled` functionality to what it was
before (so it's always a mirror of what the SDK would show).
The equivalent of `feature.isEnabled === 'unevaluated'` now becomes
`feature.isEnabled && strategy.result === 'unknown'`.
* Fix: Fold long string descriptions over multiple lines.
* Fix: update snapshot after adding line breaks to descriptions
2022-08-04 15:41:52 +02:00
|
|
|
import { Strategy } from './strategy';
|
|
|
|
import { FeatureInterface } from './feature';
|
|
|
|
import { RepositoryInterface } from './repository';
|
|
|
|
import {
|
|
|
|
Variant,
|
|
|
|
getDefaultVariant,
|
|
|
|
VariantDefinition,
|
|
|
|
selectVariant,
|
|
|
|
} from './variant';
|
|
|
|
import { Context } from './context';
|
|
|
|
import { SegmentForEvaluation } from './strategy/strategy';
|
|
|
|
import { PlaygroundStrategySchema } from 'lib/openapi/spec/playground-strategy-schema';
|
|
|
|
import { playgroundStrategyEvaluation } from '../../openapi/spec/playground-strategy-schema';
|
|
|
|
|
|
|
|
export type StrategyEvaluationResult = Pick<
|
|
|
|
PlaygroundStrategySchema,
|
|
|
|
'result' | 'segments' | 'constraints'
|
|
|
|
>;
|
|
|
|
|
|
|
|
export type FeatureStrategiesEvaluationResult = {
|
|
|
|
result: boolean | typeof playgroundStrategyEvaluation.unknownResult;
|
|
|
|
strategies: PlaygroundStrategySchema[];
|
|
|
|
};
|
|
|
|
|
|
|
|
export default class UnleashClient {
|
|
|
|
private repository: RepositoryInterface;
|
|
|
|
|
|
|
|
private strategies: Strategy[];
|
|
|
|
|
|
|
|
constructor(repository: RepositoryInterface, strategies: Strategy[]) {
|
|
|
|
this.repository = repository;
|
|
|
|
this.strategies = strategies || [];
|
|
|
|
|
|
|
|
this.strategies.forEach((strategy: Strategy) => {
|
|
|
|
if (
|
|
|
|
!strategy ||
|
|
|
|
!strategy.name ||
|
|
|
|
typeof strategy.name !== 'string' ||
|
|
|
|
!strategy.isEnabled ||
|
|
|
|
typeof strategy.isEnabled !== 'function'
|
|
|
|
) {
|
|
|
|
throw new Error('Invalid strategy data / interface');
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
private getStrategy(name: string): Strategy | undefined {
|
|
|
|
return this.strategies.find(
|
|
|
|
(strategy: Strategy): boolean => strategy.name === name,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
isEnabled(
|
|
|
|
name: string,
|
|
|
|
context: Context,
|
|
|
|
fallback: Function,
|
|
|
|
): FeatureStrategiesEvaluationResult {
|
|
|
|
const feature = this.repository.getToggle(name);
|
|
|
|
return this.isFeatureEnabled(feature, context, fallback);
|
|
|
|
}
|
|
|
|
|
|
|
|
isFeatureEnabled(
|
|
|
|
feature: FeatureInterface,
|
|
|
|
context: Context,
|
|
|
|
fallback: Function,
|
|
|
|
): FeatureStrategiesEvaluationResult {
|
|
|
|
if (!feature) {
|
|
|
|
return fallback();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!Array.isArray(feature.strategies)) {
|
|
|
|
return {
|
|
|
|
result: false,
|
|
|
|
strategies: [],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
if (feature.strategies.length === 0) {
|
|
|
|
return {
|
|
|
|
result: feature.enabled,
|
|
|
|
strategies: [],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
const strategies = feature.strategies.map(
|
|
|
|
(strategySelector): PlaygroundStrategySchema => {
|
2022-08-05 11:09:55 +02:00
|
|
|
const getStrategy = () => {
|
|
|
|
// the application hostname strategy relies on external
|
|
|
|
// variables to calculate its result. As such, we can't
|
|
|
|
// evaluate it in a way that makes sense. So we'll
|
|
|
|
// use the 'unknown' strategy instead.
|
|
|
|
if (strategySelector.name === 'applicationHostname') {
|
|
|
|
return this.getStrategy('unknown');
|
|
|
|
}
|
|
|
|
return (
|
|
|
|
this.getStrategy(strategySelector.name) ??
|
|
|
|
this.getStrategy('unknown')
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
const strategy = getStrategy();
|
feat(#1873/playground): Return detailed information on feature toggle evaluation (#1839)
* Feat: return reasons why a feature evaluated to true or false
Note: this is very rough and just straight ripped from the nodejs
client. It will need a lot of work, but is a good place to start
* Feat: add suggested shape for new payload
* Chore: minor cleanup
* Wip: make server compile again
* Remove unused schema ref
* Export new schemas
* Chore: fix some tests to use sub property
* Fix: fix some tests
* Refactor: rename some variables, uncomment some stuff
* Add segments type to bootstrap options
* Add segments capability to offline feature evaluator
* Fix function calls after turning params into an option abject
* Feat: test strategy order, etc
* Feat: add test to check that all strats are returned correctly
* Feat: allow you to include strategy ids in clients
* Wip: hook up segments in the offline client.
Note: compared to regular clients, they still fail
* Feat: add segments validation
* Fix: fix test case invariant.
* Chore: revert to returning only `boolean` from strategies.
This _should_ make it work with custom strategies too 🤞
* Feat: make more properties of the returned feature required
* Wip: add some comments and unfinished tests for edge cases
* Feat: add `isEnabledInCurrentEnvironment` prop
* Feat: consider more strategy failure cases
* Feat: test that isenabledinenvironment matches expectations
* Feat: add unknown strategies
* Fix: fix property access typo
* Feat: add unknown strategy for fallback purposes
* Feat: test edge case: all unknown strategies
* Feat: add custom strategy to arbitrary
* Feat: test that features can be true, even if not enabled in env
* Chore: add some comments
* Wip: fix sdk tests
* Remove comments, improve test logging
* Feat: add descriptions and examples to playground feature schema
* Switch `examples` for `example`
* Update schemas with descriptions and examples
* Fix: update snapshot
* Fix: openapi example
* Fix: merge issues
* Fix: fix issue where feature evaluation state was wrong
* Chore: update openapi spec
* Fix: fix broken offline client tests
* Refactor: move schemas into separate files
* Refactor: remove "reason" for incomplete evaluation.
The only instances where evaluation is incomplete is when we don't
know what the strategy is.
* Refactor: move unleash node client into test and dev dependencies
* Wip: further removal of stuff
* Chore: remove a bunch of code that we don't use
* Chore: remove comment
* Chore: remove unused code
* Fix: fix some prettier errors
* Type parameters in strategies to avoid `any`
* Fix: remove commented out code
* Feat: make `id` required on playground strategies
* Chore: remove redundant type
* Fix: remove redundant if and fix fallback evaluation
* Refactor: reduce nesting and remove duplication
* Fix: remove unused helper function
* Refactor: type `parameters` as `unknown`
* Chore: remove redundant comment
* Refactor: move constraint code into a separate file
* Refactor: rename `unleash` -> `feature-evaluator`
* Rename class `Unleash` -> `FeatureEvaluator`
* Refactor: remove this.ready and sync logic from feature evaluator
* Refactor: remove unused code, rename config type
* Refactor: remove event emission from the Unleash client
* Remove unlistened-for events in feature evaluator
* Refactor: make offline client synchronous; remove code
* Fix: update openapi snapshot after adding required strategy ids
* Feat: change `strategies` format.
This commit changes the format of a playground feature's `strategies`
properties from a list of strategies to an object with properties
`result` and `data`. It looks a bit like this:
```ts
type Strategies = {
result: boolean | "unknown",
data: Strategy[]
}
```
The reason is that this allows us to avoid the breaking change that
was previously suggested in the PR:
`feature.isEnabled` used to be a straight boolean. Then, when we found
out we couldn't necessarily evaluate all strategies (custom strats are
hard!) we changed it to `boolean | 'unevaluated'`. However, this is
confusing on a few levels as the playground results are no longer the
same as the SDK would be, nor are they strictly boolean anymore.
This change reverts the `isEnabled` functionality to what it was
before (so it's always a mirror of what the SDK would show).
The equivalent of `feature.isEnabled === 'unevaluated'` now becomes
`feature.isEnabled && strategy.result === 'unknown'`.
* Fix: Fold long string descriptions over multiple lines.
* Fix: update snapshot after adding line breaks to descriptions
2022-08-04 15:41:52 +02:00
|
|
|
|
|
|
|
const segments =
|
|
|
|
strategySelector.segments
|
|
|
|
?.map(this.getSegment(this.repository))
|
|
|
|
.filter(Boolean) ?? [];
|
|
|
|
|
|
|
|
return {
|
|
|
|
name: strategySelector.name,
|
|
|
|
id: strategySelector.id,
|
|
|
|
parameters: strategySelector.parameters,
|
|
|
|
...strategy.isEnabledWithConstraints(
|
|
|
|
strategySelector.parameters,
|
|
|
|
context,
|
|
|
|
strategySelector.constraints,
|
|
|
|
segments,
|
|
|
|
),
|
|
|
|
};
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
// Feature evaluation
|
|
|
|
const overallStrategyResult = () => {
|
|
|
|
// if at least one strategy is enabled, then the feature is enabled
|
|
|
|
if (
|
|
|
|
strategies.some((strategy) => strategy.result.enabled === true)
|
|
|
|
) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// if at least one strategy is unknown, then the feature _may_ be enabled
|
|
|
|
if (
|
|
|
|
strategies.some(
|
|
|
|
(strategy) => strategy.result.enabled === 'unknown',
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
return playgroundStrategyEvaluation.unknownResult;
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
};
|
|
|
|
|
|
|
|
const evalResults: FeatureStrategiesEvaluationResult = {
|
|
|
|
result: overallStrategyResult(),
|
|
|
|
strategies,
|
|
|
|
};
|
|
|
|
|
|
|
|
return evalResults;
|
|
|
|
}
|
|
|
|
|
|
|
|
getSegment(repo: RepositoryInterface) {
|
|
|
|
return (segmentId: number): SegmentForEvaluation | undefined => {
|
|
|
|
const segment = repo.getSegment(segmentId);
|
|
|
|
if (!segment) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
name: segment.name,
|
|
|
|
id: segmentId,
|
|
|
|
constraints: segment.constraints,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
getVariant(
|
|
|
|
name: string,
|
|
|
|
context: Context,
|
|
|
|
fallbackVariant?: Variant,
|
|
|
|
): Variant {
|
|
|
|
return this.resolveVariant(name, context, true, fallbackVariant);
|
|
|
|
}
|
|
|
|
|
|
|
|
// This function is intended to close an issue in the proxy where feature enabled
|
|
|
|
// state gets checked twice when resolving a variant with random stickiness and
|
|
|
|
// gradual rollout. This is not intended for general use, prefer getVariant instead
|
|
|
|
forceGetVariant(
|
|
|
|
name: string,
|
|
|
|
context: Context,
|
|
|
|
fallbackVariant?: Variant,
|
|
|
|
): Variant {
|
|
|
|
return this.resolveVariant(name, context, false, fallbackVariant);
|
|
|
|
}
|
|
|
|
|
|
|
|
private resolveVariant(
|
|
|
|
name: string,
|
|
|
|
context: Context,
|
|
|
|
checkToggle: boolean,
|
|
|
|
fallbackVariant?: Variant,
|
|
|
|
): Variant {
|
|
|
|
const fallback = fallbackVariant || getDefaultVariant();
|
|
|
|
const feature = this.repository.getToggle(name);
|
|
|
|
if (
|
|
|
|
typeof feature === 'undefined' ||
|
|
|
|
!feature.variants ||
|
|
|
|
!Array.isArray(feature.variants) ||
|
|
|
|
feature.variants.length === 0 ||
|
|
|
|
!feature.enabled
|
|
|
|
) {
|
|
|
|
return fallback;
|
|
|
|
}
|
|
|
|
|
|
|
|
let enabled = true;
|
|
|
|
if (checkToggle) {
|
|
|
|
enabled =
|
|
|
|
this.isFeatureEnabled(feature, context, () =>
|
|
|
|
fallbackVariant ? fallbackVariant.enabled : false,
|
|
|
|
).result === true;
|
|
|
|
if (!enabled) {
|
|
|
|
return fallback;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const variant: VariantDefinition | null = selectVariant(
|
|
|
|
feature,
|
|
|
|
context,
|
|
|
|
);
|
|
|
|
if (variant === null) {
|
|
|
|
return fallback;
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
name: variant.name,
|
|
|
|
payload: variant.payload,
|
|
|
|
enabled: !checkToggle || enabled,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|