Skip to content

Commit d49e39c

Browse files
tyiuhccursoragent
andcommitted
feat(experiment-tag): wire relay iframe on start (WEB-130)
Inject RelayClient from experiment.ts when RTBT rules are present: non-blocking beginRelaySync after Pass 1 applyVariants, Pass 2 re-apply when behaviors change (storage sync activates with #334). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 845d4bc commit d49e39c

5 files changed

Lines changed: 312 additions & 0 deletions

File tree

packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BehavioralTargetingRules } from '../types';
22

33
import { BehavioralTargetingEvaluator } from './evaluator';
44
import { EventStorageManager } from './event-storage';
5+
import { RelayClient } from './relay-client';
56
import { SessionManager } from './session-manager';
67
import { BehavioralTargeting } from './types';
78

@@ -51,6 +52,55 @@ export class BehavioralTargetingManager {
5152
this.evaluateEvent(eventType);
5253
}
5354

55+
/**
56+
* Attach the relay client for cross-subdomain event dual-write (#334).
57+
*/
58+
public setRelayClient(relayClient: RelayClient | null): void {
59+
(
60+
this.eventStorage as {
61+
setRelayClient?: (client: RelayClient | null) => void;
62+
}
63+
).setRelayClient?.(relayClient);
64+
}
65+
66+
/**
67+
* Pass 2: migrate local events to relay if needed, merge relay store, re-evaluate.
68+
* Returns true when relay store was merged (#334).
69+
*/
70+
public async syncFromRelay(): Promise<boolean> {
71+
const sync = (
72+
this.eventStorage as { syncFromRelay?: () => Promise<boolean> }
73+
).syncFromRelay;
74+
if (!sync) {
75+
return false;
76+
}
77+
const synced = await sync.call(this.eventStorage);
78+
if (synced) {
79+
this.evaluateAll();
80+
}
81+
return synced;
82+
}
83+
84+
/**
85+
* WEB-130: inject relay iframe (non-blocking init) and run Pass 2 sync when
86+
* event-storage relay hooks are present (#334).
87+
*
88+
* @returns true when matched behaviors changed after relay sync
89+
*/
90+
public async beginRelaySync(relayClient: RelayClient): Promise<boolean> {
91+
const behaviorsBefore = this.serializeMatchedBehaviors();
92+
await relayClient.init();
93+
if (!relayClient.relayAvailable) {
94+
return false;
95+
}
96+
this.setRelayClient(relayClient);
97+
const synced = await this.syncFromRelay();
98+
if (!synced) {
99+
return false;
100+
}
101+
return behaviorsBefore !== this.serializeMatchedBehaviors();
102+
}
103+
54104
/**
55105
* Check if a flag has behavioral targeting rules.
56106
* @param flagKey The flag key to check
@@ -175,6 +225,14 @@ export class BehavioralTargetingManager {
175225
* @param flagKey The flag key to evaluate
176226
* @param behaviorId The specific behavior ID to evaluate
177227
*/
228+
private serializeMatchedBehaviors(): string {
229+
const snapshot: Record<string, string[]> = {};
230+
for (const [flagKey, behaviorIds] of this.matchedBehaviors.entries()) {
231+
snapshot[flagKey] = [...behaviorIds].sort();
232+
}
233+
return JSON.stringify(snapshot);
234+
}
235+
178236
private evaluateBehaviorId(flagKey: string, behaviorId: string): void {
179237
const rulesByIds = this.rules[flagKey];
180238
if (!rulesByIds || !rulesByIds[behaviorId]) {

packages/experiment-tag/src/experiment.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import * as domMutatorExports from 'dom-mutator';
2424
import type { MutationController } from 'dom-mutator/dist/types';
2525

2626
import { BehavioralTargetingManager } from './behavioral-targeting';
27+
import { getRelayUrl, RelayClient } from './behavioral-targeting/relay-client';
28+
import { WEB_EXP_ID_V2_PATTERN } from './behavioral-targeting/relay-protocol';
2729
import { showPreviewModeModal } from './preview/preview';
2830
import { MessageBus } from './subscriptions/message-bus';
2931
import {
@@ -138,6 +140,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
138140
public readonly behavioralTargetingManager:
139141
| BehavioralTargetingManager
140142
| undefined;
143+
private relayClient: RelayClient | null = null;
141144
private subscriptionManager: SubscriptionManager | undefined;
142145
private isVisualEditorMode = false;
143146
private isDebugActive = false;
@@ -466,6 +469,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
466469
Object.keys(this.previewFlags).includes(key),
467470
)
468471
) {
472+
this.scheduleRelaySync(enrichedUser);
469473
this.isRunning = true;
470474
flushEventBuffer(this);
471475
return;
@@ -474,6 +478,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
474478
await this.fetchRemoteFlags();
475479
// apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags
476480
await this.applyVariants({ flagKeys: this.remoteFlagKeys });
481+
this.scheduleRelaySync(enrichedUser);
477482
this.isRunning = true;
478483
flushEventBuffer(this);
479484
}
@@ -710,6 +715,61 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
710715
}
711716
}
712717

718+
/**
719+
* Non-blocking WEB-130 relay iframe init + Pass 2 sync (requires #334 storage hooks).
720+
*/
721+
private scheduleRelaySync(user: WebExperimentUser): void {
722+
if (!this.behavioralTargetingManager || this.isVisualEditorMode) {
723+
return;
724+
}
725+
726+
const webExpIdV2 = user.web_exp_id_v2 ?? user.web_exp_id;
727+
if (!webExpIdV2 || !WEB_EXP_ID_V2_PATTERN.test(webExpIdV2)) {
728+
return;
729+
}
730+
731+
this.relayClient?.destroy();
732+
this.relayClient = new RelayClient(
733+
this.apiKey,
734+
webExpIdV2,
735+
getRelayUrl(this.apiKey),
736+
);
737+
738+
void this.behavioralTargetingManager
739+
.beginRelaySync(this.relayClient)
740+
.then((behaviorsChanged) => this.handleRelayPass2(behaviorsChanged))
741+
.catch(() => {
742+
// relay failure is local-only fallback
743+
});
744+
}
745+
746+
/**
747+
* Pass 2: re-apply variants when relay sync changes matched behaviors.
748+
*/
749+
private async handleRelayPass2(behaviorsChanged: boolean): Promise<void> {
750+
if (!behaviorsChanged || !this.behavioralTargetingManager) {
751+
return;
752+
}
753+
754+
this.updateUserWithBehaviors();
755+
756+
const flagKeys = Object.keys(this.behavioralTargetingRules);
757+
const localKeys = flagKeys.filter((key) =>
758+
this.localFlagKeys.includes(key),
759+
);
760+
const remoteKeys = flagKeys.filter((key) =>
761+
this.remoteFlagKeys.includes(key),
762+
);
763+
764+
if (localKeys.length > 0) {
765+
await this.applyVariants({ flagKeys: localKeys });
766+
}
767+
if (remoteKeys.length > 0) {
768+
await this.fetchRemoteFlags();
769+
await this.applyVariants({ flagKeys: remoteKeys });
770+
}
771+
}
772+
713773
/**
714774
* Update the user with matched behavioral targeting IDs.
715775
* Sets the user property `behavioral_targeting` to an array of all matched behavior IDs.

packages/experiment-tag/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ export interface WebExperimentClient {
173173

174174
export type WebExperimentUser = {
175175
web_exp_id?: string;
176+
/** Cross-subdomain visitor ID (#332); falls back to web_exp_id for relay wiring. */
177+
web_exp_id_v2?: string;
176178
} & ExperimentUser;
177179

178180
export type InitConfigs = {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { EvaluationOperator } from '@amplitude/experiment-core';
2+
import { BehavioralTargetingManager } from 'src/behavioral-targeting/behavioral-targeting-manager';
3+
import {
4+
RelayClient,
5+
getRelayUrl,
6+
} from 'src/behavioral-targeting/relay-client';
7+
import { RELAY_READY_MESSAGE } from 'src/behavioral-targeting/relay-protocol';
8+
9+
const API_KEY = 'test-api-key';
10+
const WEB_EXP_ID_V2 = 'oeu1383080393924r0-5047421827912331';
11+
const RELAY_URL = getRelayUrl(API_KEY);
12+
const RELAY_ORIGIN = 'https://cdn.amplitude.com';
13+
14+
describe('BehavioralTargetingManager relay wiring', () => {
15+
let relayClient: RelayClient | null = null;
16+
17+
beforeEach(() => {
18+
jest.useFakeTimers();
19+
localStorage.clear();
20+
sessionStorage.clear();
21+
document.body.innerHTML = '';
22+
});
23+
24+
afterEach(() => {
25+
relayClient?.destroy();
26+
relayClient = null;
27+
jest.useRealTimers();
28+
});
29+
30+
const signalRelayReady = () => {
31+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
32+
const iframeWindow = { postMessage: jest.fn() };
33+
Object.defineProperty(iframe, 'contentWindow', {
34+
value: iframeWindow,
35+
configurable: true,
36+
});
37+
window.dispatchEvent(
38+
new MessageEvent('message', {
39+
data: RELAY_READY_MESSAGE,
40+
source: iframeWindow as unknown as MessageEventSource,
41+
origin: RELAY_ORIGIN,
42+
}),
43+
);
44+
return iframeWindow;
45+
};
46+
47+
test('beginRelaySync injects relay iframe and returns false without storage hooks', async () => {
48+
const manager = new BehavioralTargetingManager(API_KEY, {
49+
flag_a: {
50+
behavior_1: [
51+
[
52+
{
53+
condition: {
54+
type: 'behavior',
55+
event_type: 'click',
56+
op: EvaluationOperator.GREATER_THAN_EQUALS,
57+
value: 1,
58+
time_type: 'rolling',
59+
time_value: 7,
60+
interval: 'day',
61+
},
62+
},
63+
],
64+
],
65+
},
66+
});
67+
68+
relayClient = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL);
69+
const initPromise = manager.beginRelaySync(relayClient);
70+
await jest.runAllTimersAsync();
71+
signalRelayReady();
72+
await initPromise;
73+
74+
expect(document.querySelector('iframe')).not.toBeNull();
75+
expect(relayClient.relayAvailable).toBe(true);
76+
expect(await manager.syncFromRelay()).toBe(false);
77+
});
78+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import * as experimentCore from '@amplitude/experiment-core';
2+
import { EvaluationOperator } from '@amplitude/experiment-core';
3+
import { ExperimentClient } from '@amplitude/experiment-js-client';
4+
import { RelayClient } from 'src/behavioral-targeting/relay-client';
5+
import { DefaultWebExperimentClient } from 'src/experiment';
6+
import { stringify } from 'ts-jest';
7+
8+
import { createPageObject } from './util/create-page-object';
9+
import { createMockGlobal, setupGlobalObservers } from './util/mocks';
10+
11+
const mockRelayInit = jest.fn().mockResolvedValue(undefined);
12+
const mockRelayDestroy = jest.fn();
13+
14+
jest.mock('src/behavioral-targeting/relay-client', () => {
15+
const actual = jest.requireActual('src/behavioral-targeting/relay-client');
16+
return {
17+
...actual,
18+
RelayClient: jest.fn().mockImplementation(() => ({
19+
init: mockRelayInit,
20+
destroy: mockRelayDestroy,
21+
relayAvailable: false,
22+
})),
23+
};
24+
});
25+
26+
setupGlobalObservers();
27+
28+
describe('DefaultWebExperimentClient relay iframe (WEB-130)', () => {
29+
let apiKey = 0;
30+
const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope');
31+
let mockGlobal: ReturnType<typeof createMockGlobal>;
32+
33+
beforeEach(() => {
34+
apiKey++;
35+
jest.clearAllMocks();
36+
jest.spyOn(experimentCore, 'isLocalStorageAvailable').mockReturnValue(true);
37+
mockGlobal = createMockGlobal();
38+
mockGetGlobalScope.mockReturnValue(
39+
mockGlobal as unknown as typeof globalThis,
40+
);
41+
jest.spyOn(ExperimentClient.prototype, 'setUser').mockImplementation();
42+
jest.spyOn(ExperimentClient.prototype, 'all').mockReturnValue({});
43+
jest
44+
.spyOn(ExperimentClient.prototype, 'fetch')
45+
.mockResolvedValue({} as never);
46+
});
47+
48+
test('starts relay sync when behavioral targeting rules are present', async () => {
49+
const key = stringify(apiKey);
50+
const behavioralTargetingRules = {
51+
flag_a: {
52+
behavior_1: [
53+
[
54+
{
55+
condition: {
56+
type: 'behavior',
57+
event_type: 'click',
58+
op: EvaluationOperator.GREATER_THAN_EQUALS,
59+
value: 1,
60+
time_type: 'rolling',
61+
time_value: 7,
62+
interval: 'day',
63+
},
64+
},
65+
],
66+
],
67+
},
68+
};
69+
70+
const client = DefaultWebExperimentClient.getInstance(key, {
71+
initialFlags: JSON.stringify([]),
72+
pageObjects: JSON.stringify({
73+
flag_a: createPageObject(
74+
'A',
75+
'url_change',
76+
undefined,
77+
'http://test.com',
78+
),
79+
}),
80+
behavioralTargetingRules: JSON.stringify(behavioralTargetingRules),
81+
});
82+
83+
mockGlobal.localStorage.getItem.mockImplementation((storageKey: string) => {
84+
if (storageKey === `EXP_${key.slice(0, 10)}`) {
85+
return JSON.stringify({
86+
web_exp_id: 'oeu1383080393924r0-5047421827912331',
87+
});
88+
}
89+
return null;
90+
});
91+
92+
await client.start();
93+
94+
expect(RelayClient).toHaveBeenCalledWith(
95+
key,
96+
'oeu1383080393924r0-5047421827912331',
97+
expect.stringContaining('.relay.html'),
98+
);
99+
expect(mockRelayInit).toHaveBeenCalled();
100+
});
101+
102+
test('skips relay sync when there are no behavioral targeting rules', async () => {
103+
const key = stringify(apiKey);
104+
const client = DefaultWebExperimentClient.getInstance(key, {
105+
initialFlags: JSON.stringify([]),
106+
pageObjects: JSON.stringify({}),
107+
});
108+
109+
await client.start();
110+
111+
expect(RelayClient).not.toHaveBeenCalled();
112+
expect(mockRelayInit).not.toHaveBeenCalled();
113+
});
114+
});

0 commit comments

Comments
 (0)