Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d49e39c
feat(experiment-tag): wire relay iframe on start (WEB-130)
tyiuhc Jun 16, 2026
497f859
fix(experiment-tag): cleanup relay client after failed init (WEB-130)
tyiuhc Jun 16, 2026
60cf247
fix(experiment-tag): guard relay callbacks against stale clients (WEB…
tyiuhc Jun 16, 2026
c13dbe1
chore(experiment-tag): drop web_exp_id_v2 pattern gate in relay sync
tyiuhc Jun 16, 2026
e59bc99
chore(experiment-tag): remove ticket refs from relay comments
tyiuhc Jun 16, 2026
719cb4d
fix(experiment-tag): address Bugbot late relay ready and sync timing
tyiuhc Jun 16, 2026
c06012e
fix(experiment-tag): address Bugbot relay flush and sync retention
tyiuhc Jun 17, 2026
9c00bef
refactor(experiment-tag): simplify relay sync lifecycle
tyiuhc Jun 17, 2026
6794a74
fix(experiment-tag): address Bugbot relay write and client replacement
tyiuhc Jun 17, 2026
f9ee4e3
feat(experiment-tag): add cross-subdomain web_exp_id_v2 and first_see…
tyiuhc Jun 17, 2026
5f7d421
Merge branch 'web/rtbt-relay-client' into web/rtbt-relay-iframe
tyiuhc Jun 17, 2026
324117a
fix(experiment-tag): centralize relay attach ownership in scheduleRel…
tyiuhc Jun 17, 2026
0d7b445
Merge web/rtbt-relay-client: server-zone relay URL routing
tyiuhc Jun 22, 2026
db7006f
feat(experiment-tag): pass server zone when building relay URL
tyiuhc Jun 22, 2026
2ac99ac
Merge web/rtbt-relay-client: simplify relay waitForAvailable
tyiuhc Jun 22, 2026
59c9a47
chore(release): publish
amplitude-sdk-bot Jun 26, 2026
1ce77b1
Merge web/rtbt-relay-client: relayUrl config override
tyiuhc Jun 26, 2026
06f949c
feat(experiment-tag): pass relayUrl override when building relay URL
tyiuhc Jun 26, 2026
87463c6
Merge remote-tracking branch 'origin/main' into web/rtbt-relay-iframe
tyiuhc Jun 26, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BehavioralTargetingRules } from '../types';

import { BehavioralTargetingEvaluator } from './evaluator';
import { EventStorageManager } from './event-storage';
import { RelayClient } from './relay-client';
import { SessionManager } from './session-manager';
import { BehavioralTargeting } from './types';

Expand Down Expand Up @@ -51,6 +52,48 @@ export class BehavioralTargetingManager {
this.evaluateEvent(eventType);
}

/**
* Attach the relay client for cross-subdomain event dual-write.
*/
public setRelayClient(relayClient: RelayClient | null): void {
this.eventStorage.setRelayClient(relayClient);
}

/**
* Pass 2: migrate local events to relay if needed, merge relay store, re-evaluate.
* Returns true when relay store was merged.
*/
public async syncFromRelay(): Promise<boolean> {
const synced = await this.eventStorage.syncFromRelay();
if (synced) {
this.evaluateAll();
}
return synced;
}

/**
* Inject relay iframe (non-blocking init) and run Pass 2 sync.
*
* @returns true when matched behaviors changed after relay sync
*/
public async beginRelaySync(relayClient: RelayClient): Promise<boolean> {
const behaviorsBefore = this.serializeMatchedBehaviors();
this.setRelayClient(relayClient);
await relayClient.init();
if (!relayClient.relayAvailable) {
await relayClient.waitForAvailable();
}
if (!relayClient.relayAvailable) {
this.setRelayClient(null);
return false;
}
const synced = await this.syncFromRelay();
if (!synced) {
return false;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
return behaviorsBefore !== this.serializeMatchedBehaviors();
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Check if a flag has behavioral targeting rules.
* @param flagKey The flag key to check
Expand Down Expand Up @@ -175,6 +218,14 @@ export class BehavioralTargetingManager {
* @param flagKey The flag key to evaluate
* @param behaviorId The specific behavior ID to evaluate
*/
private serializeMatchedBehaviors(): string {
const snapshot: Record<string, string[]> = {};
for (const [flagKey, behaviorIds] of this.matchedBehaviors.entries()) {
snapshot[flagKey] = [...behaviorIds].sort();
}
return JSON.stringify(snapshot);
}

private evaluateBehaviorId(flagKey: string, behaviorId: string): void {
const rulesByIds = this.rules[flagKey];
if (!rulesByIds || !rulesByIds[behaviorId]) {
Expand Down
82 changes: 69 additions & 13 deletions packages/experiment-tag/src/behavioral-targeting/relay-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class RelayClient {
private initResolve: (() => void) | null = null;
private cancelBodyReadyPoll: (() => void) | null = null;
private destroyed = false;
private readonly availableWaiters: Array<() => void> = [];

constructor(
private readonly apiKey: string,
Expand All @@ -75,6 +76,46 @@ export class RelayClient {
return this.available;
}

private notifyAvailable(): void {
const waiters = [...this.availableWaiters];
this.availableWaiters.length = 0;
for (const waiter of waiters) {
waiter();
}
}

/**
* Resolves when the relay becomes available, or after timeout.
* Use after init() when the init timer may have fired before RELAY_READY.
*/
waitForAvailable(timeoutMs = RELAY_RPC_TIMEOUT_MS): Promise<boolean> {
if (this.destroyed) {
return Promise.resolve(false);
}
if (this.available) {
return Promise.resolve(true);
}

return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timeoutId);
const idx = this.availableWaiters.indexOf(onAvailable);
if (idx !== -1) {
this.availableWaiters.splice(idx, 1);
}
resolve(this.available && !this.destroyed);
};
const onAvailable = () => finish();
this.availableWaiters.push(onAvailable);
const timeoutId = window.setTimeout(() => finish(), timeoutMs);
});
}

async init(): Promise<void> {
if (this.initPromise) {
return this.initPromise;
Expand Down Expand Up @@ -131,6 +172,7 @@ export class RelayClient {
this.iframeWindow = iframe.contentWindow;
this.available = true;
this.flush();
this.notifyAvailable();
finishInit();
return;
}
Expand Down Expand Up @@ -189,28 +231,41 @@ export class RelayClient {
if (this.destroyed) {
return;
}

// Queue until async write confirms — flush() can resend in-flight events on unload.
this.pendingWrites.push(event);
this.sendPendingWrite(event);
}

private removeConfirmedWrite(event: RelayEventRecord): void {
const idx = this.pendingWrites.indexOf(event);
if (idx !== -1) {
this.pendingWrites.splice(idx, 1);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

private sendPendingWrite(event: RelayEventRecord): void {
if (!this.available || !this.iframeWindow) {
this.pendingWrites.push(event);
return;
}
void this.sendRequest(
this.createRelayRequest('WRITE_EVENT', { event }),
).catch(() => {
// fire-and-forget
});

void this.sendRequest(this.createRelayRequest('WRITE_EVENT', { event }))
.then((response) => {
if (response.ok) {
this.removeConfirmedWrite(event);
}
})
.catch(() => {
// Keep in pendingWrites for a later flush()
});
}

flush(): void {
if (!this.available || !this.iframeWindow) {
return;
}
const writes = [...this.pendingWrites];
this.pendingWrites = [];
for (const event of writes) {
this.iframeWindow.postMessage(
this.createRelayRequest('WRITE_EVENT', { event }),
this.relayOrigin,
);
for (const event of [...this.pendingWrites]) {
this.sendPendingWrite(event);
}
}

Expand Down Expand Up @@ -256,6 +311,7 @@ export class RelayClient {
pending.reject(new Error('relay destroyed'));
}
this.pendingRequests.clear();
this.availableWaiters.length = 0;
this.iframe?.remove();
this.iframe = null;
this.iframeWindow = null;
Expand Down
76 changes: 76 additions & 0 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as domMutatorExports from 'dom-mutator';
import type { MutationController } from 'dom-mutator/dist/types';

import { BehavioralTargetingManager } from './behavioral-targeting';
import { getRelayUrl, RelayClient } from './behavioral-targeting/relay-client';
import { showPreviewModeModal } from './preview/preview';
import { MessageBus } from './subscriptions/message-bus';
import {
Expand Down Expand Up @@ -138,6 +139,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
public readonly behavioralTargetingManager:
| BehavioralTargetingManager
| undefined;
private relayClient: RelayClient | null = null;
private subscriptionManager: SubscriptionManager | undefined;
private isVisualEditorMode = false;
private isDebugActive = false;
Expand Down Expand Up @@ -466,6 +468,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
Object.keys(this.previewFlags).includes(key),
)
) {
this.scheduleRelaySync(enrichedUser);
this.isRunning = true;
flushEventBuffer(this);
return;
Expand All @@ -474,6 +477,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
await this.fetchRemoteFlags();
// apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags
await this.applyVariants({ flagKeys: this.remoteFlagKeys });
this.scheduleRelaySync(enrichedUser);
this.isRunning = true;
flushEventBuffer(this);
}
Expand Down Expand Up @@ -710,6 +714,78 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
}
}

/**
* Non-blocking relay iframe init + Pass 2 sync.
*/
private scheduleRelaySync(user: WebExperimentUser): void {
if (!this.behavioralTargetingManager || this.isVisualEditorMode) {
return;
}

const webExpIdV2 = user.web_exp_id_v2 ?? user.web_exp_id;
if (!webExpIdV2) {
return;
}

this.relayClient?.destroy();
const relayClient = new RelayClient(
this.apiKey,
webExpIdV2,
getRelayUrl(this.apiKey),
);
this.relayClient = relayClient;

void this.behavioralTargetingManager
.beginRelaySync(relayClient)
.then((behaviorsChanged) => {
if (this.relayClient !== relayClient) {
return;
}
if (!relayClient.relayAvailable) {
relayClient.destroy();
this.relayClient = null;
return;
}
return this.handleRelayPass2(behaviorsChanged).catch((pass2Error) => {
console.warn('Experiment relay Pass 2 failed:', pass2Error);
});
})
.catch(() => {
if (this.relayClient !== relayClient) {
return;
}
relayClient.destroy();
this.relayClient = null;
});
Comment thread
tyiuhc marked this conversation as resolved.
Comment thread
tyiuhc marked this conversation as resolved.
Comment thread
tyiuhc marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}

/**
* Pass 2: re-apply variants when relay sync changes matched behaviors.
*/
private async handleRelayPass2(behaviorsChanged: boolean): Promise<void> {
if (!behaviorsChanged || !this.behavioralTargetingManager) {
return;
}

this.updateUserWithBehaviors();

const flagKeys = Object.keys(this.behavioralTargetingRules);
const localKeys = flagKeys.filter((key) =>
this.localFlagKeys.includes(key),
);
const remoteKeys = flagKeys.filter((key) =>
this.remoteFlagKeys.includes(key),
);

if (localKeys.length > 0) {
await this.applyVariants({ flagKeys: localKeys });
}
if (remoteKeys.length > 0) {
await this.fetchRemoteFlags();
await this.applyVariants({ flagKeys: remoteKeys });
}
}

/**
* Update the user with matched behavioral targeting IDs.
* Sets the user property `behavioral_targeting` to an array of all matched behavior IDs.
Expand Down
2 changes: 2 additions & 0 deletions packages/experiment-tag/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ export interface WebExperimentClient {

export type WebExperimentUser = {
web_exp_id?: string;
/** Cross-subdomain visitor ID; falls back to web_exp_id for relay wiring. */
web_exp_id_v2?: string;
} & ExperimentUser;

export type InitConfigs = {
Expand Down
Loading
Loading