diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 62284cc40..003c02189 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -10,7 +10,12 @@ import type { DdNativeFlagsType } from '../nativeModulesTypes'; import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; -import type { JsonValue, EvaluationContext, FlagDetails } from './types'; +import type { + JsonValue, + EvaluationContext, + FlagDetails, + PrimitiveValue +} from './types'; export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -143,7 +148,8 @@ export class FlagsClient { value: flag.value as T, variant: flag.variationKey, allocationKey: flag.allocationKey, - reason: flag.reason + reason: flag.reason, + extraLogging: buildExtraLogging(flag.extraLogging) }; return details; @@ -264,3 +270,27 @@ export class FlagsClient { return this.getObjectDetails(key, defaultValue).value; }; } + +const buildExtraLogging = ( + raw: Record +): Record | undefined => { + if (!raw || Object.keys(raw).length === 0) { + return undefined; + } + + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (key === 'allocationKey') { + continue; // typed field wins + } + if ( + typeof value === 'boolean' || + typeof value === 'string' || + typeof value === 'number' || + value === null + ) { + result[key] = value as PrimitiveValue; + } + } + return Object.keys(result).length > 0 ? result : undefined; +}; diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 06903099c..ed5882898 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -58,6 +58,34 @@ jest.spyOn(NativeModules.DdFlags, 'setEvaluationContext').mockResolvedValue({ variationType: '', variationValue: '', extraLogging: {} + }, + 'test-flag-with-extra-logging': { + key: 'test-flag-with-extra-logging', + value: true, + allocationKey: 'alloc-abc', + variationKey: 'true', + reason: 'TARGETED', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: { + campaignId: 'camp-123', + score: 42, + eligible: true, + allocationKey: 'should-be-skipped', + nestedObj: { foo: 'bar' } + } + }, + 'test-flag-empty-extra-logging': { + key: 'test-flag-empty-extra-logging', + value: 'green', + allocationKey: '', + variationKey: 'green', + reason: 'STATIC', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: {} } }); @@ -206,6 +234,44 @@ describe('FlagsClient', () => { }); }); + it('should include extraLogging primitives (excluding allocationKey key) in details', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + const details = flagsClient.getBooleanDetails( + 'test-flag-with-extra-logging', + false + ); + + expect(details.extraLogging).toEqual({ + campaignId: 'camp-123', + score: 42, + eligible: true + // 'allocationKey' key is excluded (typed field wins) + // nestedObj is excluded (non-primitive) + }); + // allocationKey field itself is still populated + expect(details.allocationKey).toBe('alloc-abc'); + }); + + it('should return undefined extraLogging when extraLogging cache entry is empty', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + const details = flagsClient.getStringDetails( + 'test-flag-empty-extra-logging', + 'default' + ); + + expect(details.extraLogging).toBeUndefined(); + }); + it('should return TYPE_MISMATCH when using wrong typed accessor method', async () => { // Flag values are mocked in the __mocks__/react-native.ts file. const flagsClient = DdFlags.getClient(); diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 12bfb18f6..6310cbc51 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -204,6 +204,12 @@ export interface FlagDetails { * Useful for debugging targeting rules. */ allocationKey?: string; + /** + * Extra logging metadata from the flag assignment. + * Contains only primitive values (boolean, string, number, null). + * Useful for debugging and analytics. + */ + extraLogging?: Record; /** * Code of the error that occurred during evaluation, if any. */ diff --git a/packages/react-native-openfeature/__mocks__/react-native.ts b/packages/react-native-openfeature/__mocks__/react-native.ts new file mode 100644 index 000000000..855d5a39c --- /dev/null +++ b/packages/react-native-openfeature/__mocks__/react-native.ts @@ -0,0 +1,16 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const actualRN = require('react-native'); + +actualRN.NativeModules.DdFlags = { + enable: jest.fn(() => Promise.resolve()), + setEvaluationContext: jest.fn(() => Promise.resolve({})), + trackEvaluation: jest.fn(() => Promise.resolve()) +}; + +module.exports = actualRN; diff --git a/packages/react-native-openfeature/src/__tests__/provider.test.ts b/packages/react-native-openfeature/src/__tests__/provider.test.ts new file mode 100644 index 000000000..7a4d9f4ea --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/provider.test.ts @@ -0,0 +1,169 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DdFlags } from '@datadog/mobile-react-native'; +import { NativeModules } from 'react-native'; + +import { DatadogOpenFeatureProvider } from '../provider'; + +jest.spyOn(NativeModules.DdFlags, 'setEvaluationContext').mockResolvedValue({ + 'bool-flag': { + key: 'bool-flag', + value: true, + allocationKey: 'alloc-xyz', + variationKey: 'true', + reason: 'TARGETED', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: { + campaignId: 'camp-999', + score: 7, + eligible: false, + ignored: { nested: true } + } + }, + 'flag-no-alloc': { + key: 'flag-no-alloc', + value: 'hello', + allocationKey: '', + variationKey: 'hello', + reason: 'STATIC', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: { + source: 'experiment-a' + } + }, + 'flag-alloc-key-collision': { + key: 'flag-alloc-key-collision', + value: true, + allocationKey: 'real-alloc', + variationKey: 'true', + reason: 'TARGETED', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: { + allocationKey: 'impostor-alloc', + label: 'test' + } + }, + 'flag-empty-extra-logging': { + key: 'flag-empty-extra-logging', + value: 42, + allocationKey: '', + variationKey: '42', + reason: 'STATIC', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: {} + }, + 'flag-null-only-extra-logging': { + key: 'flag-null-only-extra-logging', + value: 42, + allocationKey: '', + variationKey: '42', + reason: 'STATIC', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: { + nullField: null + } + } +}); + +describe('DatadogOpenFeatureProvider', () => { + let provider: DatadogOpenFeatureProvider; + + beforeEach(async () => { + jest.clearAllMocks(); + + Object.assign(DdFlags, { + isFeatureEnabled: false, + clients: {} + }); + + await DdFlags.enable(); + + provider = new DatadogOpenFeatureProvider(); + await provider.initialize({ targetingKey: 'user-1' }); + }); + + describe('toFlagResolution / flagMetadata', () => { + it('should include extraLogging primitives in flagMetadata', () => { + const result = provider.resolveBooleanEvaluation( + 'bool-flag', + false, + {}, + {} as any + ); + + expect(result.flagMetadata).toEqual({ + campaignId: 'camp-999', + score: 7, + eligible: false, + allocationKey: 'alloc-xyz' + }); + // Non-primitive 'ignored' key should NOT appear + expect(result.flagMetadata).not.toHaveProperty('ignored'); + }); + + it('should include extraLogging in flagMetadata when there is no allocationKey', () => { + const result = provider.resolveStringEvaluation( + 'flag-no-alloc', + 'default', + {}, + {} as any + ); + + expect(result.flagMetadata).toEqual({ + source: 'experiment-a' + }); + }); + + it('should use the typed allocationKey field, not the allocationKey key from extraLogging', () => { + const result = provider.resolveBooleanEvaluation( + 'flag-alloc-key-collision', + false, + {}, + {} as any + ); + + // The typed allocationKey wins; extraLogging's 'allocationKey' is excluded + expect(result.flagMetadata?.allocationKey).toBe('real-alloc'); + expect(result.flagMetadata).toEqual({ + label: 'test', + allocationKey: 'real-alloc' + }); + }); + + it('should return undefined flagMetadata when extraLogging is empty and allocationKey is absent', () => { + const result = provider.resolveNumberEvaluation( + 'flag-empty-extra-logging', + 0, + {}, + {} as any + ); + + expect(result.flagMetadata).toBeUndefined(); + }); + + it('should return undefined flagMetadata when extraLogging has only null values and allocationKey is absent', () => { + const result = provider.resolveNumberEvaluation( + 'flag-null-only-extra-logging', + 0, + {}, + {} as any + ); + + expect(result.flagMetadata).toBeUndefined(); + }); + }); +}); diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 6cbae29ca..6c0e065f0 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -160,6 +160,7 @@ const toFlagResolution = (details: FlagDetails): ResolutionDetails => { reason, variant, allocationKey, + extraLogging, errorCode, errorMessage } = details; @@ -167,11 +168,30 @@ const toFlagResolution = (details: FlagDetails): ResolutionDetails => { const parsedErrorCode = errorCode && (ErrorCode[errorCode as ErrorCode] || ErrorCode.GENERAL); + // Build flagMetadata: extraLogging primitives first, allocationKey last (wins on collision). + // OpenFeature FlagMetadata does not support null values, so null entries are omitted. + let flagMetadata: Record | undefined; + const hasExtraLogging = + extraLogging && Object.values(extraLogging).some(v => v !== null); + if (allocationKey || hasExtraLogging) { + flagMetadata = {}; + if (extraLogging) { + for (const [k, v] of Object.entries(extraLogging)) { + if (v !== null) { + flagMetadata[k] = v; + } + } + } + if (allocationKey) { + flagMetadata.allocationKey = allocationKey; + } + } + const result: ResolutionDetails = { value, reason, variant, - flagMetadata: allocationKey ? { allocationKey } : undefined, + flagMetadata: flagMetadata || undefined, errorCode: parsedErrorCode, errorMessage };