Skip to content

Commit 504f95b

Browse files
authored
feat: support persistent UTM targeting (#226)
1 parent 17d8548 commit 504f95b

File tree

6 files changed

+492
-48
lines changed

6 files changed

+492
-48
lines changed

packages/experiment-browser/src/types/user.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export type ExperimentUser = {
126126
*/
127127
url_param?: Record<string, string | string[]>;
128128

129+
/**
130+
* Persisted parameters parsed from the URL and stored in local storage.
131+
*/
132+
persisted_url_param?: Record<string, string | string[]>;
133+
129134
/**
130135
* The user agent string.
131136
*/

packages/experiment-tag/src/experiment.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
RevertVariantsOptions,
3434
} from './types';
3535
import { applyAntiFlickerCss } from './util/anti-flicker';
36+
import { enrichUserWithCampaignData } from './util/campaign';
3637
import { setMarketingCookie } from './util/cookie';
3738
import { getInjectUtils } from './util/inject-utils';
3839
import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger';
@@ -247,6 +248,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
247248
}
248249
}
249250

251+
const enrichedUser = await enrichUserWithCampaignData(this.apiKey, user);
252+
250253
// If no integration has been set, use an Amplitude integration.
251254
if (!this.globalScope.experimentIntegration) {
252255
const connector = AnalyticsConnector.getInstance('$default_instance');
@@ -258,7 +261,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
258261
}
259262
this.globalScope.experimentIntegration.type = 'integration';
260263
this.experimentClient.addPlugin(this.globalScope.experimentIntegration);
261-
this.experimentClient.setUser(user);
264+
this.experimentClient.setUser(enrichedUser);
262265

263266
if (!this.isRemoteBlocking) {
264267
// Remove anti-flicker css if remote flags are not blocking

packages/experiment-tag/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const Defaults: WebExperimentConfig = {
6262
*/
6363

6464
export interface WebExperimentClient {
65-
start(): void;
65+
start(): Promise<void>;
6666

6767
getExperimentClient(): ExperimentClient;
6868

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
type Campaign,
3+
CampaignParser,
4+
CookieStorage,
5+
getStorageKey,
6+
MKTG,
7+
} from '@amplitude/analytics-core';
8+
import { UTMParameters } from '@amplitude/analytics-core/lib/esm/types/campaign';
9+
import { type ExperimentUser } from '@amplitude/experiment-js-client';
10+
11+
import { getStorageItem, setStorageItem } from './storage';
12+
13+
/**
14+
* Enriches the user object's userProperties with UTM parameters based on priority:
15+
* 1. URL params (highest priority)
16+
* 2. experiment-tag persisted props (medium priority)
17+
* 3. analytics-browser persisted props (lowest priority, if using default Amplitude Analytics integration)
18+
*/
19+
export async function enrichUserWithCampaignData(
20+
apiKey: string,
21+
user: ExperimentUser,
22+
): Promise<ExperimentUser> {
23+
const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`;
24+
const [currentCampaign, persistedAmplitudeCampaign] = await fetchCampaignData(
25+
apiKey,
26+
);
27+
const persistedExperimentCampaign = getStorageItem<UTMParameters>(
28+
'localStorage',
29+
experimentStorageKey,
30+
);
31+
32+
// Filter out undefined values and non-UTM parameters
33+
const utmParams: Partial<UTMParameters> = {};
34+
const allCampaigns = [
35+
persistedAmplitudeCampaign, // lowest priority
36+
persistedExperimentCampaign, // medium prioirty
37+
currentCampaign, // highest priority
38+
];
39+
40+
for (const campaign of allCampaigns) {
41+
if (campaign) {
42+
for (const [key, value] of Object.entries(campaign)) {
43+
if (key.startsWith('utm_') && value !== undefined) {
44+
utmParams[key] = value;
45+
}
46+
}
47+
}
48+
}
49+
50+
if (Object.keys(utmParams).length > 0) {
51+
persistUrlParams(apiKey, utmParams);
52+
return {
53+
...user,
54+
persisted_url_param: utmParams,
55+
};
56+
}
57+
return user;
58+
}
59+
60+
/**
61+
* Persists UTM parameters from the current URL to experiment-tag storage
62+
*/
63+
export function persistUrlParams(
64+
apiKey: string,
65+
campaign: Record<string, string>,
66+
): void {
67+
const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`;
68+
setStorageItem('localStorage', experimentStorageKey, campaign);
69+
}
70+
71+
async function fetchCampaignData(
72+
apiKey: string,
73+
): Promise<[Campaign, Campaign | undefined]> {
74+
const storage = new CookieStorage<Campaign>();
75+
const storageKey = getStorageKey(apiKey, MKTG);
76+
const currentCampaign = await new CampaignParser().parse();
77+
const previousCampaign = await storage.get(storageKey);
78+
return [currentCampaign, previousCampaign];
79+
}

0 commit comments

Comments
 (0)