Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions packages/core/src/flags/FlagsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -264,3 +270,27 @@ export class FlagsClient {
return this.getObjectDetails(key, defaultValue).value;
};
}

const buildExtraLogging = (
raw: Record<string, unknown>
): Record<string, PrimitiveValue> | undefined => {
if (!raw || Object.keys(raw).length === 0) {
return undefined;
}

const result: Record<string, PrimitiveValue> = {};
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;
};
66 changes: 66 additions & 0 deletions packages/core/src/flags/__tests__/FlagsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}
}
});

Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/flags/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ export interface FlagDetails<T> {
* 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<string, PrimitiveValue>;
/**
* Code of the error that occurred during evaluation, if any.
*/
Expand Down
16 changes: 16 additions & 0 deletions packages/react-native-openfeature/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
@@ -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;
145 changes: 145 additions & 0 deletions packages/react-native-openfeature/src/__tests__/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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: {}
}
});

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();
});
});
});
22 changes: 21 additions & 1 deletion packages/react-native-openfeature/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,38 @@ const toFlagResolution = <T>(details: FlagDetails<T>): ResolutionDetails<T> => {
reason,
variant,
allocationKey,
extraLogging,
errorCode,
errorMessage
} = details;

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<string, string | number | boolean> | undefined;
const hasExtraLogging =
extraLogging && Object.keys(extraLogging).length > 0;
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;
}
}
Comment on lines +171 to +188

const result: ResolutionDetails<T> = {
value,
reason,
variant,
flagMetadata: allocationKey ? { allocationKey } : undefined,
flagMetadata: flagMetadata || undefined,
errorCode: parsedErrorCode,
errorMessage
};
Expand Down