Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -1,5 +1,5 @@
import { isConsentWithheld } from '../consent/consent-gate';
import { getTopLevelDomainSync, SyncJsonCookie } from '../util/cookie';
import { getTopLevelDomain, SyncJsonCookie } from '../util/cookie';

/**
* Default rolling inactivity window before a session rotates, mirroring
Expand Down Expand Up @@ -146,7 +146,7 @@ export class SessionManager {
}
const resolved =
typeof location !== 'undefined' && location.hostname
? getTopLevelDomainSync(location.hostname)
? getTopLevelDomain(location.hostname)
: '';
// While consent is withheld the resolver returns an unprobed guess (a real
// probe writes a throwaway cookie). Don't pin the guess: leaving the cache
Expand Down
32 changes: 5 additions & 27 deletions packages/experiment-tag/src/consent/consent-cookie-storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { CookieStorage } from '@amplitude/analytics-core';

import { mergeIdentityCookieJson } from '../util/grant-flush-merge';

import {
Expand All @@ -10,9 +8,8 @@ import {
} from './consent-gate';

/**
* The part of analytics-core's `CookieStorage` that experiment-tag actually uses.
* Naming it lets call sites accept the consent wrapper and the raw storage
* interchangeably, and lets tests substitute a plain object.
* The faux asynchronous cookie backend the consent gate wraps. Implemented over
* `document.cookie` in `util/cookie.ts`; tests substitute a plain object.
*/
export interface AsyncCookieStore<T> {
get(key: string): Promise<T | undefined>;
Expand All @@ -27,8 +24,9 @@ const snapshotValue = <T>(value: T): T =>
/**
* Holds cookie writes in memory until consent arrives, then hands them to the
* real storage. The counterpart to the gate in `util/storage.ts`, for the cookies
* that go through analytics-core rather than this package's own helpers:
* cross-subdomain identity, redirect impressions, and marketing attribution.
* that go through analytics-core's wire format rather than this package's own
* helpers: cross-subdomain identity, redirect impressions, and marketing
* attribution.
*
* Reads are gated with the writes, so a visitor who has not decided is not
* re-identified from a cookie an earlier consented visit left behind.
Expand Down Expand Up @@ -139,23 +137,3 @@ export class ConsentAwareCookieStorage<T> implements AsyncCookieStore<T> {
});
}
}

type CookieStorageOptions = ConstructorParameters<typeof CookieStorage>[0];

/**
* Builds the cookie storage every experiment-tag call site should use, so a
* new one cannot silently bypass the consent gate. Pass a function when an
* option needs a device probe (the cross-subdomain `domain`); it is evaluated
* on the first access that reaches real storage rather than frozen at
* construction while consent is withheld.
*/
export const createCookieStorage = <T>(
options?:
| CookieStorageOptions
| (() => Promise<CookieStorageOptions> | CookieStorageOptions),
): AsyncCookieStore<T> =>
new ConsentAwareCookieStorage<T>(
typeof options === 'function'
? async () => new CookieStorage<T>(await options())
: new CookieStorage<T>(options),
);
60 changes: 18 additions & 42 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ import type { MutationController } from 'dom-mutator/dist/types';
import { BehavioralTargetingManager } from './behavioral-targeting';
import { getRelayUrl, RelayClient } from './behavioral-targeting/relay-client';
import { clearIfErasedElsewhere } from './consent/clear-data';
import {
type AsyncCookieStore,
createCookieStorage,
} from './consent/consent-cookie-storage';
import { type AsyncCookieStore } from './consent/consent-cookie-storage';
import {
consentGate,
isConsentPending,
Expand Down Expand Up @@ -66,6 +63,7 @@ import { applyAntiFlickerCss, removeAntiFlickerCss } from './util/anti-flicker';
import { enrichUserWithCampaignData } from './util/campaign';
import { mergeWithWindowConfig } from './util/config';
import {
createCookieStorage,
getTopLevelDomain,
resolveCrossSubdomainObject,
setMarketingCookie,
Expand Down Expand Up @@ -589,13 +587,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
this.subscriptionManager.markUrlAsPublished(this.globalScope.location.href);
this.messageBus.publish('url_change', { updateActivePages: true });

// Warm the cross-subdomain cookie-domain cache. Must run after the
// synchronous url_change above, whose subscribers apply anti-flicker
// variants/redirects before this first await. While consent is withheld
// this returns an uncached guess (probing writes a cookie), so consumers
// resolve the domain lazily at write time instead of capturing this result.
await getTopLevelDomain(this.globalScope.location.hostname);

const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`;
const user =
getStorageItem<WebExperimentUser>(
Expand All @@ -621,18 +612,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
// first_seen rather than a subdomain-local mint. The cookie domain is
// resolved when the storage first reaches real cookies (post-grant for a
// pending start), so it is never pinned to an unprobed guess.
const crossSubdomainCookieStorage = createCookieStorage<string>(
async () => {
const domain = await getTopLevelDomain(
this.globalScope.location.hostname,
);
return {
...(domain && { domain }),
sameSite: 'Lax',
expirationDays: 365,
};
},
);
const crossSubdomainCookieStorage = createCookieStorage<string>({
domain: getTopLevelDomain(this.globalScope.location.hostname),
sameSite: 'Lax',
expirationDays: 365,
});

const defaultUserProviderStorageKey = `${experimentStorageName}_DEFAULT_USER_PROVIDER`;
const defaultUserProviderData =
Expand Down Expand Up @@ -2055,15 +2039,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
) {
const storage = createCookieStorage<
Record<string, StoredRedirectImpression>
>(async () => {
const domain = await getTopLevelDomain(
this.globalScope.location.hostname,
);
return {
...(domain && { domain }),
sameSite: 'Lax',
expirationDays: 1 / 1440, // 1 minute
};
>({
domain: getTopLevelDomain(this.globalScope.location.hostname),
sameSite: 'Lax',
expirationDays: 1 / 1440, // 1 minute
});

try {
Expand Down Expand Up @@ -2150,14 +2129,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
if (this.config.redirectConfig?.encodeRedirectInCookie) {
cookieStorage = createCookieStorage<
Record<string, StoredRedirectImpression>
>(async () => {
const domain = await getTopLevelDomain(
this.globalScope.location.hostname,
);
return {
...(domain && { domain }),
sameSite: 'Lax',
};
>({
domain: getTopLevelDomain(this.globalScope.location.hostname),
sameSite: 'Lax',
});
try {
cookieImpressions = (await cookieStorage.get(storageKey)) || {};
Expand Down Expand Up @@ -2199,12 +2173,14 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
const cleanup = async () => {
removeStorageItem('sessionStorage', storageKey);
if (cookieStorage) {
await cookieStorage.remove(storageKey).catch((error) => {
try {
await cookieStorage.remove(storageKey);
} catch (error) {
console.error(
`Failed to remove redirect impressions from cookie ${storageKey}:`,
error,
);
});
}
}
};

Expand Down
3 changes: 1 addition & 2 deletions packages/experiment-tag/src/util/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import {
import { UTMParameters } from '@amplitude/analytics-core/lib/esm/types/campaign';
import { type ExperimentUser } from '@amplitude/experiment-js-client';

import { createCookieStorage } from '../consent/consent-cookie-storage';

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

/**
Expand Down
131 changes: 105 additions & 26 deletions packages/experiment-tag/src/util/cookie.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core';
import {
CampaignParser,
MKTG,
decodeCookieValue,
} from '@amplitude/analytics-core';
import type { Campaign } from '@amplitude/analytics-core';

import {
type AsyncCookieStore,
createCookieStorage,
AsyncCookieStore,
ConsentAwareCookieStorage,
} from '../consent/consent-cookie-storage';
import {
isConsentPending,
Expand Down Expand Up @@ -128,7 +132,12 @@ const KNOWN_2LDS = [
'workers.dev',
];

let cachedDomain: string | undefined;
/**
* Cross-subdomain cookie domain per hostname, so {@link getTopLevelDomain}
* probes at most once per host. Keyed by hostname so a page (or a test file)
* that touches more than one never crosses them.
*/
const cachedDomains: Record<string, string> = {};

/**
* Synchronously probes whether a cookie can be written to `.<domain>` by
Expand Down Expand Up @@ -191,29 +200,17 @@ function unprobedDomainGuess(hostname: string): string {
* the first {@link getCookieDomainLevels} entry that accepts one, as a
* leading-dot domain (e.g. `.example.com`), or `''` when none does.
*/
export function getTopLevelDomainSync(hostname: string): string {
export function getTopLevelDomain(hostname: string): string {
if (hostname in cachedDomains) return cachedDomains[hostname];
if (isConsentWithheld()) {
return unprobedDomainGuess(hostname);
}
for (const domain of getCookieDomainLevels(hostname)) {
if (isDomainWritableSync(domain)) {
return '.' + domain;
}
}
return '';
}

export async function getTopLevelDomain(hostname: string): Promise<string> {
if (cachedDomain !== undefined) return cachedDomain;
if (isConsentWithheld()) {
return unprobedDomainGuess(hostname);
}
for (const domain of getCookieDomainLevels(hostname)) {
if (await CookieStorage.isDomainWritable(domain)) {
return (cachedDomain = '.' + domain);
return (cachedDomains[hostname] = '.' + domain);
}
}
return (cachedDomain = '');
return (cachedDomains[hostname] = '');
}

/**
Expand Down Expand Up @@ -319,6 +316,91 @@ export function deleteRawCookie(key: string, domain?: string): void {
}
}

/**
* Synchronous, format-compatible read of a value written by analytics-core's
* `CookieStorage` (base64 of URL-encoded JSON) without async CookieStore API.
* NOTE: CookieStorage filters duplicate cookie names by domain
* but this function using document.cookie does not have that ability
* Returns `undefined` when the cookie is absent or undecodable.
*/
export function readCookieStorageSync<T>(key: string): T | undefined {
try {
const raw = readRawCookie(key);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something claude pointed out:

getRaw picks the cookie whose domain matches options.domain (isDomainEqual over cookieStore.getAll). document.cookie can't do that, so this takes whatever's listed first. With a host-only EXP_ and a .example.com EXP_ cookie both present we can hand back a stale identity — analytics-core has a cookies.duplicate diagnostic for it, so it happens.

Chrome-only change since everyone else already fell through to getRawSync, but worth a line in the description.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing out that difference, updated the comment.

if (raw === undefined) return undefined;
const decoded = decodeCookieValue(raw);
if (decoded === undefined) return undefined;
return JSON.parse(decoded) as T;
} catch {
return undefined;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Synchronous write in analytics-core's base64 format of URL-encoded JSON
* Copy of its `CookieStorage.prototype.setSync` logic since it's not exported
*/
export function writeCookieStorageSync<T>(
Comment thread
kwliou marked this conversation as resolved.
key: string,
value: T,
options: {
domain?: string;
sameSite?: string;
expirationDays?: number;
secure?: boolean;
} = {},
): void {
if (typeof document === 'undefined') return;
try {
let cookie = `${key}=${btoa(encodeURIComponent(JSON.stringify(value)))}`;
if (options.expirationDays) {
const expires = new Date();
expires.setTime(
expires.getTime() + options.expirationDays * 24 * 60 * 60 * 1000,
);
cookie += `; expires=${expires.toUTCString()}`;
}
cookie += '; path=/';
if (options.domain) cookie += `; domain=${options.domain}`;
if (options.secure) cookie += '; Secure';
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
document.cookie = cookie;
} catch {
/* blocked cookie I/O degrades silently */
}
}

/** Cookie-backed sync store, in analytics-core's wire format. */
type CookieStorageOptions = {
Comment thread
kwliou marked this conversation as resolved.
domain?: string;
sameSite?: string;
expirationDays?: number;
secure?: boolean;
};

const documentCookieStore = <T>(
options: CookieStorageOptions = {},
): AsyncCookieStore<T> => ({
get: async (key) => readCookieStorageSync<T>(key),
set: async (key, value) =>
writeCookieStorageSync<T>(key, value, {
sameSite: 'Lax',
secure: location.protocol === 'https:',
...options,
}),
Comment thread
cursor[bot] marked this conversation as resolved.
remove: async (key) => deleteRawCookie(key, options.domain),
});

/**
* The single consent-gated cookie store every experiment-tag call site should
* use, so a new one cannot silently bypass the consent gate. Reads and writes
* are synchronous (`document.cookie`); the gate buffers pending writes and
* flushes them on grant.
*/
export const createCookieStorage = <T>(
options: CookieStorageOptions = {},
): AsyncCookieStore<T> =>
new ConsentAwareCookieStorage<T>(documentCookieStore<T>(options));

/**
* Synchronous two-tier (cookie → in-memory) JSON store. The cookie is the
* cross-tab / cross-subdomain source of truth; if writes are blocked (detected
Expand Down Expand Up @@ -412,12 +494,9 @@ export class SyncJsonCookie<T> {
export async function setMarketingCookie(apiKey: string, hostname: string) {
// Domain resolved lazily so a pending-time guess is not baked in; see
// createCookieStorage.
const storage = createCookieStorage<Campaign>(async () => {
const domain = await getTopLevelDomain(hostname);
return {
sameSite: 'Lax',
...(domain && { domain }),
};
const storage = createCookieStorage<Campaign>({
domain: getTopLevelDomain(hostname),
sameSite: 'Lax',
});

const parser = new CampaignParser();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ describe('SessionManager', () => {
});

test('does not pin the unprobed guess: first post-grant write probes for real', () => {
const spy = jest.spyOn(cookieUtil, 'getTopLevelDomainSync');
const spy = jest.spyOn(cookieUtil, 'getTopLevelDomain');
activateConsent('denied');
const manager = new SessionManager(testApiKey);
// Denial cleanup falls through to a cookie delete, which resolves the
Expand Down
Loading
Loading