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
39 changes: 39 additions & 0 deletions packages/core/src/flags/__tests__/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Copyright 2016-Present Datadog, Inc.
*/

import { InternalLog } from '../../InternalLog';
import { SdkVerbosity } from '../../config/types/SdkVerbosity';
import { processEvaluationContext } from '../internal';

jest.mock('../../InternalLog', () => {
Expand All @@ -14,6 +16,43 @@ jest.mock('../../InternalLog', () => {
});

describe('processEvaluationContext', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it.each(['user-1', ''])(
'preserves the string targeting key %p without a warning',
targetingKey => {
expect(processEvaluationContext({ targetingKey })).toStrictEqual({
targetingKey,
attributes: {}
});
expect(InternalLog.log).not.toHaveBeenCalled();
}
);

it.each([42, true, false, null, undefined, {}, []])(

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:

[P3] null and undefined are interchangeable here but not downstream — an application that writes targetingKey: null instead of undefined can get a different offline outcome.

toDdContext maps both to '' via ?? ''. But isEmptyContext (mappers.ts:45-47) tests value === undefined, and the offline provider branches on that before mapping. So the two diverge.

Executed — through the real offline provider, against a user-123 snapshot
{}                          -> READY
{ targetingKey: undefined } -> READY
{ targetingKey: null }      -> ERROR  "The evaluation context does not match the offline
                                       precomputed configuration. Serving default values."

undefined is treated as "no override" and re-adopts the embedded context; null is treated as a real context, gets mapped to the anonymous subject, and fails to match.

The divergence is conditional, because isEmptyContext inspects every value: it only appears when the rest of the context is empty. Adding one ordinary attribute collapses it, and against an anonymous snapshot both succeed:

{ targetingKey: null,      region: 'us' } -> ERROR      { targetingKey: undefined, region: 'us' } -> ERROR
{ targetingKey: null } vs '' snapshot     -> READY      { targetingKey: undefined } vs '' snapshot -> READY

So: same intent, different result in the narrow case, and nothing documents it.

This is the concrete reason the null row in this it.each is worth a second look: it is not simply "another non-string". null is the one value the two layers disagree about.

Scope

For this warning specifically, the nullish rows are unreachable from either provider — toDdContext applies ?? '' first, so neither null nor undefined arrives here. 42, true and false do reach it, and rumContext.integration.test.ts:184 already asserts that. So the rows are not dead as a group; the two nullish ones are documentation for direct DdFlags callers.

For reference — what the browser SDK does with null

Pinned to local commit cb896583, because upstream has since moved (the provider now enriches context with RUM user defaults by default, and the line numbers below have shifted):

targetingKey: null
wire (fetchConfiguration.ts:79, || '') ''
exposure event (exposureEvent.ts:20, = '') null — the default only applies to undefined
aggregation key (flagEvaluationAggregator.ts:106, || '') ''

So the browser SDK is inconsistent about null too, in its own way: the wire treats it as anonymous while the exposure event lets it through to subject.id. Neither SDK has a stated position. Worth one, if these are ever reconciled.

Suggested change — a note here; the divergence itself is a separate call
    // `null` and `undefined` never reach this warning from either provider: toDdContext
    // applies `targetingKey ?? ''` first. They are here for direct DdFlags callers.
    // Note the two are not equivalent further out — isEmptyContext treats only `undefined`
    // as absent, so `{ targetingKey: null }` is a real context to the offline provider.
    it.each([42, true, false, null, undefined, {}, []])(

Whether isEmptyContext should also treat null as absent is a real decision and bigger than this PR; I would rather flag it than smuggle it in. If you want it tracked, it is a one-line change with an offline test.

'uses the anonymous subject for a non-string final targeting key %p',
targetingKey => {
// JavaScript callers can provide values outside the TypeScript contract.
const context = {
targetingKey: targetingKey as never,
attributes: { plan: 'pro' }
};

expect(processEvaluationContext(context)).toStrictEqual({
targetingKey: '',
attributes: { plan: 'pro' }
});
expect(context.targetingKey).toBe(targetingKey);
expect(InternalLog.log).toHaveBeenCalledTimes(1);
expect(InternalLog.log).toHaveBeenCalledWith(
"The evaluation context targetingKey is not a string. Using the anonymous subject ('') instead.",
SdkVerbosity.WARN
);
}
);

it('keeps primitive attributes and drops non-primitive ones', () => {
expect(
processEvaluationContext({
Expand Down
Loading
Loading