diff --git a/packages/shared/common/src/api/data/LDEvaluationDetail.ts b/packages/shared/common/src/api/data/LDEvaluationDetail.ts index 84f08d5d4..6cc6f22a1 100644 --- a/packages/shared/common/src/api/data/LDEvaluationDetail.ts +++ b/packages/shared/common/src/api/data/LDEvaluationDetail.ts @@ -28,6 +28,11 @@ export interface LDEvaluationDetail { * An object describing the main factor that influenced the flag evaluation value. */ reason: LDEvaluationReason; + + /** + * An ordered list of prerequisite flag keys evaluated while determining the flags value. + */ + prerequisites?: string[]; } export interface LDEvaluationDetailTyped { @@ -47,4 +52,9 @@ export interface LDEvaluationDetailTyped { * An object describing the main factor that influenced the flag evaluation value. */ reason: LDEvaluationReason; + + /** + * An ordered list of prerequisite flag keys evaluated while determining the flags value. + */ + prerequisites?: string[]; } diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts index ca7b006e0..f9e42e1af 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.events.test.ts @@ -199,4 +199,45 @@ describe('sdk-client object', () => { expect.stringMatching(/was called with a non-numeric/), ); }); + + it('sends events for prerequisite flags', async () => { + await ldc.identify({ kind: 'user', key: 'bob' }); + ldc.variation('has-prereq-depth-1', false); + ldc.flush(); + + // Prerequisite evaluation event should be emitted before the evaluation event for the flag + // being evaluated. + expect(mockedSendEvent).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + context: expect.anything(), + creationDate: expect.any(Number), + default: undefined, + key: 'is-prereq', + kind: 'feature', + samplingRatio: 1, + trackEvents: true, + value: true, + variation: 0, + version: 1, + withReasons: false, + }), + ); + expect(mockedSendEvent).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + context: expect.anything(), + creationDate: expect.any(Number), + default: false, + key: 'has-prereq-depth-1', + kind: 'feature', + samplingRatio: 1, + trackEvents: true, + value: true, + variation: 0, + version: 4, + withReasons: false, + }), + ); + }); }); diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts index 4fe6fe50f..e46d06a53 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.storage.test.ts @@ -104,6 +104,8 @@ describe('sdk-client storage', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', @@ -156,6 +158,8 @@ describe('sdk-client storage', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', @@ -218,6 +222,8 @@ describe('sdk-client storage', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', @@ -388,6 +394,8 @@ describe('sdk-client storage', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', @@ -517,6 +525,8 @@ describe('sdk-client storage', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts index f6308e9cb..cdbe32cb2 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.test.ts @@ -103,6 +103,8 @@ describe('sdk-client object', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', diff --git a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts index 51a1503e7..f4f5ca70b 100644 --- a/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts +++ b/packages/shared/sdk-client/__tests__/LDClientImpl.timeout.test.ts @@ -89,6 +89,8 @@ describe('sdk-client identify timeout', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', @@ -112,6 +114,8 @@ describe('sdk-client identify timeout', () => { 'easter-i-tunes-special': false, 'easter-specials': 'no specials', fdsafdsafdsafdsa: true, + 'has-prereq-depth-1': true, + 'is-prereq': true, 'log-level': 'warn', 'moonshot-demo': true, test1: 's1', diff --git a/packages/shared/sdk-client/__tests__/evaluation/mockResponse.json b/packages/shared/sdk-client/__tests__/evaluation/mockResponse.json index d8f8eb5ea..10c3b4882 100644 --- a/packages/shared/sdk-client/__tests__/evaluation/mockResponse.json +++ b/packages/shared/sdk-client/__tests__/evaluation/mockResponse.json @@ -54,5 +54,24 @@ "value": true, "variation": 0, "trackEvents": false + }, + "is-prereq": { + "value": true, + "variation": 0, + "reason": { + "kind": "FALLTHROUGH" + }, + "version": 1, + "trackEvents": true + }, + "has-prereq-depth-1": { + "value": true, + "variation": 0, + "prerequisites": ["is-prereq"], + "reason": { + "kind": "FALLTHROUGH" + }, + "version": 4, + "trackEvents": true } } diff --git a/packages/shared/sdk-client/src/LDClientImpl.ts b/packages/shared/sdk-client/src/LDClientImpl.ts index 7802bbbcf..f6867a6cd 100644 --- a/packages/shared/sdk-client/src/LDClientImpl.ts +++ b/packages/shared/sdk-client/src/LDClientImpl.ts @@ -301,7 +301,7 @@ export default class LDClientImpl implements LDClient { return createErrorEvaluationDetail(ErrorKinds.FlagNotFound, defaultValue); } - const { reason, value, variation } = foundItem.flag; + const { reason, value, variation, prerequisites } = foundItem.flag; if (typeChecker) { const [matched, type] = typeChecker(value); @@ -324,11 +324,27 @@ export default class LDClientImpl implements LDClient { } } - const successDetail = createSuccessEvaluationDetail(value, variation, reason); + const successDetail = createSuccessEvaluationDetail(value, variation, reason, prerequisites); if (value === undefined || value === null) { this.logger.debug('Result value is null. Providing default value.'); successDetail.value = defaultValue; } + + successDetail.prerequisites?.forEach((prereqKey) => { + const prereqFlag = this.flagManager.get(prereqKey); + if (prereqFlag) { + this.eventProcessor?.sendEvent( + eventFactory.evalEventClient( + prereqKey, + prereqFlag.flag.value, + undefined, + prereqFlag.flag, + evalContext, + prereqFlag.flag.reason, + ), + ); + } + }); this.eventProcessor?.sendEvent( eventFactory.evalEventClient( flagKey, diff --git a/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts b/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts index f0e8d741c..cb94cae82 100644 --- a/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts +++ b/packages/shared/sdk-client/src/evaluation/evaluationDetail.ts @@ -17,10 +17,12 @@ export function createSuccessEvaluationDetail( value: LDFlagValue, variationIndex?: number, reason?: LDEvaluationReason, + prerequisites?: string[], ): LDEvaluationDetail { return { value, variationIndex: variationIndex ?? null, reason: reason ?? null, + prerequisites, }; } diff --git a/packages/shared/sdk-client/src/types/index.ts b/packages/shared/sdk-client/src/types/index.ts index 18b24736d..947c72832 100644 --- a/packages/shared/sdk-client/src/types/index.ts +++ b/packages/shared/sdk-client/src/types/index.ts @@ -10,6 +10,7 @@ export interface Flag { reason?: LDEvaluationReason; debugEventsUntilDate?: number; deleted?: boolean; + prerequisites?: string[]; } export interface PatchFlag extends Flag { diff --git a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts index 585c6a9b5..830f25100 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.allFlags.test.ts @@ -268,6 +268,60 @@ describe('given an LDClient with test data', () => { done(); }); }); + + it('includes prerequisites in flag meta', async () => { + await td.update(td.flag('is-prereq').valueForAll(true)); + await td.usePreconfiguredFlag({ + key: 'has-prereq-depth-1', + on: true, + prerequisites: [ + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 4, + }); + + const state = await client.allFlagsState(defaultUser, { + withReasons: true, + detailsOnlyForTrackedFlags: false, + }); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ 'is-prereq': true, 'has-prereq-depth-1': true }); + expect(state.toJSON()).toEqual({ + 'is-prereq': true, + 'has-prereq-depth-1': true, + $flagsState: { + 'is-prereq': { + variation: 0, + reason: { + kind: 'FALLTHROUGH', + }, + version: 1, + }, + 'has-prereq-depth-1': { + variation: 0, + prerequisites: ['is-prereq'], + reason: { + kind: 'FALLTHROUGH', + }, + version: 4, + }, + }, + $valid: true, + }); + }); }); describe('given an offline client', () => { diff --git a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts index a57dc8599..5e6f2b878 100644 --- a/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts +++ b/packages/shared/sdk-server/__tests__/LDClient.evaluation.test.ts @@ -253,6 +253,75 @@ describe('given an LDClient with test data', () => { expect(res.reason.kind).toEqual('ERROR'); expect(res.reason.errorKind).toEqual('WRONG_TYPE'); }); + + it('includes prerequisite information for flags with prerequisites', async () => { + await td.update(td.flag('is-prereq').valueForAll(true)); + await td.usePreconfiguredFlag({ + key: 'has-prereq-depth-1', + on: true, + prerequisites: [ + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 4, + }); + + const res = await client.variationDetail('has-prereq-depth-1', defaultUser, false); + expect(res.value).toEqual(true); + expect(res.reason.kind).toEqual('FALLTHROUGH'); + expect(res.prerequisites).toEqual(['is-prereq']); + }); + + it.each([ + ['boolVariationDetail', true, false], + ['numberVariationDetail', 42, 3.14], + ['stringVariationDetail', 'value', 'default'], + ['jsonVariationDetail', { value: 'value' }, { value: 'default' }], + ])( + 'includes prerequisite information for typed evals', + async (method: string, value: any, def: any) => { + await td.update(td.flag('is-prereq').valueForAll(true)); + await td.usePreconfiguredFlag({ + key: 'has-prereq-depth-1', + on: true, + prerequisites: [ + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [value, def], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 4, + }); + + // @ts-ignore Typescript cannot infer the matching method types. + const res = await client[method]('has-prereq-depth-1', defaultUser, def); + expect(res.value).toEqual(value); + expect(res.reason.kind).toEqual('FALLTHROUGH'); + expect(res.prerequisites).toEqual(['is-prereq']); + }, + ); }); describe('given an offline client', () => { diff --git a/packages/shared/sdk-server/__tests__/evaluation/Evaluator.prerequisite.test.ts b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.prerequisite.test.ts new file mode 100644 index 000000000..f86e6bfed --- /dev/null +++ b/packages/shared/sdk-server/__tests__/evaluation/Evaluator.prerequisite.test.ts @@ -0,0 +1,188 @@ +import { Context, deserializePoll } from '../../src'; +import { BigSegmentStoreMembership } from '../../src/api/interfaces'; +import { Flag } from '../../src/evaluation/data/Flag'; +import { Segment } from '../../src/evaluation/data/Segment'; +import Evaluator from '../../src/evaluation/Evaluator'; +import { Queries } from '../../src/evaluation/Queries'; +import EventFactory from '../../src/events/EventFactory'; +import { FlagsAndSegments } from '../../src/store/serialization'; +import { createBasicPlatform } from '../createBasicPlatform'; + +describe('given a flag payload with prerequisites', () => { + let evaluator: Evaluator; + const basePayload = { + segments: {}, + flags: { + 'has-prereq-depth-1': { + key: 'has-prereq-depth-1', + on: true, + prerequisites: [ + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 4, + }, + 'has-prereq-depth-2': { + key: 'has-prereq-depth-2', + on: true, + prerequisites: [ + { + key: 'has-prereq-depth-1', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 3, + }, + 'has-prereq-depth-3': { + key: 'has-prereq-depth-3', + on: true, + prerequisites: [ + { + key: 'has-prereq-depth-1', + variation: 0, + }, + { + key: 'has-prereq-depth-2', + variation: 0, + }, + { + key: 'is-prereq', + variation: 0, + }, + ], + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 3, + }, + 'is-prereq': { + key: 'is-prereq', + on: true, + fallthrough: { + variation: 0, + }, + offVariation: 1, + variations: [true, false], + clientSideAvailability: { + usingMobileKey: true, + usingEnvironmentId: true, + }, + clientSide: true, + version: 3, + }, + }, + }; + + let testPayload: FlagsAndSegments; + + class TestQueries implements Queries { + constructor(private readonly data: FlagsAndSegments) {} + + getFlag(key: string, cb: (flag: Flag | undefined) => void): void { + const res = this.data.flags[key]; + cb(res); + } + + getSegment(key: string, cb: (segment: Segment | undefined) => void): void { + const res = this.data.segments[key]; + cb(res); + } + + getBigSegmentsMembership( + _userKey: string, + ): Promise<[BigSegmentStoreMembership | null, string] | undefined> { + throw new Error('Method not implemented.'); + } + } + + beforeEach(() => { + testPayload = deserializePoll(JSON.stringify(basePayload))!; + evaluator = new Evaluator(createBasicPlatform(), new TestQueries(testPayload!)); + }); + + it('can track prerequisites for a basic prereq', async () => { + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-1']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('FALLTHROUGH'); + + expect(res.detail.prerequisites).toEqual(['is-prereq']); + }); + + it('can track prerequisites for a prereq of a prereq', async () => { + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-2']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('FALLTHROUGH'); + + expect(res.detail.prerequisites).toEqual(['is-prereq', 'has-prereq-depth-1']); + }); + + it('can track prerequisites for a flag with multiple prereqs with and without additional prereqs', async () => { + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-3']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('FALLTHROUGH'); + + expect(res.detail.prerequisites).toEqual([ + 'is-prereq', + 'has-prereq-depth-1', + 'is-prereq', + 'has-prereq-depth-1', + 'has-prereq-depth-2', + 'is-prereq', + ]); + }); + + it('has can handle a prerequisite failure', async () => { + testPayload.flags['is-prereq'].on = false; + const res = await evaluator.evaluate( + testPayload?.flags['has-prereq-depth-3']!, + Context.fromLDContext({ kind: 'user', key: 'bob' }), + new EventFactory(true), + ); + + expect(res.detail.reason.kind).toEqual('PREREQUISITE_FAILED'); + expect(res.detail.reason.prerequisiteKey).toEqual('has-prereq-depth-1'); + + expect(res.detail.prerequisites).toEqual(['is-prereq', 'has-prereq-depth-1']); + }); +}); diff --git a/packages/shared/sdk-server/src/FlagsStateBuilder.ts b/packages/shared/sdk-server/src/FlagsStateBuilder.ts index 86bbc23bb..9c16dfdd1 100644 --- a/packages/shared/sdk-server/src/FlagsStateBuilder.ts +++ b/packages/shared/sdk-server/src/FlagsStateBuilder.ts @@ -10,6 +10,7 @@ interface FlagMeta { trackEvents?: boolean; trackReason?: boolean; debugEventsUntilDate?: number; + prerequisites?: string[]; } export default class FlagsStateBuilder { @@ -30,6 +31,7 @@ export default class FlagsStateBuilder { trackEvents: boolean, trackReason: boolean, detailsOnlyIfTracked: boolean, + prerequisites?: string[], ) { this.flagValues[flag.key] = value; const meta: FlagMeta = {}; @@ -56,6 +58,9 @@ export default class FlagsStateBuilder { if (flag.debugEventsUntilDate !== undefined) { meta.debugEventsUntilDate = flag.debugEventsUntilDate; } + if (prerequisites && prerequisites.length) { + meta.prerequisites = prerequisites; + } this.flagMetadata[flag.key] = meta; } diff --git a/packages/shared/sdk-server/src/LDClientImpl.ts b/packages/shared/sdk-server/src/LDClientImpl.ts index 34d6f02a5..b17aee047 100644 --- a/packages/shared/sdk-server/src/LDClientImpl.ts +++ b/packages/shared/sdk-server/src/LDClientImpl.ts @@ -392,6 +392,7 @@ export default class LDClientImpl implements LDClient { value: res.detail.value as TResult, reason: res.detail.reason, variationIndex: res.detail.variationIndex, + prerequisites: res.detail.prerequisites, }; resolve(typedRes); }, @@ -655,6 +656,7 @@ export default class LDClientImpl implements LDClient { flag.trackEvents || requireExperimentData, requireExperimentData, detailsOnlyIfTracked, + res.detail.prerequisites, ); iterCb(true); }); diff --git a/packages/shared/sdk-server/src/evaluation/Evaluator.ts b/packages/shared/sdk-server/src/evaluation/Evaluator.ts index ef95b9eee..d83cd05cd 100644 --- a/packages/shared/sdk-server/src/evaluation/Evaluator.ts +++ b/packages/shared/sdk-server/src/evaluation/Evaluator.ts @@ -68,6 +68,7 @@ function computeUpdatedBigSegmentsStatus( interface EvalState { events?: internal.InputEvalEvent[]; + prerequisites?: string[]; bigSegmentsStatus?: BigSegmentStoreStatusString; @@ -116,24 +117,7 @@ export default class Evaluator { async evaluate(flag: Flag, context: Context, eventFactory?: EventFactory): Promise { return new Promise((resolve) => { - const state: EvalState = {}; - this.evaluateInternal( - flag, - context, - state, - [], - (res) => { - if (state.bigSegmentsStatus) { - res.detail.reason = { - ...res.detail.reason, - bigSegmentsStatus: state.bigSegmentsStatus, - }; - } - res.events = state.events; - resolve(res); - }, - eventFactory, - ); + this.evaluateCb(flag, context, resolve, eventFactory); }); } @@ -156,6 +140,9 @@ export default class Evaluator { bigSegmentsStatus: state.bigSegmentsStatus, }; } + if (state.prerequisites) { + res.detail.prerequisites = state.prerequisites; + } res.events = state.events; cb(res); }, @@ -273,7 +260,10 @@ export default class Evaluator { (res) => { // eslint-disable-next-line no-param-reassign state.events ??= []; + // eslint-disable-next-line no-param-reassign + state.prerequisites ??= []; + state.prerequisites.push(prereqFlag.key); if (eventFactory) { state.events.push( eventFactory.evalEventServer(prereqFlag, context, res.detail, null, flag),