1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

feat: strategy variants in playground (#4281)

This commit is contained in:
Mateusz Kwasniewski 2023-07-21 08:15:15 +02:00 committed by GitHub
parent ce70f9f54e
commit f1d1d7d49a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 263 additions and 57 deletions

View File

@ -162,7 +162,7 @@
"stoppable": "^1.1.0",
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
"unleash-client": "3.21.0",
"unleash-client": "4.1.0-beta.5",
"uuid": "^9.0.0"
},
"devDependencies": {

View File

@ -2,10 +2,10 @@ import { Strategy } from './strategy';
import { FeatureInterface } from './feature';
import { RepositoryInterface } from './repository';
import {
Variant,
getDefaultVariant,
VariantDefinition,
selectVariant,
Variant,
VariantDefinition,
} from './variant';
import { Context } from './context';
import { SegmentForEvaluation } from './strategy/strategy';
@ -24,6 +24,8 @@ export type EvaluatedPlaygroundStrategy = Omit<
export type FeatureStrategiesEvaluationResult = {
result: boolean | typeof playgroundStrategyEvaluation.unknownResult;
variant?: Variant;
variants?: VariantDefinition[];
strategies: EvaluatedPlaygroundStrategy[];
};
@ -110,30 +112,45 @@ export default class UnleashClient {
?.map(this.getSegment(this.repository))
.filter(Boolean) ?? [];
const evaluationResult = strategy.isEnabledWithConstraints(
strategySelector.parameters,
context,
strategySelector.constraints,
segments,
strategySelector.disabled,
strategySelector.variants,
);
return {
name: strategySelector.name,
id: strategySelector.id,
title: strategySelector.title,
disabled: strategySelector.disabled || false,
parameters: strategySelector.parameters,
...strategy.isEnabledWithConstraints(
strategySelector.parameters,
context,
strategySelector.constraints,
segments,
strategySelector.disabled,
),
...evaluationResult,
};
},
);
// Feature evaluation
const overallStrategyResult = () => {
const overallStrategyResult = (): [
boolean | typeof playgroundStrategyEvaluation.unknownResult,
VariantDefinition[] | undefined,
Variant | undefined | null,
] => {
// if at least one strategy is enabled, then the feature is enabled
const enabledStrategy = strategies.find(
(strategy) => strategy.result.enabled === true,
);
if (
strategies.some((strategy) => strategy.result.enabled === true)
enabledStrategy &&
enabledStrategy.result.evaluationStatus === 'complete'
) {
return true;
return [
true,
enabledStrategy.result.variants,
enabledStrategy.result.variant,
];
}
// if at least one strategy is unknown, then the feature _may_ be enabled
@ -142,14 +159,21 @@ export default class UnleashClient {
(strategy) => strategy.result.enabled === 'unknown',
)
) {
return playgroundStrategyEvaluation.unknownResult;
return [
playgroundStrategyEvaluation.unknownResult,
undefined,
undefined,
];
}
return false;
return [false, undefined, undefined];
};
const [result, variants, variant] = overallStrategyResult();
const evalResults: FeatureStrategiesEvaluationResult = {
result: overallStrategyResult(),
result,
variant,
variants,
strategies,
};
@ -197,8 +221,27 @@ export default class UnleashClient {
): Variant {
const fallback = fallbackVariant || getDefaultVariant();
const feature = this.repository.getToggle(name);
if (typeof feature === 'undefined') {
return fallback;
}
let enabled = true;
if (checkToggle) {
const result = this.isFeatureEnabled(feature, context, () =>
fallbackVariant ? fallbackVariant.enabled : false,
);
enabled = result.result === true;
const strategyVariant = result.variant;
if (enabled && strategyVariant) {
return strategyVariant;
}
if (!enabled) {
return fallback;
}
}
if (
typeof feature === 'undefined' ||
!feature.variants ||
!Array.isArray(feature.variants) ||
feature.variants.length === 0 ||
@ -207,17 +250,6 @@ export default class UnleashClient {
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,

View File

@ -3,6 +3,7 @@ import { PlaygroundSegmentSchema } from 'lib/openapi/spec/playground-segment-sch
import { StrategyEvaluationResult } from '../client';
import { Constraint, operators } from '../constraint';
import { Context } from '../context';
import { selectVariantDefinition, VariantDefinition } from '../variant';
export type SegmentForEvaluation = {
name: string;
@ -16,6 +17,7 @@ export interface StrategyTransportInterface {
disabled?: boolean;
parameters: any;
constraints: Constraint[];
variants?: VariantDefinition[];
segments?: number[];
id?: string;
}
@ -114,11 +116,12 @@ export class Strategy {
}
isEnabledWithConstraints(
parameters: unknown,
parameters: Record<string, unknown>,
context: Context,
constraints: Iterable<Constraint>,
segments: Array<SegmentForEvaluation>,
disabled?: boolean,
variantDefinitions?: VariantDefinition[],
): StrategyEvaluationResult {
const constraintResults = this.checkConstraints(context, constraints);
const enabledResult = this.isEnabled(parameters, context);
@ -127,10 +130,27 @@ export class Strategy {
const overallResult =
constraintResults.result && enabledResult && segmentResults.result;
const variantDefinition = variantDefinitions
? selectVariantDefinition(
parameters.groupId as string,
variantDefinitions,
context,
)
: undefined;
const variant = variantDefinition
? {
name: variantDefinition.name,
enabled: true,
payload: variantDefinition.payload,
}
: undefined;
return {
result: {
enabled: disabled ? false : overallResult,
evaluationStatus: 'complete',
variant,
variants: variant ? variantDefinitions : undefined,
},
constraints: constraintResults.constraints,
segments: segmentResults.segments,

View File

@ -3,7 +3,6 @@ import { Context } from './context';
import { FeatureInterface } from './feature';
import normalizedValue from './strategy/util';
import { resolveContextValue } from './helpers';
import { PayloadType } from 'unleash-client';
interface Override {
contextName: string;
@ -11,7 +10,7 @@ interface Override {
}
export interface Payload {
type: PayloadType;
type: 'string' | 'csv' | 'json';
value: string;
}
@ -19,8 +18,8 @@ export interface VariantDefinition {
name: string;
weight: number;
stickiness?: string;
payload: Payload;
overrides: Override[];
payload?: Payload;
overrides?: Override[];
}
export interface Variant {
@ -66,39 +65,40 @@ function overrideMatchesContext(context: Context): (o: Override) => boolean {
}
function findOverride(
feature: FeatureInterface,
variants: VariantDefinition[],
context: Context,
): VariantDefinition | undefined {
return feature.variants
return variants
.filter((variant) => variant.overrides)
.find((variant) =>
variant.overrides.some(overrideMatchesContext(context)),
variant.overrides?.some(overrideMatchesContext(context)),
);
}
export function selectVariant(
feature: FeatureInterface,
export function selectVariantDefinition(
featureName: string,
variants: VariantDefinition[],
context: Context,
): VariantDefinition | null {
const totalWeight = feature.variants.reduce((acc, v) => acc + v.weight, 0);
const totalWeight = variants.reduce((acc, v) => acc + v.weight, 0);
if (totalWeight <= 0) {
return null;
}
const variantOverride = findOverride(feature, context);
const variantOverride = findOverride(variants, context);
if (variantOverride) {
return variantOverride;
}
const { stickiness } = feature.variants[0];
const { stickiness } = variants[0];
const target = normalizedValue(
getSeed(context, stickiness),
feature.name,
featureName,
totalWeight,
);
let counter = 0;
const variant = feature.variants.find(
const variant = variants.find(
(v: VariantDefinition): VariantDefinition | undefined => {
if (v.weight === 0) {
return undefined;
@ -112,3 +112,10 @@ export function selectVariant(
);
return variant || null;
}
export function selectVariant(
feature: FeatureInterface,
context: Context,
): VariantDefinition | null {
return selectVariantDefinition(feature.name, feature.variants, context);
}

View File

@ -250,6 +250,77 @@ describe('offline client', () => {
expect(client.isEnabled(name, {}).result).toBeTruthy();
});
it('returns strategy variant over feature variant', async () => {
const name = 'toggle-name';
const client = await offlineUnleashClient({
features: [
{
strategies: [
{
name: 'default',
constraints: [
{
values: ['my-app-name'],
inverted: false,
operator: 'IN' as 'IN',
contextName: 'appName',
caseInsensitive: false,
},
],
variants: [
{
name: 'ignoreNonMatchingStrategyVariant',
weightType: 'variable',
weight: 1000,
stickiness: 'default',
},
],
},
{
name: 'default',
constraints: [
{
values: ['client-test'],
inverted: false,
operator: 'IN' as 'IN',
contextName: 'appName',
caseInsensitive: false,
},
],
variants: [
{
name: 'strategyVariant',
weightType: 'variable',
weight: 1000,
stickiness: 'default',
},
],
},
],
project: 'default',
stale: false,
enabled: true,
name,
type: 'experiment',
variants: [
{
name: 'ignoreFeatureStrategyVariant',
weightType: 'variable',
weight: 1000,
stickiness: 'default',
},
],
},
],
context: { appName: 'client-test' },
logError: console.log,
});
expect(client.getVariant(name, {}).name).toEqual('strategyVariant');
expect(client.getVariant(name, {}).enabled).toBeTruthy();
expect(client.isEnabled(name, {}).result).toBeTruthy();
});
it(`returns '${playgroundStrategyEvaluation.unknownResult}' if it can't evaluate a feature`, async () => {
const name = 'toggle-name';
const context = { appName: 'client-test' };

View File

@ -28,6 +28,13 @@ export const mapFeaturesForClient = (
strategies: feature.strategies.map((strategy) => ({
parameters: {},
...strategy,
variants: (strategy.variants || []).map((variant) => ({
...variant,
payload: variant.payload && {
...variant.payload,
type: variant.payload.type as PayloadType,
},
})),
constraints:
strategy.constraints &&
strategy.constraints.map((constraint) => ({

View File

@ -180,7 +180,10 @@ export class PlaygroundService {
name: feature.name,
environment,
context,
variants: variantsMap[feature.name] || [],
variants:
strategyEvaluationResult.variants ||
variantsMap[feature.name] ||
[],
};
});
}

View File

@ -20,6 +20,19 @@ exports[`featureSchema constraints 1`] = `
exports[`featureSchema variant override values must be an array 1`] = `
{
"errors": [
{
"instancePath": "/variants/0/payload/type",
"keyword": "enum",
"message": "must be equal to one of the allowed values",
"params": {
"allowedValues": [
"json",
"csv",
"string",
],
},
"schemaPath": "#/properties/payload/properties/type/enum",
},
{
"instancePath": "/variants/0/overrides/0/values",
"keyword": "type",

View File

@ -93,7 +93,7 @@ export const advancedPlaygroundEnvironmentFeatureSchema = {
description: `The feature variant you receive based on the provided context or the _disabled
variant_. If a feature is disabled or doesn't have any
variants, you would get the _disabled variant_.
Otherwise, you'll get one of thefeature's defined variants.`,
Otherwise, you'll get one of the feature's defined variants.`,
type: 'object',
additionalProperties: false,
required: ['name', 'enabled'],
@ -118,7 +118,6 @@ export const advancedPlaygroundEnvironmentFeatureSchema = {
type: {
description: 'The format of the payload.',
type: 'string',
enum: ['json', 'csv', 'string'],
},
value: {
type: 'string',

View File

@ -23,7 +23,7 @@ test('clientFeaturesSchema required fields', () => {
weightType: 'fix',
stickiness: 'c',
payload: {
type: 'a',
type: 'string',
value: 'b',
},
overrides: [

View File

@ -43,6 +43,7 @@ export const createStrategyVariantSchema = {
description:
'The type of the value. Commonly used types are string, json and csv.',
type: 'string',
enum: ['json', 'csv', 'string'],
},
value: {
description: 'The actual value of payload',

View File

@ -11,7 +11,7 @@ test('featureSchema', () => {
weightType: 'fix',
stickiness: 'a',
overrides: [{ contextName: 'a', values: ['a'] }],
payload: { type: 'a', value: 'b' },
payload: { type: 'string', value: 'b' },
},
],
environments: [

View File

@ -108,7 +108,6 @@ export const playgroundFeatureSchema = {
type: {
description: 'The format of the payload.',
type: 'string',
enum: ['json', 'csv', 'string'],
},
value: {
type: 'string',

View File

@ -2,6 +2,8 @@ import { FromSchema } from 'json-schema-to-ts';
import { parametersSchema } from './parameters-schema';
import { playgroundConstraintSchema } from './playground-constraint-schema';
import { playgroundSegmentSchema } from './playground-segment-schema';
import { variantSchema } from './variant-schema';
import { overrideSchema } from './override-schema';
export const playgroundStrategyEvaluation = {
evaluationComplete: 'complete',
@ -51,6 +53,55 @@ export const strategyEvaluationResults = {
description:
'Whether this strategy evaluates to true or not.',
},
variant: {
description: `The feature variant you receive based on the provided context or the _disabled
variant_. If a feature is disabled or doesn't have any
variants, you would get the _disabled variant_.
Otherwise, you'll get one of the feature's defined variants.`,
type: 'object',
additionalProperties: false,
required: ['name', 'enabled'],
properties: {
name: {
type: 'string',
description:
"The variant's name. If there is no variant or if the toggle is disabled, this will be `disabled`",
example: 'red-variant',
},
enabled: {
type: 'boolean',
description:
"Whether the variant is enabled or not. If the feature is disabled or if it doesn't have variants, this property will be `false`",
},
payload: {
type: 'object',
additionalProperties: false,
required: ['type', 'value'],
description:
'An optional payload attached to the variant.',
properties: {
type: {
description: 'The format of the payload.',
type: 'string',
enum: ['json', 'csv', 'string'],
},
value: {
type: 'string',
description:
'The payload value stringified.',
example: '{"property": "value"}',
},
},
},
},
nullable: true,
example: { name: 'green', enabled: true },
},
variants: {
type: 'array',
description: 'The feature variants.',
items: { $ref: variantSchema.$id },
},
},
},
],
@ -139,6 +190,8 @@ export const playgroundStrategySchema = {
playgroundConstraintSchema,
playgroundSegmentSchema,
parametersSchema,
variantSchema,
overrideSchema,
},
},
} as const;

View File

@ -1,5 +1,4 @@
import { FromSchema } from 'json-schema-to-ts';
import { PayloadType } from 'unleash-client';
export const proxyFeatureSchema = {
$id: '#/components/schemas/proxyFeatureSchema',
@ -51,7 +50,7 @@ export const proxyFeatureSchema = {
type: {
type: 'string',
description: 'The format of the payload.',
enum: Object.values(PayloadType),
enum: ['json', 'csv', 'string'],
},
value: {
type: 'string',

View File

@ -38,11 +38,13 @@ export const variantSchema = {
type: 'object',
required: ['type', 'value'],
description: 'Extra data configured for this variant',
additionalProperties: false,
properties: {
type: {
description:
'The type of the value. Commonly used types are string, json and csv.',
type: 'string',
enum: ['json', 'csv', 'string'],
},
value: {
description: 'The actual value of payload',

View File

@ -122,7 +122,7 @@ export interface IVariant {
weight: number;
weightType: 'variable' | 'fix';
payload?: {
type: string;
type: 'json' | 'csv' | 'string';
value: string;
};
stickiness: string;

View File

@ -7509,15 +7509,15 @@ universalify@^0.1.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
unleash-client@3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.21.0.tgz#a31ab30acb42abfb3a21180aa83e4415a3124ec1"
integrity sha512-I7eYhRyOia3oBZ9Tu1v+IlNO+XJgsjcMEO2+j+e4A7LTTKZvGoV8WPfDGGxiMPKBPHNUACkERB3YhCQ9jzTGoQ==
unleash-client@4.1.0-beta.5:
version "4.1.0-beta.5"
resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-4.1.0-beta.5.tgz#7407a9dae30411cb2cb849569a6e058cf6b6c47c"
integrity sha512-aN5PdvfAlVBc7Fm5cgQr7pc2j6rvbRtp6G9kow0O3FP4h3UCFbM2i0NvSB4r3F8AysXWonbv9IB/TyAC2CGsPA==
dependencies:
ip "^1.1.8"
make-fetch-happen "^10.2.1"
murmurhash3js "^3.0.1"
semver "^7.3.8"
semver "^7.5.3"
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"