diff --git a/packages/sdk/openfeature-node-server/README.md b/packages/sdk/openfeature-node-server/README.md index 67af9e1b7e..4b9785c976 100644 --- a/packages/sdk/openfeature-node-server/README.md +++ b/packages/sdk/openfeature-node-server/README.md @@ -69,6 +69,24 @@ There are several attributes with special handling within a single or multi-cont - `anonymous` - Must be a boolean. Equivalent to `anonymous` in the SDK. - `name` - Must be a string. Equivalent to `name` in the SDK. +### Flag Metadata + +Evaluation results include LaunchDarkly specific information in the OpenFeature flag metadata. A key is only present when it applies to the evaluation. + +| Key | Type | Description | +|---|---|---| +| `variationIndex` | number | The index of the served variation. Absent when the default value was returned. | +| `inExperiment` | boolean | Present, and `true`, when the evaluation was part of an experiment. | +| `ruleIndex` | number | The index of the matched rule, for a `RULE_MATCH` reason. | +| `ruleId` | string | The identifier of the matched rule, for a `RULE_MATCH` reason. | +| `prerequisiteKey` | string | The key of the failed prerequisite flag, for a `PREREQUISITE_FAILED` reason. | +| `bigSegmentsStatus` | string | The status of the Big Segment query, when the evaluation required one. | + +```typescript +const details = await client.getBooleanDetails('my-boolean-flag', false); +const inExperiment = details.flagMetadata.inExperiment === true; +``` + ### Examples #### A single user context diff --git a/packages/shared/openfeature-server-common/__tests__/BaseOpenFeatureProvider.test.ts b/packages/shared/openfeature-server-common/__tests__/BaseOpenFeatureProvider.test.ts index 3080053be4..389eaad975 100644 --- a/packages/shared/openfeature-server-common/__tests__/BaseOpenFeatureProvider.test.ts +++ b/packages/shared/openfeature-server-common/__tests__/BaseOpenFeatureProvider.test.ts @@ -83,7 +83,12 @@ it('resolveBooleanEvaluation returns the translated result for boolean flags', a { kind: 'user', key: 'u' }, false, ); - expect(result).toEqual({ value: true, variant: '1', reason: 'OFF' }); + expect(result).toEqual({ + value: true, + variant: '1', + reason: 'OFF', + flagMetadata: { variationIndex: 1 }, + }); }); it('resolveStringEvaluation returns the translated result for string flags', async () => { @@ -97,7 +102,12 @@ it('resolveStringEvaluation returns the translated result for string flags', asy const result = await provider.resolveStringEvaluation('flag', 'red', { targetingKey: 'u' }); - expect(result).toEqual({ value: 'green', variant: '0', reason: 'FALLTHROUGH' }); + expect(result).toEqual({ + value: 'green', + variant: '0', + reason: 'FALLTHROUGH', + flagMetadata: { variationIndex: 0 }, + }); }); it('resolveNumberEvaluation returns the translated result for number flags', async () => { @@ -111,7 +121,12 @@ it('resolveNumberEvaluation returns the translated result for number flags', asy const result = await provider.resolveNumberEvaluation('flag', 0, { targetingKey: 'u' }); - expect(result).toEqual({ value: 42, variant: '2', reason: 'TARGET_MATCH' }); + expect(result).toEqual({ + value: 42, + variant: '2', + reason: 'TARGET_MATCH', + flagMetadata: { variationIndex: 2 }, + }); }); it('resolveObjectEvaluation returns the translated result for object flags', async () => { @@ -129,7 +144,12 @@ it('resolveObjectEvaluation returns the translated result for object flags', asy { targetingKey: 'u' }, ); - expect(result).toEqual({ value: { a: 1 }, variant: '3', reason: 'RULE_MATCH' }); + expect(result).toEqual({ + value: { a: 1 }, + variant: '3', + reason: 'RULE_MATCH', + flagMetadata: { variationIndex: 3 }, + }); }); it.each([ @@ -328,6 +348,11 @@ it('wraps a host logger that throws so flag evaluation does not crash', async () targetingKey: 'u', kind: 42 as unknown as string, }), - ).resolves.toEqual({ value: true, variant: '0', reason: 'OFF' }); + ).resolves.toEqual({ + value: true, + variant: '0', + reason: 'OFF', + flagMetadata: { variationIndex: 0 }, + }); }); diff --git a/packages/shared/openfeature-server-common/__tests__/translateResult.test.ts b/packages/shared/openfeature-server-common/__tests__/translateResult.test.ts index 521aad8645..c5db3b83ec 100644 --- a/packages/shared/openfeature-server-common/__tests__/translateResult.test.ts +++ b/packages/shared/openfeature-server-common/__tests__/translateResult.test.ts @@ -60,3 +60,73 @@ it('does populate the errorCode when there is an error', () => { }); expect(translated.errorCode).toEqual('GENERAL'); }); + +it('includes the variation index in the flag metadata', () => { + expect( + translateResult({ + value: true, + variationIndex: 9, + reason: { kind: 'FALLTHROUGH' }, + }).flagMetadata, + ).toEqual({ variationIndex: 9 }); +}); + +it('omits the variation index from the flag metadata when there is no variation', () => { + expect( + translateResult({ + value: true, + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + }).flagMetadata, + ).toEqual({}); +}); + +it('includes inExperiment in the flag metadata for experiment evaluations', () => { + expect( + translateResult({ + value: true, + variationIndex: 9, + reason: { kind: 'FALLTHROUGH', inExperiment: true }, + }).flagMetadata, + ).toEqual({ variationIndex: 9, inExperiment: true }); +}); + +it('omits inExperiment from the flag metadata for non-experiment evaluations', () => { + expect( + translateResult({ + value: true, + variationIndex: 9, + reason: { kind: 'FALLTHROUGH', inExperiment: false }, + }).flagMetadata, + ).toEqual({ variationIndex: 9 }); +}); + +it('includes the rule in the flag metadata for rule matches', () => { + expect( + translateResult({ + value: true, + variationIndex: 9, + reason: { kind: 'RULE_MATCH', ruleIndex: 2, ruleId: 'the-rule-id' }, + }).flagMetadata, + ).toEqual({ variationIndex: 9, ruleIndex: 2, ruleId: 'the-rule-id' }); +}); + +it('includes the prerequisite key in the flag metadata for failed prerequisites', () => { + expect( + translateResult({ + value: true, + variationIndex: 9, + reason: { kind: 'PREREQUISITE_FAILED', prerequisiteKey: 'the-prerequisite-key' }, + }).flagMetadata, + ).toEqual({ variationIndex: 9, prerequisiteKey: 'the-prerequisite-key' }); +}); + +it('includes the big segments status in the flag metadata', () => { + expect( + translateResult({ + value: true, + variationIndex: 9, + reason: { kind: 'FALLTHROUGH', bigSegmentsStatus: 'STALE' }, + }).flagMetadata, + ).toEqual({ variationIndex: 9, bigSegmentsStatus: 'STALE' }); +}); diff --git a/packages/shared/openfeature-server-common/src/translateResult.ts b/packages/shared/openfeature-server-common/src/translateResult.ts index bc327ae2c3..b9ca2b046a 100644 --- a/packages/shared/openfeature-server-common/src/translateResult.ts +++ b/packages/shared/openfeature-server-common/src/translateResult.ts @@ -1,8 +1,15 @@ import { ErrorCode } from '@openfeature/server-sdk'; -import type { ResolutionDetails } from '@openfeature/server-sdk'; +import type { FlagMetadata, ResolutionDetails } from '@openfeature/server-sdk'; import type { LDEvaluationDetail } from '@launchdarkly/js-sdk-common'; +const VARIATION_INDEX_KEY = 'variationIndex'; +const IN_EXPERIMENT_KEY = 'inExperiment'; +const RULE_INDEX_KEY = 'ruleIndex'; +const RULE_ID_KEY = 'ruleId'; +const PREREQUISITE_KEY_KEY = 'prerequisiteKey'; +const BIG_SEGMENTS_STATUS_KEY = 'bigSegmentsStatus'; + /** * Convert an `errorKind` into an OpenFeature `errorCode`. */ @@ -21,6 +28,32 @@ function translateErrorKind(errorKind: string | undefined): ErrorCode { } } +/** + * Convert the LaunchDarkly specific parts of an evaluation into OpenFeature flag metadata. + */ +function translateFlagMetadata(result: LDEvaluationDetail): FlagMetadata { + const metadata: FlagMetadata = {}; + if (result.variationIndex !== undefined && result.variationIndex !== null) { + metadata[VARIATION_INDEX_KEY] = result.variationIndex; + } + if (result.reason.inExperiment) { + metadata[IN_EXPERIMENT_KEY] = true; + } + if (result.reason.ruleIndex !== undefined) { + metadata[RULE_INDEX_KEY] = result.reason.ruleIndex; + } + if (result.reason.ruleId !== undefined) { + metadata[RULE_ID_KEY] = result.reason.ruleId; + } + if (result.reason.prerequisiteKey !== undefined) { + metadata[PREREQUISITE_KEY_KEY] = result.reason.prerequisiteKey; + } + if (result.reason.bigSegmentsStatus !== undefined) { + metadata[BIG_SEGMENTS_STATUS_KEY] = result.reason.bigSegmentsStatus; + } + return metadata; +} + /** * Translate an {@link LDEvaluationDetail} to a {@link ResolutionDetails}. * @@ -30,6 +63,7 @@ export function translateResult(result: LDEvaluationDetail): ResolutionDetail value: result.value, variant: result.variationIndex?.toString(), reason: result.reason.kind, + flagMetadata: translateFlagMetadata(result), }; if (result.reason.kind === 'ERROR') {