Skip to content
Merged
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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P1] Consider toStrictEqual for these four assertions, and characterization tests for three branches that currently survive mutation.

toEqual ignores properties whose value is undefined, which is the shape a dropped typeof guard here would produce — so core's own suite cannot see that regression today. The suite is otherwise sharp: a positive control I ran reddened 14 of the 15 new tests. The four calls sit at lines 51, 67, 89 and 97, across three tests.

What I measured — mutations over both projects, 58 suites / 813 tests baseline

Every result quoted below is one I ran locally against this head, not something you can read off the diff. "After" means with the toStrictEqual change and the tests below added.

Mutation Now With the changes below
remove the typeof user.email === 'string' guard RED ×1, openfeature only; core green RED ×5, 4 in core
remove the outer try/catch GREEN RED ×1
remove the typeof user.id === 'string' guard GREEN RED ×1
push extraInfo after the top-level fields GREEN RED ×1

To be precise about what these tests do: all four are characterization tests. They pass against this head and detect the mutations listed above — not one-to-one, though: the missing-id test also fires on the email-guard mutation, because that one leaves an unexpected undefined-valued property behind. They pin current behaviour rather than assert a fix. That is the point; the three GREEN rows are branches nothing currently holds in place.

A smaller note on the compatibility suite

rumContext.compatibility.test.ts:19-31 does assert something real — that the provider still initializes and forwards context against a reduced core mock. It just does not cover anything this PR changes: provider.ts is byte-identical to the base commit. It also stayed green under the positive control that reddened 14 of the other 15 new tests, so it will not move if enrichment regresses. Worth retitling so it does not read as coverage for the new helper.

Suggested tests

Both imports already exist in the file; context is declared locally.

it('omits the targeting key when the RUM user has no id', () => {
    UserInfoSingleton.getInstance().addUserExtraInfo({ plan: 'pro' });

    expect(
        enrichEvaluationContextWithRumUser({ region: 'us-east-1' })
    ).toStrictEqual({ plan: 'pro', region: 'us-east-1' });
});

it('prefers top-level RUM user fields over colliding extraInfo keys', () => {
    UserInfoSingleton.getInstance().setUserInfo({
        id: 'rum-user',
        name: 'RUM Name',
        email: 'rum@example.com',
        extraInfo: {
            targetingKey: 'extra-targeting-key',
            name: 'Extra Name',
            email: 'extra@example.com'
        }
    });

    expect(enrichEvaluationContextWithRumUser({})).toStrictEqual({
        targetingKey: 'rum-user',
        name: 'RUM Name',
        email: 'rum@example.com'
    });
});

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

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

it('drops RUM defaults once the RUM user is cleared', () => {
    UserInfoSingleton.getInstance().setUserInfo({
        id: 'rum-user',
        extraInfo: { plan: 'pro' }
    });
    expect(enrichEvaluationContextWithRumUser({})).toStrictEqual({
        targetingKey: 'rum-user',
        plan: 'pro'
    });

    UserInfoSingleton.getInstance().clearUserInfo();

    expect(
        enrichEvaluationContextWithRumUser({ region: 'us-east-1' })
    ).toStrictEqual({ region: 'us-east-1' });
});

The last one matters because the README documents a logout flow and no test exercises clearUserInfo today — the existing tests use UserInfoSingleton.reset().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. I'll update!

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);
Comment thread
greghuels marked this conversation as resolved.
} else {
effectiveContext.set(key, value);
}
}

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

const getRumContextEntries = (): Array<[string, unknown]> => {
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P1] Read extraInfo and the RUM user's own fields in separate try blocks, and log on the degraded paths — today one unreadable custom property discards the whole RUM user, targeting key included, with no signal.

Object.entries(user.extraInfo ?? {}) invokes application getters, and it runs before name, email and targetingKey are read, so one throwing property returns [] and the subject is lost. Both catch blocks here are silent. By rg over packages/core/src/flags/** excluding __tests__, they are the only fully silent catches against 12 InternalLog.log sites — internal.ts:47 warns merely for dropping one non-primitive attribute.

The existing test cannot distinguish the two outcomes

Every result quoted below is one I ran locally against this head, not something you can read off the diff.

rumIntegration.test.ts:105-119 supplies targetingKey: 'explicit-user' in the application context, which overwrites the RUM id either way, so it passes whether or not the id survives. Against a single-try structure, the assertion that does distinguish them is the only failure I saw:

enrichEvaluationContextWithRumUser › keeps the RUM user fields when an extraInfo property cannot be read
The outer catch hands back an object that throws at the next hop

In the one case I could construct for it — a throwing getter on the application context — it returns the input by reference, still carrying undefined-valued keys and the getter, and toDdContext's rest-spread re-reads it:

same object as input? true
own keys = [ 'region', 'email', 'boom' ]
toDdContext threw: app getter threw

So in that case it defers the throw rather than preventing it. Worth a log line either way, so the degrade is visible.

One thing to watch if you take this

Splitting the try blocks the obvious way moves the name/email/id reads outside any try, and getRumContextEntries() is called before the outer try, so a throwing user.name getter would then escape enrichEvaluationContextWithRumUser entirely. I hit that while testing the patch below; the version quoted keeps both reads guarded. Measured before and after:

reads outside a try:   threw = "name getter threw"   result = undefined
reads inside a try:    threw = no                    result = {"region":"us"}
Suggested change — complete, including imports
import { InternalLog } from '../InternalLog';
import { SdkVerbosity } from '../config/types/SdkVerbosity';
import { UserInfoSingleton } from '../sdk/UserInfoSingleton/UserInfoSingleton';
import type { UserInfo } from '../sdk/UserInfoSingleton/types';

and, at module scope:

// Keys the RUM user's own fields own. `targetingKey` must stay a string and must come from
// `user.id` alone, so an `extraInfo` entry never reaches any of them.
const RESERVED_RUM_KEYS = ['targetingKey', 'name', 'email'];
const getRumContextEntries = (): Array<[string, unknown]> => {
    const user = readRumUser();
    if (!user) {
        return [];
    }

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

    // `extraInfo` and the RUM user's own fields are read in separate try blocks, so an unreadable
    // custom property cannot discard the targeting key with it. Within each group it is all or
    // nothing: `Object.entries` materializes every value before the loop runs, and the three
    // reserved fields are destructured together. Every read here can invoke an application getter.
    try {
        for (const [key, value] of Object.entries(user.extraInfo ?? {})) {
            if (RESERVED_RUM_KEYS.includes(key)) {
                InternalLog.log(
                    `RUM user property "${key}" is reserved and was not added to the evaluation context. Only the RUM user's own id, name, and email supply targetingKey, name, and email.`,
                    SdkVerbosity.WARN
                );
                continue;
            }
            if (!isSupportedAttribute(value)) {
                InternalLog.log(
                    `RUM user property "${key}" is not a string, number, or boolean. Omitting it from the evaluation context.`,
                    SdkVerbosity.WARN
                );
                continue;
            }
            entries.push([key, value]);
        }
    } catch (error) {
        InternalLog.log(
            `Some RUM user properties could not be read (${errorMessage(
                error
            )}) and were not added to the evaluation context.`,
            SdkVerbosity.WARN
        );
    }

    try {
        // Read each field once and insert the value that was validated. Reading the property
        // again after the `typeof` check would let a getter return a string and then something
        // else, putting a non-string into a reserved key.
        const { name, email, id } = user;
        if (typeof name === 'string') {
            entries.push(['name', name]);
        }
        if (typeof email === 'string') {
            entries.push(['email', email]);
        }
        if (typeof id === 'string') {
            entries.push(['targetingKey', id]);
        }
    } catch (error) {
        InternalLog.log(
            `Some RUM user fields could not be read (${errorMessage(
                error
            )}) and were not added to the evaluation context.`,
            SdkVerbosity.WARN
        );
    }

    return entries;
};

const readRumUser = (): UserInfo | undefined => {
    let user: UserInfo | undefined;

    try {
        user = UserInfoSingleton.getInstance().getUserInfo();
    } catch (error) {
        InternalLog.log(
            `Could not read the RUM user (${errorMessage(
                error
            )}). No RUM values were added to the evaluation context.`,
            SdkVerbosity.WARN
        );

        return undefined;
    }

    // Reported here, not by the caller, so a read failure does not also report "no user".
    if (!user) {
        InternalLog.log(
            'No RUM user is set, so no RUM values were added to the evaluation context. Call DdSdkReactNative.setUserInfo() and await it before enriching.',
            SdkVerbosity.WARN
        );
    }

    return user;
};
// Reading `error.message` can itself throw, and this runs inside a catch that must not throw.
const errorMessage = (error: unknown): string => {
    try {
        // `String(...)` inside the guard: reading `message` can throw, and so can coercing it.
        return error instanceof Error ? String(error.message) : 'unknown error';
    } catch {
        return 'unknown error';
    }
};

and the log line on the outer catch:

    } catch (error) {
        InternalLog.log(
            `Could not read the application evaluation context (${errorMessage(
                error
            )}). Returning it unchanged, without the RUM user.`,
            SdkVerbosity.WARN
        );

        return context;
    }

Three details worth keeping if you take this. The "no RUM user" warning lives in readRumUser, not the caller, so a read failure is reported once rather than also as an absent user. The isolation is between the two groups, not inside them: Object.entries materializes every extraInfo value before the loop runs, so one throwing getter still costs the whole attribute bag — I measured a readable plan: 'pro' disappearing alongside it. What the split buys is that the targeting key, name and email survive it. Both warnings are worded for that: some properties could not be read and were not added. The three reserved fields are destructured once and the destructured values are inserted, so a getter cannot pass the typeof check and then return something else on a second read — that one is inherited from the current code and is worth closing while you are here. And errorMessage both reads and coerces inside its guard, because error.message can throw on access and on toString — and template interpolation would do that coercion outside the guard. Two earlier drafts of this patch leaked an exception that way, and getRumContextEntries() runs before the outer try, so it would have escaped enrichment entirely. With the whole set applied I measured 60 suites and 833 tests passing (1 skipped, 834 total), tsc --noEmit clean, eslint 0 errors and prettier clean.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this is an edge case worth addressing. I'll update.

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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P2] Consider skipping targetingKey, name and email while promoting extraInfo, so a custom user attribute cannot occupy a reserved key.

extraInfo is pushed first and the RUM user's own fields overwrite it, which is the right precedence — but targetingKey is only pushed when typeof user.id === 'string'. addUserExtraInfo can leave a user with extraInfo and no id, and DdSdkReactNative.tsx:268 documents that path, so in that state an extraInfo entry is the only source for the key.

What I measured

Every result quoted below is one I ran locally against this head, not something you can read off the diff.

addUserExtraInfo({ targetingKey:'FROM-EXTRA' })
  enrichRumContext({})  => {"targetingKey":"FROM-EXTRA"}

setUserInfo({ id:'rum-123', extraInfo:{ name:'FROM-EXTRA', email:'e@x.com' } })
  enrichRumContext({})  => {"name":"FROM-EXTRA","email":"e@x.com","targetingKey":"rum-123"}

addUserExtraInfo({ targetingKey: 42 })
  toDdContext(...).targetingKey === 42    // typeof "number"

The last line stops at toDdContext; I did not exercise the bridge. It is worth flagging because specs/NativeDdFlags.ts:21 declares targetingKey: string and DdFlagsImplementation.kt:67 declares a non-null Kotlin String, and processEvaluationContext passes targetingKey through untouched — it validates only attributes.

There is a precedent for the type check specifically: configuration/context.ts:24-25 treats a non-string targeting key as absent because "the wire is untrusted". Reserving the key's source is a separate policy question.

Suggested change — a reserved-key list checked while promoting
import { InternalLog } from '../InternalLog';
import { SdkVerbosity } from '../config/types/SdkVerbosity';
import { UserInfoSingleton } from '../sdk/UserInfoSingleton/UserInfoSingleton';
import type { UserInfo } from '../sdk/UserInfoSingleton/types';

and, at module scope:

// Keys the RUM user's own fields own. `targetingKey` must stay a string and must come from
// `user.id` alone, so an `extraInfo` entry never reaches any of them.
const RESERVED_RUM_KEYS = ['targetingKey', 'name', 'email'];

then, inside the extraInfo loop in getRumContextEntries:

            if (RESERVED_RUM_KEYS.includes(key)) {
                InternalLog.log(
                    `RUM user property "${key}" is reserved and was not added to the evaluation context. Only the RUM user's own id, name, and email supply targetingKey, name, and email.`,
                    SdkVerbosity.WARN
                );
                continue;
            }

The wording deliberately does not promise a replacement value, because in the motivating case the corresponding user field is absent.

@greghuels greghuels Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as #1363 (comment). Had a long AI conversation about tradeoffs and I think consistent merge behavior and removing any surprises wins.

Here's the LLM comment on this after some pushback:
I think we should prefer consistent merge precedence: extraInfo → RUM identity fields → explicit application context over mixing responsibilities of merge and validation. Special-casing these keys would make enrichment less predictable. An invalid final targetingKey should be handled the same way regardless of whether it came from RUM or application context, rather than changing the merge rules.

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 = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should null be allowed as value?
We have IS_NULL checks in our targeting rules

@greghuels greghuels Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it looks like custom RUM attributes can contain null, I'll update

@greghuels greghuels Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on my earlier reply: null is valid for IS_NULL targeting, but I found an existing Android limitation that makes this larger than a helper change. The native flags context currently accepts only string attributes, and the React Native bridge serializes null as the literal string "null". I checked edge-assignments: IS_NULL recognizes actual null/missing attributes, not that string.

To keep #1428 scoped, I've removed the added null support from isSupportedAttribute and its runtime predicate. It remains string | number | boolean, retaining the existing omission of null-valued RUM properties. Application-supplied context values are unchanged, and the tests/docs now reflect that scope.

End-to-end null preservation needs a separate, coordinated native SDK/bridge change that accounts for public API compatibility. Deferring that existing platform issue rather than expanding this review-feedback PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional context -- I had an initial native proposal (DataDog/dd-sdk-android#3869), but I closed it since it risks NullPointerException in client apps. Since this explodes scope, I'm deferring this issue rather than expanding this review-feedback PR.

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
Comment thread
btthomas marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P2] Answering @sbarrio's question, and suggesting a short comment here.

Underscore-prefixing to mean "internal" is conventional throughout this repo, and so is publishing such a symbol — DdSdkReactNative carries four _-prefixed statics reachable through the root export, three of them under a "FOR INTERNAL USE ONLY" doc block. What is new on this line is the alias: it is core's only root export whose name differs from its definition. That is the part worth recording, because nothing else does.

Where the repo already does this — seven patterns, with links
Pattern Meaning Instances
class _Foo + export const Foo = getGlobalInstance(…) only the instance leaves the module _InternalLog → InternalLog, _GlobalState → GlobalState, _BufferSingleton, _DatadogProviderState, _DistributedTracingSampling
_method on an exported class internal, and published _initializeFromDatadogProvider, _enableFeaturesFromDatadogProvider, _enableFeaturesFromDatadogProviderAsync, _initializeFromDatadogProviderWithConfigurationAsync — on a class root-exported
export const _foo exported for tests, kept off every entry point _isDebugIdInBundle, _replaceDebugIdInBundle, _insertDebugIdCommentInBundle
_foo module-private helper _getErrorMessage, _getErrorStack
_foo module-private state _cachedSessionId
__internal…ForTesting internal seam for a same-package consumer __internalResetIsInitializedForTesting
__internal_x Babel plugin option the CLI supplies, declared "not meant for end users" __internal_saveSvgMapToDisk, __internal_reactNativeSVG on PluginOptions, passed in by generate-sr-assets

The second row is the load-bearing one: three of those four carry the "FOR INTERNAL USE ONLY" block (_enableFeaturesFromDatadogProviderAsync has none), so they are internal by documentation alone, and they ship on the public surface through a root-exported class. So publishing an underscored internal is established practice here, not a departure.

Separately, _param marks an intentionally unused argument — _oldContext and _context in the OpenFeature package, _route and _event in the navigation packages. That one is partly mechanical: noUnusedParameters: true makes TypeScript exempt _-prefixed parameters, for the files in the program — tests and utils are excluded. Elsewhere the underscore itself enforces nothing — the module boundary is what actually hides rows 1, 4 and 5, since those declarations are never exported. There is no naming-convention rule, and argsIgnorePattern is commented out with args: 'none' in its place.

So the placement is normal; the alias is the new part

index.tsx:116-117 holds the only two top-level named underscore exports on core's entry point, and this is the only one renamed at the boundary:

    __ddExtractText,
    enrichEvaluationContextWithRumUser as __ddEnrichEvaluationContextWithRumUser

__ddExtractText carries the prefix at its definition and is exported unaliased, because insertRumActionImport writes that exact identifier into application source through getImportDeclaration + insertAtProgramTop, alongside DdBabelInteractionTracking. Every addNamed call in that file is React's jsx, jsxs or Fragment. Injecting a fixed name into a customer's module scope is what motivates an unusual one there; nothing injects this symbol, so here the prefix is a marker rather than a constraint. Either symbol could have been aliased at the export; this one was, and the local name stays descriptive as a result. Reasonable — just unrecorded.

The marker travels one subpath further: ./jest spreads the root surface via ...actualDatadog, so both top-level __dd helpers survive there — though it then replaces DdSdkReactNative, which drops the four underscored statics.

What the prefix does not buy

Emitted with the repo's own tsconfig:

export { …, DdBabelInteractionTracking, __ddExtractText,
  enrichEvaluationContextWithRumUser as __ddEnrichEvaluationContextWithRumUser };

@internal is advisory — no api-extractor.json, no tsdoc.json, no stripInternal in any tsconfig. .gitlab-ci.yml runs test:lint, test:js and test:build, none of which notices a new root export.

And the channel itself looks right

Core has no public on-demand getter for the current RUM user: UserInfoSingleton is exported from its own module but not from core's entry point, and DdSdkReactNative has setUserInfo, clearUserInfo and addUserExtraInfo with no reader. (An event mapper does receive userInfo on each event, which is not the same thing.)

A subpath was the alternative, with history worth knowing: packages/core/internal/package.json still exists as a directory stub pointing at ../lib/*/internal.js, but packages/core/src/internal.tsx was removed in b72cd3e5 Remove internal export, and internal is not in core's files array — so the stub is dead either way. Core's exports map lists only ., ./metro, ./jest and ./package.json; under Node resolution that blocks unlisted subpaths, though Metro warns and falls back. ./metro shows the live pattern if you ever revive it.

Suggested comment
    DdBabelInteractionTracking,
    // Internal entry points for the Datadog companion packages, and for the identifier the Babel
    // plugin injects into application code. Not part of the public API of
    // `@datadog/mobile-react-native`: they may change or be removed in any release, and nothing
    // enforces that beyond this comment. The `__dd` prefix is that marker. It is applied at the
    // export for the enrichment helper so its local name stays descriptive; `__ddExtractText`
    // carries it at the definition because the Babel plugin injects that literal identifier.
    // Application code uses the documented wrappers instead — for RUM context enrichment,
    // `enrichRumContext` from `@datadog/mobile-react-native-openfeature`.
    __ddExtractText,
    enrichEvaluationContextWithRumUser as __ddEnrichEvaluationContextWithRumUser
};

It pairs with pinning the consumer's type to this export (my comment on rumContext.ts:21), which turns a rename into a build error naming the symbol instead of eight tests reporting a version mismatch.

@greghuels greghuels Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read this over -- I don't think adding a comment is necessary and would just add noise that wouldn't provide meaningful context. If anything, we can just get rid of the alias since the original name is descriptive and preserves the "internal" __dd prefix. The rename itself likely was a stylistic choice by the agent.

@sbarrio sbarrio Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@greghuels Thanks, that's exactly why I asked.

I thought that keeping the original name was fine (and aligned with other modules) and thus could not understand the need for a rename.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good callout. I'll open a follow-up PR with some changes. Appreciate you flagging it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is addressed in #1428

};
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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P1] Neither snippet compiles as written, and the global setContext they use is not Datadog-scoped.

Both blocks use DdSdkReactNative and OpenFeature without importing them, so neither runs at all if pasted into a fresh module. The prose names logout but shows only setUserInfo, which does nothing for an empty id; clearUserInfo() is the logout call and is not mentioned. Separately, OpenFeature.setContext(ctx) with no domain is not Datadog-scoped, which matters once a second provider is in play.

What I measured — tsc on the exact extracted blocks

Every result quoted below is one I ran locally against this head, not something you can read off the diff.

current1.tsx(10,7): TS2304: Cannot find name 'DdSdkReactNative'.
current1.tsx(16,7): TS2304: Cannot find name 'OpenFeature'.
current1.tsx(17,7): TS2304: Cannot find name 'OpenFeature'.

current2.tsx(1,7):  TS2304: Cannot find name 'DdSdkReactNative'.
current2.tsx(1,36): TS2304: Cannot find name 'newUser'.
current2.tsx(2,30): TS2304: Cannot find name 'enrichRumContext'.
current2.tsx(2,47): TS2304: Cannot find name 'applicationContext'.

The replacements below both compile clean under the same config — I extracted them back out of the proposed README and ran that text, not my local copy of it. The first one adds a DdFlags.enable() call so the flags step is visible in the block; it still assumes the core SDK was initialized earlier, as at README:38-44.

The logout gap, measured

setUserInfo early-returns for a non-string or empty id (UserInfoSingleton.ts:15-17), so the shape the prose implies leaves the previous user in place:

setUserInfo({id:'user-123', email:'old@example.com'})
setUserInfo({id:''})                      // no-op
enrichRumContext({region:'us'})
  => {"email":"old@example.com","targetingKey":"user-123","region":"us"}

clearUserInfo()
enrichRumContext({region:'us'})  => {"region":"us"}      // no targetingKey; toDdContext then supplies ''
The global context reaches other providers — and the fix needs a consumer change

From the installed @openfeature/web-sdk@1.8.0, dist/cjs/index.js:1074: the no-domain branch of setContext builds unboundProviders as every domain-scoped provider not present in _domainScopedContext, then runs their context-change handlers. So an app running another vendor's provider on a domain with no context of its own receives the RUM user's attributes there too. The section mentions domains at line 121 but never shows the two-argument call.

I kept the main snippets on the global context, because that is what the rest of the README does and <OpenFeatureProvider> at line 148 is unqualified. The domain variant belongs in the warning instead, with the consumer line it requires — a domain-bound provider is invisible to a client with no domain. Both parts, which I compiled de-quoted:

const DATADOG_DOMAIN = 'datadog';

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

and at line 148, <OpenFeatureProvider domain={DATADOG_DOMAIN}> in place of <OpenFeatureProvider>, or OpenFeature.getClient(DATADOG_DOMAIN) for a direct client.

It has to be all of it, not just registration: every later setContext needs the domain too, including the login and logout calls in the second snippet and the one at line 138. A domain keeps its own context, so a global update would leave the Datadog domain on the previous user.

One more line worth adding: numeric attributes differ by platform

DdFlagsImplementation.kt:176-179 builds mutableMapOf<String, String>() and assigns parsed[key] = value.toString(); DdFlagsImplementation.swift:122-127 uses AnyValue.wrap and preserves the type. The Kotlin file's own comment at :227 says "the React Native bridge converts Long values to Double", and a boxed Double of 42 prints as "42.0" (I ran that on the JDK). No Kotlin test covers buildEvaluationContext. This is pre-existing for hand-authored contexts; what is new is that isSupportedAttribute promotes numbers the application did not pick for targeting.

Suggested replacement snippets — both compile clean as posted
import { DdFlags, DdSdkReactNative } from '@datadog/mobile-react-native';
import {
    DatadogOpenFeatureProvider,
    enrichRumContext
} from '@datadog/mobile-react-native-openfeature';
import { OpenFeature } from '@openfeature/react-sdk';

// The context your application owns. Keep this reference and enrich it; never overwrite it.
const applicationContext = {
    region: 'us-east-1'
};

const setUpFlags = async (): Promise<void> => {
    await DdFlags.enable();

    // `await` this: the RUM user is only readable once `setUserInfo` resolves.
    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());
};

void setUpFlags();

and for login / logout:

import { DdSdkReactNative } from '@datadog/mobile-react-native';
import { enrichRumContext } from '@datadog/mobile-react-native-openfeature';
import { OpenFeature } from '@openfeature/react-sdk';

// The same application-owned context as above.
const applicationContext = { region: 'us-east-1' };

const onLogin = async (): Promise<void> => {
    await DdSdkReactNative.setUserInfo({
        id: 'user-456',
        email: 'next@example.com'
    });
    await OpenFeature.setContext(enrichRumContext(applicationContext));
};

const onLogout = async (): Promise<void> => {
    await DdSdkReactNative.clearUserInfo();
    // With no RUM user the context carries no targeting key, and the provider maps that to the
    // anonymous subject.
    await OpenFeature.setContext(enrichRumContext(applicationContext));
};

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Comment from Claude working with Aaron Silverman:

[P1] Consider replacing this forward-reference with a direct warning about DatadogOfflineOpenFeatureProvider, and pinning the behaviour with a test.

An enriched context is servable offline only when the snapshot was precomputed for that exact context, because the comparison is an exact attribute key-set match. The guidance this sentence points at starts 80+ lines later at line 204, behind an intervening section, and the hybrid-app recommendation at 216 is to give that domain an explicitly empty context, which an enriched context usually is not once a RUM user is set.

What I measured — one failure shape is loud, the other is silent

Every result quoted below is one I ran locally against this head, not something you can read off the diff. Snapshot precomputed for { targetingKey: 'user-123' }.

Loud — enrich before registering, with a RUM email the snapshot does not carry:

enriched = {"email":"u@example.com","targetingKey":"user-123"}
setProviderAndWait rejected: InvalidContextError
status = ERROR | value = false | errorCode = INVALID_CONTEXT

Silent — a matching context at startup, then one unrelated addUserExtraInfo and a re-enrich:

status before = READY | value = true
addUserExtraInfo({ last_screen: 'checkout' })
enrichRumContext({}) => {"last_screen":"checkout","targetingKey":"user-123"}
setContext(domain, drifted) rejected? NO - it resolved normally
status after = ERROR | value = false | errorCode = INVALID_CONTEXT

The second shape is the one worth documenting: the await succeeds rather than rejecting, while every flag reverts to its coded default. The provider does move to ERROR and evaluations do carry INVALID_CONTEXT, so an app listening for ProviderEvents.Error or reading evaluation details can see it — what it will not see is a thrown error at the call it just made. Note the drift is not automatic — it needs the app to re-enrich and set the context again, which is exactly what the section tells it to do after a user change. addUserExtraInfo merges into extraInfo and removes nothing (UserInfoSingleton.ts:22-30), so a call with a new key widens the set; setUserInfo replaces the whole user and clearUserInfo drops it. None of those necessarily changes the key set — a replacement user with the same keys does not, and an application override can mask an addition — but any of them can, and the comparison needs an exact match, so a narrowing change breaks it just as a widening one does. The one exception is narrowing all the way to an empty effective context, which re-adopts the embedded context and recovers, per README:209.

Suggested wording
> **Warning:** using `enrichRumContext()` with `DatadogOfflineOpenFeatureProvider` is fragile. A
> precomputed configuration is a single-subject snapshot and the active context must match it
> exactly after normalization, including the full attribute key set, so an enriched context is only
> servable if the snapshot was computed for that exact context. It is also easy to drift out of:
> an `addUserExtraInfo()`, a `setUserInfo()` that replaces the user, or a `clearUserInfo()` can
> each change the normalized context, so a context that matched at startup can stop matching the
> next time you re-enrich after a user change. The provider then enters the OpenFeature `ERROR` state
> and every flag falls back to your coded default (`errorCode: INVALID_CONTEXT`). That transition
> does not reject the `setContext` call, so watch `ProviderEvents.Error` if you rely on it. The one
> effective context that always works is an empty one, which re-adopts the snapshot's embedded
> context and recovers the provider — so prefer giving the offline provider its own domain with an
> explicit empty context, as described in [Offline initialization](#offline-initialization).

Both shapes above are worth a regression test; there is no test combining enrichRumContext with the offline provider today.

@greghuels greghuels Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that offline doesn't make sense with enrichRumContext, but there's also value in a simple README.md, and this is a pretty verbose explanation for something that most users won't ever do.

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