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
1 change: 1 addition & 0 deletions csr/csr-common/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export type FlagEvaluationPluginData = {
payload: {
flagKey: string;
variant: string;
assignmentOrigin: string;
};
};

Expand Down
37 changes: 23 additions & 14 deletions csr/session-recording/src/flag-observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,62 +11,71 @@ describe('observeFlags', () => {
const writes: FlagWrite[] = [];
observeFlags(w => writes.push(w));

(window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a' };
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a', assignmentOrigin: 'rule-1' };

expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a' }]);
expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a', assignmentOrigin: 'rule-1' }]);
});

it('emits snapshot entries for pre-existing flags', () => {
(window as any).__confidence = {
flags: {
'flag-a': { variant: 'v1' },
'flag-b': { variant: 'v2' },
'flag-a': { variant: 'v1', assignmentOrigin: 'rule-a' },
'flag-b': { variant: 'v2', assignmentOrigin: 'rule-b' },
},
};

const writes: FlagWrite[] = [];
observeFlags(w => writes.push(w));

expect(writes).toContainEqual({ flagKey: 'flag-a', variant: 'v1' });
expect(writes).toContainEqual({ flagKey: 'flag-b', variant: 'v2' });
expect(writes).toContainEqual({ flagKey: 'flag-a', variant: 'v1', assignmentOrigin: 'rule-a' });
expect(writes).toContainEqual({ flagKey: 'flag-b', variant: 'v2', assignmentOrigin: 'rule-b' });
});

it('defaults assignmentOrigin to empty string when missing', () => {
const writes: FlagWrite[] = [];
observeFlags(w => writes.push(w));

(window as any).__confidence.flags['my-flag'] = { variant: 'treatment-a' };

expect(writes).toEqual([{ flagKey: 'my-flag', variant: 'treatment-a', assignmentOrigin: '' }]);
});

it('observes new writes after reading the snapshot', () => {
(window as any).__confidence = {
flags: { existing: { variant: 'old' } },
flags: { existing: { variant: 'old', assignmentOrigin: 'rule-old' } },
};

const writes: FlagWrite[] = [];
observeFlags(w => writes.push(w));

(window as any).__confidence.flags['new-flag'] = { variant: 'new' };
(window as any).__confidence.flags['new-flag'] = { variant: 'new', assignmentOrigin: 'rule-new' };

expect(writes).toHaveLength(2);
expect(writes[0]).toEqual({ flagKey: 'existing', variant: 'old' });
expect(writes[1]).toEqual({ flagKey: 'new-flag', variant: 'new' });
expect(writes[0]).toEqual({ flagKey: 'existing', variant: 'old', assignmentOrigin: 'rule-old' });
expect(writes[1]).toEqual({ flagKey: 'new-flag', variant: 'new', assignmentOrigin: 'rule-new' });
});

it('cleanup replaces proxy with plain copy', () => {
const writes: FlagWrite[] = [];
const cleanup = observeFlags(w => writes.push(w));

(window as any).__confidence.flags['flag-a'] = { variant: 'v1' };
(window as any).__confidence.flags['flag-a'] = { variant: 'v1', assignmentOrigin: 'rule-1' };
expect(writes).toHaveLength(1);

cleanup();

(window as any).__confidence.flags['flag-b'] = { variant: 'v2' };
(window as any).__confidence.flags['flag-b'] = { variant: 'v2', assignmentOrigin: 'rule-2' };
expect(writes).toHaveLength(1);
});

it('preserves data after cleanup', () => {
observeFlags(() => {});
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment' };
(window as any).__confidence.flags['my-flag'] = { variant: 'treatment', assignmentOrigin: 'rule-1' };

const cleanup = observeFlags(() => {});
cleanup();

expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment' });
expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment', assignmentOrigin: 'rule-1' });
});

it('ignores writes with missing variant', () => {
Expand Down
14 changes: 9 additions & 5 deletions csr/session-recording/src/flag-observer.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
export type FlagWrite = { flagKey: string; variant: string };
export type FlagWrite = { flagKey: string; variant: string; assignmentOrigin: string };
export type FlagWriteCallback = (write: FlagWrite) => void;

export function observeFlags(onFlagWrite: FlagWriteCallback): () => void {
if (typeof window === 'undefined') return () => {};

const confidence = ((window as any).__confidence ??= {});
const existing: Record<string, { variant: string }> = confidence.flags ?? {};
const target: Record<string, { variant: string }> = { ...existing };
const existing: Record<string, { variant: string; assignmentOrigin?: string }> = confidence.flags ?? {};
const target: Record<string, { variant: string; assignmentOrigin?: string }> = { ...existing };

const proxy = new Proxy(target, {
set(_target, prop, value) {
if (typeof prop === 'string' && value && typeof value.variant === 'string') {
_target[prop] = value;
onFlagWrite({ flagKey: prop, variant: value.variant });
onFlagWrite({ flagKey: prop, variant: value.variant, assignmentOrigin: value.assignmentOrigin ?? '' });
}
return true;
},
Expand All @@ -22,7 +22,11 @@ export function observeFlags(onFlagWrite: FlagWriteCallback): () => void {

for (const [name, data] of Object.entries(existing)) {
if (data && typeof (data as any).variant === 'string') {
onFlagWrite({ flagKey: name, variant: (data as any).variant });
onFlagWrite({
flagKey: name,
variant: (data as any).variant,
assignmentOrigin: (data as any).assignmentOrigin ?? '',
});
}
}

Expand Down
4 changes: 2 additions & 2 deletions csr/session-recording/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,10 @@ export function initSessionRecorder(options: InitSessionRecorderOptions): Sessio

stopRecorder = record(sendEvent, recordingConfig);

stopObservingFlags = observeFlags(({ flagKey, variant }) => {
stopObservingFlags = observeFlags(({ flagKey, variant, assignmentOrigin }) => {
const data: FlagEvaluationPluginData = {
plugin: 'csr:flagEvaluation',
payload: { flagKey, variant },
payload: { flagKey, variant, assignmentOrigin },
};
sendEvent?.({
type: RecordingEventType.Plugin,
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/proto/confidence/flags/resolver/v1/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,7 @@ message ResolvedFlag {

// Determines whether the flag should be applied in the clients
bool should_apply = 6;

// The name of the rule that matched the flag assignment.
string assignment_origin = 7;
}
13 changes: 13 additions & 0 deletions packages/sdk/src/Confidence.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Confidence } from './Confidence';
import { publishFlagEvaluation } from './flag-evaluation-global';

jest.mock('./flag-evaluation-global');

describe('Confidence E2E Tests', () => {
const loggerMock = {
Expand All @@ -25,6 +28,16 @@ describe('Confidence E2E Tests', () => {
});
});

it('should publish assignmentOrigin on evaluation', async () => {
await confidence.withContext({ targeting_key: 'test-b' }).evaluateFlag('web-sdk-e2e-flag.int', 0);

expect(publishFlagEvaluation).toHaveBeenCalledWith(
'flags/web-sdk-e2e-flag',
'flags/web-sdk-e2e-flag/variants/treatment',
'flags/web-sdk-e2e-flag/rules/anxow1dwkiydabdtlizm',
);
});

it('should evaluate a flag with null pants context', async () => {
const evaluation = await confidence
// @ts-expect-error null is not a valid Value type
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk/src/FlagResolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ function makeResolvedFlag({
value = { my_bool: true, my_string: 'hello' } as { [key: string]: any } | undefined,
reason = ResolveReason.RESOLVE_REASON_MATCH,
shouldApply = true,
assignmentOrigin = '',
flagSchema = {
schema: {
my_bool: { boolSchema: {} },
my_string: { stringSchema: {} },
},
} as any,
} = {}) {
return { flag, variant, value, reason, shouldApply, flagSchema };
return { flag, variant, value, reason, shouldApply, assignmentOrigin, flagSchema };
}

describe('FlagResolution', () => {
Expand All @@ -37,7 +38,7 @@ describe('FlagResolution', () => {

resolution.evaluate('test-flag.my_bool', false);

expect(publishFlagEvaluation).toHaveBeenCalledWith('flags/test-flag', 'flags/test-flag/variants/treatment');
expect(publishFlagEvaluation).toHaveBeenCalledWith('flags/test-flag', 'flags/test-flag/variants/treatment', '');
});

it('does not publish on NO_SEGMENT_MATCH', () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk/src/FlagResolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type ResolvedFlag = {
| 'TARGETING_KEY_ERROR'
| 'ERROR';
shouldApply: boolean;
assignmentOrigin: string;
};

export type Applier = (flagName: string) => void;
Expand All @@ -65,7 +66,7 @@ export class ReadyFlagResolution implements FlagResolution {
private readonly onEvaluation?: EvaluationObserver,
) {
for (const resolvedFlag of resolveResponse.resolvedFlags) {
const { flag, variant, value, reason, flagSchema } = resolvedFlag;
const { flag, variant, value, reason, flagSchema, assignmentOrigin } = resolvedFlag;
const name = flag.slice(FLAG_PREFIX.length);

const schema = flagSchema ? Schema.parse({ structSchema: flagSchema }) : Schema.ANY;
Expand All @@ -74,6 +75,7 @@ export class ReadyFlagResolution implements FlagResolution {
value: value! as Value.Struct,
variant,
reason: toEvaluationReason(reason),
assignmentOrigin,
set shouldApply(value) {
resolvedFlag.shouldApply = value;
},
Expand Down Expand Up @@ -127,7 +129,7 @@ export class ReadyFlagResolution implements FlagResolution {

const value = (rawValue === null || rawValue === undefined ? defaultValue : rawValue) as T;

publishFlagEvaluation(FLAG_PREFIX + name, flag.variant);
publishFlagEvaluation(FLAG_PREFIX + name, flag.variant, flag.assignmentOrigin);

result = {
reason,
Expand Down
47 changes: 28 additions & 19 deletions packages/sdk/src/flag-evaluation-global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,41 @@ describe('publishFlagEvaluation', () => {
delete (window as any).__confidence;
});

it('writes { variant } to window.__confidence.flags', () => {
publishFlagEvaluation('my-flag', 'treatment-a');
it('writes { variant, assignmentOrigin } to window.__confidence.flags', () => {
publishFlagEvaluation('my-flag', 'treatment-a', 'rule-1');

expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment-a' });
expect((window as any).__confidence.flags['my-flag']).toEqual({
variant: 'treatment-a',
assignmentOrigin: 'rule-1',
});
});

it('initializes window.__confidence and flags if missing', () => {
expect((window as any).__confidence).toBeUndefined();

publishFlagEvaluation('my-flag', 'control');
publishFlagEvaluation('my-flag', 'control', '');

expect((window as any).__confidence).toBeDefined();
expect((window as any).__confidence.flags).toBeDefined();
expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'control' });
expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'control', assignmentOrigin: '' });
});

it('preserves existing flags object', () => {
const existing = { 'other-flag': { variant: 'baseline' } };
const existing = { 'other-flag': { variant: 'baseline', assignmentOrigin: '' } };
(window as any).__confidence = { flags: existing };

publishFlagEvaluation('my-flag', 'treatment-a');
publishFlagEvaluation('my-flag', 'treatment-a', 'rule-1');

expect((window as any).__confidence.flags).toBe(existing);
expect(existing['other-flag']).toEqual({ variant: 'baseline' });
expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment-a' });
expect(existing['other-flag']).toEqual({ variant: 'baseline', assignmentOrigin: '' });
expect((window as any).__confidence.flags['my-flag']).toEqual({
variant: 'treatment-a',
assignmentOrigin: 'rule-1',
});
});

it('deduplicates: skips write if variant is unchanged', () => {
const flags: Record<string, { variant: string }> = {};
const flags: Record<string, { variant: string; assignmentOrigin: string }> = {};
(window as any).__confidence = { flags };
const spy = jest.fn();
const proxy = new Proxy(flags, {
Expand All @@ -48,24 +54,27 @@ describe('publishFlagEvaluation', () => {
});
(window as any).__confidence.flags = proxy;

publishFlagEvaluation('my-flag', 'treatment-a');
publishFlagEvaluation('my-flag', 'treatment-a');
publishFlagEvaluation('my-flag', 'treatment-a', 'rule-1');
publishFlagEvaluation('my-flag', 'treatment-a', 'rule-1');

expect(spy).toHaveBeenCalledTimes(1);
});

it('writes when variant changes for same flag', () => {
publishFlagEvaluation('my-flag', 'treatment-a');
publishFlagEvaluation('my-flag', 'treatment-b');
publishFlagEvaluation('my-flag', 'treatment-a', 'rule-1');
publishFlagEvaluation('my-flag', 'treatment-b', 'rule-2');

expect((window as any).__confidence.flags['my-flag']).toEqual({ variant: 'treatment-b' });
expect((window as any).__confidence.flags['my-flag']).toEqual({
variant: 'treatment-b',
assignmentOrigin: 'rule-2',
});
});

it('supports multiple flags', () => {
publishFlagEvaluation('flag-a', 'variant-1');
publishFlagEvaluation('flag-b', 'variant-2');
publishFlagEvaluation('flag-a', 'variant-1', 'rule-1');
publishFlagEvaluation('flag-b', 'variant-2', 'rule-2');

expect((window as any).__confidence.flags['flag-a']).toEqual({ variant: 'variant-1' });
expect((window as any).__confidence.flags['flag-b']).toEqual({ variant: 'variant-2' });
expect((window as any).__confidence.flags['flag-a']).toEqual({ variant: 'variant-1', assignmentOrigin: 'rule-1' });
expect((window as any).__confidence.flags['flag-b']).toEqual({ variant: 'variant-2', assignmentOrigin: 'rule-2' });
});
});
5 changes: 3 additions & 2 deletions packages/sdk/src/flag-evaluation-global.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
interface ConfidenceFlagEntry {
variant: string;
assignmentOrigin: string;
}

interface ConfidenceGlobal {
Expand All @@ -12,11 +13,11 @@ declare global {
}
}

export function publishFlagEvaluation(name: string, variant: string): void {
export function publishFlagEvaluation(name: string, variant: string, assignmentOrigin: string): void {
if (typeof window === 'undefined') return;
(window as any).__confidence ??= {};
const confidence = (window as any).__confidence as ConfidenceGlobal;
confidence.flags ??= {};
if (confidence.flags[name]?.variant === variant) return;
confidence.flags[name] = { variant };
confidence.flags[name] = { variant, assignmentOrigin };
}
Loading
Loading