Skip to content
5 changes: 5 additions & 0 deletions packages/experiment-browser/src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export type ExperimentUser = {
*/
url_param?: Record<string, string | string[]>;

/**
* Persisted parameters parsed from the URL and stored in local storage.
*/
persisted_url_param?: Record<string, string | string[]>;

/**
* The user agent string.
*/
Expand Down
5 changes: 4 additions & 1 deletion packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
RevertVariantsOptions,
} from './types';
import { applyAntiFlickerCss } from './util/anti-flicker';
import { enrichUserWithCampaignData } from './util/campaign';
import { setMarketingCookie } from './util/cookie';
import { getInjectUtils } from './util/inject-utils';
import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger';
Expand Down Expand Up @@ -247,6 +248,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
}
}

const enrichedUser = await enrichUserWithCampaignData(this.apiKey, user);

// If no integration has been set, use an Amplitude integration.
if (!this.globalScope.experimentIntegration) {
const connector = AnalyticsConnector.getInstance('$default_instance');
Expand All @@ -258,7 +261,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
}
this.globalScope.experimentIntegration.type = 'integration';
this.experimentClient.addPlugin(this.globalScope.experimentIntegration);
this.experimentClient.setUser(user);
this.experimentClient.setUser(enrichedUser);

if (!this.isRemoteBlocking) {
// Remove anti-flicker css if remote flags are not blocking
Expand Down
2 changes: 1 addition & 1 deletion packages/experiment-tag/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const Defaults: WebExperimentConfig = {
*/

export interface WebExperimentClient {
start(): void;
start(): Promise<void>;

getExperimentClient(): ExperimentClient;

Expand Down
79 changes: 79 additions & 0 deletions packages/experiment-tag/src/util/campaign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
type Campaign,
CampaignParser,
CookieStorage,
getStorageKey,
MKTG,
} from '@amplitude/analytics-core';
import { UTMParameters } from '@amplitude/analytics-core/lib/esm/types/campaign';
import { type ExperimentUser } from '@amplitude/experiment-js-client';

import { getStorageItem, setStorageItem } from './storage';

/**
* Enriches the user object's userProperties with UTM parameters based on priority:
* 1. URL params (highest priority)
* 2. experiment-tag persisted props (medium priority)
* 3. analytics-browser persisted props (lowest priority, if using default Amplitude Analytics integration)
*/
export async function enrichUserWithCampaignData(
apiKey: string,
user: ExperimentUser,
): Promise<ExperimentUser> {
const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`;
const [currentCampaign, persistedAmplitudeCampaign] = await fetchCampaignData(
apiKey,
);
const persistedExperimentCampaign = getStorageItem<UTMParameters>(
'localStorage',
experimentStorageKey,
);

// Filter out undefined values and non-UTM parameters
const utmParams: Partial<UTMParameters> = {};
const allCampaigns = [
persistedAmplitudeCampaign, // lowest priority
persistedExperimentCampaign, // medium prioirty
currentCampaign, // highest priority
];

for (const campaign of allCampaigns) {
if (campaign) {
for (const [key, value] of Object.entries(campaign)) {
if (key.startsWith('utm_') && value !== undefined) {
utmParams[key] = value;
}
}
}
}

if (Object.keys(utmParams).length > 0) {
persistUrlParams(apiKey, utmParams);
return {
...user,
persisted_url_param: utmParams,
};
}
return user;
}

/**
* Persists UTM parameters from the current URL to experiment-tag storage
*/
export function persistUrlParams(
apiKey: string,
campaign: Record<string, string>,
): void {
const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`;
setStorageItem('localStorage', experimentStorageKey, campaign);
}

async function fetchCampaignData(
apiKey: string,
): Promise<[Campaign, Campaign | undefined]> {
const storage = new CookieStorage<Campaign>();
const storageKey = getStorageKey(apiKey, MKTG);
const currentCampaign = await new CampaignParser().parse();
const previousCampaign = await storage.get(storageKey);
return [currentCampaign, previousCampaign];
}
Loading