Skip to content
145 changes: 99 additions & 46 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AnalyticsConnector } from '@amplitude/analytics-connector';
import { CookieStorage } from '@amplitude/analytics-core';
import {
EvaluationFlag,
getGlobalScope,
Expand Down Expand Up @@ -49,6 +50,7 @@ import {
urlWithoutParamsAndAnchor,
concatenateQueryParamsOf,
matchesUrl,
getCookieDomain,
} from './util/url';
import { UUID } from './util/uuid';
import { convertEvaluationVariantToVariant } from './util/variant';
Expand Down Expand Up @@ -277,6 +279,13 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
keyToVariant: this.previewFlags,
});

// fire stored redirect impressions upon startup
this.fireStoredRedirectImpressions().catch();
// Subscribe directly to url_change events to fire redirect impressions
this.messageBus.subscribe('url_change', () => {
this.fireStoredRedirectImpressions().catch();
});

if (
// do not fetch remote flags if all remote flags are in preview mode
this.remoteFlagKeys.every((key) =>
Expand Down Expand Up @@ -353,8 +362,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
this.urlExposureCache[currentUrl] = {};
}

this.fireStoredRedirectImpressions();

for (const key in variants) {
// preview actions are handled by previewVariants
if ((flagKeys && !flagKeys.includes(key)) || this.previewFlags[key]) {
Expand Down Expand Up @@ -580,11 +587,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
return;
}

this.storeRedirectImpressions(flagKey, variant, redirectUrl);
const currentDomain = getCookieDomain(this.globalScope.location.href);
this.storeRedirectImpressions(flagKey, variant, currentDomain, redirectUrl);

// set previous url - relevant for SPA if redirect happens before push/replaceState is complete
this.previousUrl = this.globalScope.location.href;
setMarketingCookie(this.apiKey).then();
setMarketingCookie(this.apiKey, currentDomain).then();
// perform redirection
if (this.customRedirectHandler) {
this.customRedirectHandler(targetUrl);
Expand Down Expand Up @@ -802,57 +810,102 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
private storeRedirectImpressions(
flagKey: string,
variant: Variant,
currentDomain: string | undefined,
redirectUrl: string,
) {
const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`;
// Store the current flag and variant for exposure tracking after redirect
const storedRedirects =
getStorageItem('sessionStorage', redirectStorageKey) || {};
storedRedirects[flagKey] = { redirectUrl, variant };
setStorageItem('sessionStorage', redirectStorageKey, storedRedirects);
}
const redirectDomain = getCookieDomain(redirectUrl);

private fireStoredRedirectImpressions() {
// Check for stored redirects and process them
const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`;
const storedRedirects =
getStorageItem('sessionStorage', redirectStorageKey) || {};

// If we have stored redirects, track exposures for them
if (Object.keys(storedRedirects).length > 0) {
for (const storedFlagKey in storedRedirects) {
const { redirectUrl, variant } = storedRedirects[storedFlagKey];
const currentUrl = urlWithoutParamsAndAnchor(
this.globalScope.location.href,
// Only allow redirects to same root domain for security
if (currentDomain !== redirectDomain) {
console.warn(
`Redirect impressions are only supported for same root domain. Current: ${currentDomain}, Redirect: ${redirectDomain}`,
);
return;
}

const storage = new CookieStorage<
Record<string, { redirectUrl: string; variant: Variant }>
>({
domain: redirectDomain,
sameSite: 'Lax',
expirationDays: 1 / 1440, // 1 minute
});

const storageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`;
storage
.get(storageKey)
.then((storedRedirects) => {
const redirects = storedRedirects || {};
redirects[flagKey] = { redirectUrl, variant };
storage.set(storageKey, redirects).catch();
})
.catch((error) => {
console.error(
`Failed to store redirect impression for ${flagKey}:`,
error,
);
});
}

private async fireStoredRedirectImpressions() {
const storage = new CookieStorage<
Record<string, { redirectUrl: string; variant: Variant }>
>({
domain: getCookieDomain(this.globalScope.location.href),
sameSite: 'Lax',
});

const storageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`;

try {
const storedRedirects = (await storage.get(storageKey)) || {};
if (Object.keys(storedRedirects).length === 0) {
return;
}

const currentUrl = urlWithoutParamsAndAnchor(
this.globalScope.location.href,
);

// Track exposures for redirects that match current URL
for (const flagKey in storedRedirects) {
const { redirectUrl, variant } = storedRedirects[flagKey];
const strippedRedirectUrl = urlWithoutParamsAndAnchor(redirectUrl);
if (matchesUrl([currentUrl], strippedRedirectUrl)) {
// Force variant to ensure original evaluation result is tracked
this.exposureWithDedupe(storedFlagKey, variant, true);

// Remove this flag from stored redirects
delete storedRedirects[storedFlagKey];
if (matchesUrl([currentUrl], strippedRedirectUrl)) {
this.exposureWithDedupe(flagKey, variant, true);
delete storedRedirects[flagKey];
}
}
}

// Update or clear the storage
if (Object.keys(storedRedirects).length > 0) {
// track exposure with timeout of 500ms
this.globalScope.setTimeout(() => {
const redirects =
getStorageItem('sessionStorage', redirectStorageKey) || {};
for (const storedFlagKey in redirects) {
this.exposureWithDedupe(
storedFlagKey,
redirects[storedFlagKey].variant,
true,
);
}
removeStorageItem('sessionStorage', redirectStorageKey);
}, 500);
} else {
removeStorageItem('sessionStorage', redirectStorageKey);
// Track remaining redirects after timeout, then cleanup
if (Object.keys(storedRedirects).length > 0) {
this.globalScope.setTimeout(async () => {
try {
const redirects = (await storage.get(storageKey)) || {};
for (const flagKey in redirects) {
this.exposureWithDedupe(
flagKey,
redirects[flagKey].variant,
true,
);
}
await storage.remove(storageKey);
} catch (error) {
console.error(
`Failed to remove redirect impressions from ${storageKey}:`,
error,
);
}
}, 500);
} else {
await storage.remove(storageKey);
}
} catch (error) {
console.error(
`Failed to retrieve redirect impressions from ${storageKey}:`,
error,
);
}
}

Expand Down
8 changes: 7 additions & 1 deletion packages/experiment-tag/src/util/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ import type { Campaign } from '@amplitude/analytics-core';
/**
* Utility function to generate and set marketing cookie
* Parses current campaign data from URL and referrer, then stores it in the marketing cookie
* @param apiKey - The API key used to generate the storage key
* @param domain - Cookie domain (e.g., result from getCookieDomain)
*/
export async function setMarketingCookie(apiKey: string) {
export async function setMarketingCookie(
apiKey: string,
domain: string | undefined,
) {
const storage = new CookieStorage<Campaign>({
sameSite: 'Lax',
...(domain && { domain }),
});

const parser = new CampaignParser();
Expand Down
31 changes: 31 additions & 0 deletions packages/experiment-tag/src/util/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,34 @@ export const isPreviewMode = (): boolean => {
}
return false;
};

/**
* Extracts the root domain from a URL and returns it with a leading dot for cookie sharing.
*/
export const getCookieDomain = (url: string): string | undefined => {
try {
const hostname = new URL(url).hostname;

if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
return '.localhost';
}
// Special handling for Vercel and other platform domains
// These are on the public suffix list and cannot have cookies set at the root
const publicSuffixes = ['vercel.app', 'netlify.app', 'pages.dev'];

for (const suffix of publicSuffixes) {
if (hostname.endsWith(`.${suffix}`)) {
// Return the full hostname without a leading dot
// This sets the cookie for ONLY this specific subdomain
return '.' + hostname;
}
}

const parts = hostname.split('.');
const rootDomain = parts.length <= 2 ? hostname : parts.slice(-2).join('.');

return `.${rootDomain}`;
} catch (error) {
return undefined;
}
};
Loading
Loading