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
120 changes: 120 additions & 0 deletions packages/core/src/flags/__tests__/rumIntegration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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 } from '../rumIntegration';

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

it('normalizes the application context when no RUM user is available', () => {
const context = {
targetingKey: 'explicit-user',
email: undefined
};

expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual({
targetingKey: 'explicit-user'
});
expect(context).toStrictEqual({
targetingKey: 'explicit-user',
email: undefined
});
});

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 each time it is called', () => {
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('uses application 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)).toStrictEqual(
context
);
});
});
82 changes: 82 additions & 0 deletions packages/core/src/flags/rumIntegration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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;
};

/**
* Add the current RUM user to an OpenFeature-shaped evaluation context.
*
* @internal Used by the explicit helper in the Datadog OpenFeature package. This is a point-in-time
* read; it does not synchronize OpenFeature when the RUM user changes. 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 => {
const effectiveContext = new Map(getRumContextEntries());

try {
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 getRumContextEntries = (): Array<[string, unknown]> => {
try {
const user = UserInfoSingleton.getInstance().getUserInfo();
if (!user) {
return [];
}

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

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

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

return entries;
} catch {
return [];
}
};

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/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
54 changes: 54 additions & 0 deletions packages/react-native-openfeature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,60 @@ 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

Use `enrichRumContext()` when you explicitly want to use the current RUM user as part of an
OpenFeature evaluation context. Neither Datadog OpenFeature provider enriches context automatically.
This keeps context changes visible through OpenFeature and avoids changing flag assignments unless
your application opts in.

The helper maps the RUM user ID to `targetingKey`. It maps `name`, `email`, and flat string, number,
or boolean `extraInfo` properties to evaluation attributes. Values in the application context take
precedence over RUM values, so you can use a different targeting key (for example, a device or session
ID). An application field set to `undefined` removes the corresponding RUM value and is omitted from
the returned context. Nested RUM user properties are not included.

Keep the original application-owned context and enrich it before passing it to OpenFeature:

```tsx
import {
DatadogOpenFeatureProvider,
enrichRumContext
} from '@datadog/mobile-react-native-openfeature';

const applicationContext = {
region: 'us-east-1'
};

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

await OpenFeature.setContext(enrichRumContext(applicationContext));
await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider());
```

`enrichRumContext()` reads the RUM user when it is called; it does not establish a live connection
between RUM and OpenFeature. After a login, logout, or account switch, update the RUM user and enrich
the original application-owned context again:

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

Do not pass `OpenFeature.getContext()` back to `enrichRumContext()`. That context already contains
values from the previous RUM user, so those values would be treated as application-owned overrides
and could prevent the new RUM user from replacing them. Retain the original application context
separately, as shown above.

`rumIntegrationEnabled` only controls whether feature flag evaluation events are sent to RUM. It
does not enable or disable `enrichRumContext()`. If you use OpenFeature domains or multiple providers,
you can apply the enriched context only to the intended domain. For the offline provider, continue to
follow the precomputed configuration context requirements below.

### 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,38 @@
/*
* 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, enrichRumContext } from '../index';

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

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

describe('RUM context core compatibility', () => {
it('keeps the provider usable with a core version that predates enrichment', async () => {
const provider = new DatadogOpenFeatureProvider();

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

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

it('reports incompatible package versions when enrichment is requested', () => {
expect(() => enrichRumContext({})).toThrow(
'requires compatible versions of @datadog/mobile-react-native and @datadog/mobile-react-native-openfeature'
);
});
});
Loading