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
2 changes: 2 additions & 0 deletions packages/core/src/flags/DdFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { DdNativeFlagsType } from '../nativeModulesTypes';
import { getGlobalInstance } from '../utils/singletonUtils';

import { FlagsClient } from './FlagsClient';
import { setRumIntegrationEnabled } from './rumIntegration';
import type { DdFlagsType, FlagsConfiguration } from './types';

const FLAGS_MODULE = 'com.datadog.reactnative.flags';
Expand All @@ -34,6 +35,7 @@ class DdFlagsWrapper implements DdFlagsType {
enable = async (configuration: FlagsConfiguration = {}): Promise<void> => {
await this.nativeFlags.enable({ enabled: true, ...configuration });

setRumIntegrationEnabled(configuration.rumIntegrationEnabled ?? true);
this.isFeatureEnabled = true;
};

Expand Down
124 changes: 124 additions & 0 deletions packages/core/src/flags/__tests__/rumIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* 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 { UserInfoSingleton } from '../../sdk/UserInfoSingleton/UserInfoSingleton';
import {
enrichEvaluationContextWithRumUser,
setRumIntegrationEnabled
} from '../rumIntegration';

describe('enrichEvaluationContextWithRumUser', () => {
beforeEach(() => {
UserInfoSingleton.reset();
setRumIntegrationEnabled(true);
});

it('returns the original context when no RUM user is available', () => {
const context = { targetingKey: 'explicit-user' };

expect(enrichEvaluationContextWithRumUser(context)).toBe(context);
});

it('adds flat primitive RUM user properties and lets explicit context win', () => {
UserInfoSingleton.getInstance().setUserInfo({
id: 'rum-user',
name: 'RUM Name',
email: 'rum@example.com',
extraInfo: {
company_name: 'Example, Inc.',
age: 42,
active: true,
nullable: null,
profile: { plan: 'enterprise' },
roles: ['admin']
}
});

expect(
enrichEvaluationContextWithRumUser({
targetingKey: 'explicit-user',
email: 'explicit@example.com',
request_attribute: 'request-value'
})
).toEqual({
targetingKey: 'explicit-user',
name: 'RUM Name',
email: 'explicit@example.com',
company_name: 'Example, Inc.',
age: 42,
active: true,
request_attribute: 'request-value'
});
});

it('preserves an explicitly empty targeting key', () => {
UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' });

expect(
enrichEvaluationContextWithRumUser({ targetingKey: '' })
).toEqual({ targetingKey: '' });
});

it('uses explicitly undefined fields to remove RUM defaults', () => {
UserInfoSingleton.getInstance().setUserInfo({
id: 'rum-user',
email: 'rum@example.com',
extraInfo: { plan: 'pro' }
});

expect(
enrichEvaluationContextWithRumUser({
targetingKey: undefined,
email: undefined,
plan: undefined,
request_attribute: 'request-value'
})
).toStrictEqual({ request_attribute: 'request-value' });
});

it('uses the latest RUM user whenever context is reconciled', () => {
UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' });
expect(enrichEvaluationContextWithRumUser({})).toEqual({
targetingKey: 'rum-user-a'
});

UserInfoSingleton.getInstance().setUserInfo({
id: 'rum-user-b',
extraInfo: { plan: 'pro' }
});
expect(enrichEvaluationContextWithRumUser({})).toEqual({
targetingKey: 'rum-user-b',
plan: 'pro'
});
});

it('does not enrich context when RUM integration is disabled', () => {
UserInfoSingleton.getInstance().setUserInfo({
id: 'rum-user',
email: 'rum@example.com'
});
setRumIntegrationEnabled(false);
const context = { request_attribute: 'request-value' };

expect(enrichEvaluationContextWithRumUser(context)).toBe(context);
});

it('preserves context when RUM user properties cannot be read', () => {
const extraInfo = Object.defineProperty({}, 'broken', {
enumerable: true,
get: () => {
throw new Error('cannot read user property');
}
});
UserInfoSingleton.getInstance().setUserInfo({
id: 'rum-user',
extraInfo
});
const context = { targetingKey: 'explicit-user' };

expect(enrichEvaluationContextWithRumUser(context)).toBe(context);
});
});
83 changes: 83 additions & 0 deletions packages/core/src/flags/rumIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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 { UserInfoSingleton } from '../sdk/UserInfoSingleton/UserInfoSingleton';

type FlatEvaluationContext = Record<string, unknown> & {
targetingKey?: string;
};

let rumIntegrationEnabled = true;

/** @internal Keep the JS integration state aligned with the native Flags configuration. */
export const setRumIntegrationEnabled = (enabled: boolean): void => {
rumIntegrationEnabled = enabled;
};

/**
* Add the current RUM user to an OpenFeature-shaped evaluation context.
*
* @internal Shared with the Datadog OpenFeature package. RUM values provide defaults; fields
* explicitly supplied by the application remain authoritative. An explicitly undefined field
* removes the corresponding RUM default and is omitted from the effective context.
*/
export const enrichEvaluationContextWithRumUser = <
T extends FlatEvaluationContext
>(
context: T
): T => {
try {
if (!rumIntegrationEnabled) {
return context;
}

const user = UserInfoSingleton.getInstance().getUserInfo();
if (!user) {
return context;
}

const rumContextEntries: Array<[string, unknown]> = [];

for (const [key, value] of Object.entries(user.extraInfo ?? {})) {
if (isSupportedAttribute(value)) {
rumContextEntries.push([key, value]);
}
}

if (typeof user.name === 'string') {
rumContextEntries.push(['name', user.name]);
}
if (typeof user.email === 'string') {
rumContextEntries.push(['email', user.email]);
}
if (typeof user.id === 'string') {
rumContextEntries.push(['targetingKey', user.id]);
}

const effectiveContext = new Map(rumContextEntries);
for (const [key, value] of Object.entries(context)) {
if (value === undefined) {
effectiveContext.delete(key);
} else {
effectiveContext.set(key, value);
}
}

return Object.fromEntries(effectiveContext) as T;
} catch {
return context;
}
};

const isSupportedAttribute = (
value: unknown
): value is string | number | boolean => {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
);
};
4 changes: 3 additions & 1 deletion packages/core/src/flags/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export interface FlagsConfiguration {
/**
* Enables the RUM integration.
*
* When enabled, flag evaluation events are sent to RUM for correlation with user sessions.
* When enabled, flag evaluation events are sent to RUM for correlation with user sessions,
* and the Datadog OpenFeature provider includes flat primitive RUM user properties in its
* evaluation context.
*
* @default true
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
configurationToString
} from './flags/configuration';
import type { ParsedFlagsConfiguration } from './flags/configuration';
import { enrichEvaluationContextWithRumUser } from './flags/rumIntegration';
import type {
FlagsConfiguration,
FlagDetails,
Expand Down Expand Up @@ -112,7 +113,8 @@ export {
DatadogTracingIdentifier,
DatadogTracingContext,
DdBabelInteractionTracking,
__ddExtractText
__ddExtractText,
enrichEvaluationContextWithRumUser as __ddEnrichEvaluationContextWithRumUser
};
export type {
Timestamp,
Expand Down
39 changes: 39 additions & 0 deletions packages/react-native-openfeature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,45 @@ After completing this setup, your app is ready for flag evaluation with OpenFeat

> **Note**: Sending flag evaluation data to Datadog is automatically enabled when using the Feature Flags SDK. Provide `rumIntegrationEnabled` and `trackExposures` parameters to the `DdFlags.enable()` call to configure.

### RUM user context

When RUM integration is enabled (the default), the online provider includes the user set through
`DdSdkReactNative.setUserInfo()` in the OpenFeature evaluation context. The RUM user ID supplies the
targeting key, while `name`, `email`, and flat string, number, or boolean `extraInfo` properties become
evaluation attributes. Fields set explicitly through `OpenFeature.setContext()` take precedence. An
explicitly `undefined` field suppresses the corresponding RUM value and is omitted from the effective
evaluation context.

Set the RUM user before registering the provider:

```tsx
await DdSdkReactNative.setUserInfo({
id: 'user-123',
email: 'user@example.com',
extraInfo: { company_name: 'Example, Inc.' }
});

await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider());
```

Calling `DdSdkReactNative.setUserInfo()` after provider initialization does not automatically update
the OpenFeature evaluation context. After a login or account switch, update the RUM user and then
reconcile the provider while preserving explicitly configured OpenFeature properties:

```tsx
await DdSdkReactNative.setUserInfo(newUser);
await OpenFeature.setContext(OpenFeature.getContext());
```

Until reconciliation completes, the provider continues using its previous effective evaluation
context. Reconciliation refetches assignments, and subsequent evaluations and evaluation tracking
use the new RUM user context.

Nested RUM user properties are not included. Setting `rumIntegrationEnabled: false` in
`DdFlags.enable()` disables both RUM feature flag tracking and RUM user context enrichment. The
offline provider does not enrich its context because precomputed configurations are bound to their
embedded context.

### Using the OpenFeature React SDK

For complete details on using the OpenFeature React SDK, including flag evaluation, evaluation context management, and advanced setup options, see the OpenFeature React SDK [documentation][1].
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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 { DatadogOpenFeatureProvider } from '../provider';

const mockFlagsClient = {
setEvaluationContext: jest.fn(() => Promise.resolve())
};

jest.mock('@datadog/mobile-react-native', () => ({
DdFlags: { getClient: jest.fn(() => mockFlagsClient) },
configurationFromString: jest.fn()
}));

describe('DatadogOpenFeatureProvider core compatibility', () => {
it('preserves context when the installed core does not expose the RUM enricher', async () => {
const provider = new DatadogOpenFeatureProvider();

await provider.initialize({
targetingKey: 'explicit-user',
plan: 'pro'
});

expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({
targetingKey: 'explicit-user',
attributes: { plan: 'pro' }
});
});
});
Loading