Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a2fb25b
feat(analytics-browser): add config attribution.excludeInternalReferrers
daniel-graham-amplitude Feb 21, 2026
bc6adc8
chore: tidy-up code
daniel-graham-amplitude Feb 23, 2026
391740f
again
daniel-graham-amplitude Feb 23, 2026
bcfa5b9
again
daniel-graham-amplitude Feb 23, 2026
1f72748
again
daniel-graham-amplitude Feb 23, 2026
3058362
again
daniel-graham-amplitude Feb 23, 2026
aef8dbd
again
daniel-graham-amplitude Feb 23, 2026
f197b3f
again
daniel-graham-amplitude Feb 23, 2026
a514822
again
daniel-graham-amplitude Feb 23, 2026
4ad7ac6
chore: clean up exclude internal referrers typeguard
daniel-graham-amplitude Feb 23, 2026
41f1b92
chore: clean-up
daniel-graham-amplitude Feb 23, 2026
1afd192
again
daniel-graham-amplitude Feb 23, 2026
b4ef3ec
again
daniel-graham-amplitude Feb 23, 2026
bddc740
again
daniel-graham-amplitude Feb 23, 2026
12e1983
again
daniel-graham-amplitude Feb 23, 2026
64d6a98
again
daniel-graham-amplitude Feb 23, 2026
779236d
again
daniel-graham-amplitude Feb 23, 2026
ddef25a
again
daniel-graham-amplitude Feb 23, 2026
3753da2
chore: make types more explicit
daniel-graham-amplitude Feb 23, 2026
d7d89c0
again
daniel-graham-amplitude Feb 23, 2026
9d358cf
refactor: move functions around to be nicer
daniel-graham-amplitude Feb 23, 2026
58e99cc
refactor: move functions around to be nicer
daniel-graham-amplitude Feb 23, 2026
1bd481b
Merge branch 'main' of github.com:amplitude/Amplitude-TypeScript into…
daniel-graham-amplitude Feb 25, 2026
8400eb0
again
daniel-graham-amplitude Feb 25, 2026
ab59b05
again
daniel-graham-amplitude Feb 25, 2026
afba4e3
fix: PR fixes
daniel-graham-amplitude Feb 25, 2026
e80f3c1
fix: use cookieDomain to test internal domain before using window.loc…
daniel-graham-amplitude Feb 26, 2026
1ae14a2
fix: use cookieDomain to test internal domain before using window.loc…
daniel-graham-amplitude Feb 26, 2026
818a0cb
again
daniel-graham-amplitude Feb 26, 2026
9491d2b
fix: use TLD instead of cookie domain
daniel-graham-amplitude Feb 26, 2026
28c085c
fix: use TLD instead of cookie domain
daniel-graham-amplitude Feb 26, 2026
ef1169c
again
daniel-graham-amplitude Feb 26, 2026
012529d
again
daniel-graham-amplitude Feb 27, 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
145 changes: 144 additions & 1 deletion packages/analytics-browser/src/attribution/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { createIdentifyEvent, Identify, ILogger, Campaign, BASE_CAMPAIGN } from '@amplitude/analytics-core';
import {
createIdentifyEvent,
Identify,
ILogger,
Campaign,
BASE_CAMPAIGN,
getGlobalScope,
} from '@amplitude/analytics-core';

import { ExcludeInternalReferrersOptions, EXCLUDE_INTERNAL_REFERRERS_CONDITIONS } from '../types';

export interface Options {
excludeReferrers?: (string | RegExp)[];
excludeInternalReferrers?: true | false | ExcludeInternalReferrersOptions;
initialEmptyValue?: string;
resetSessionOnNewCampaign?: boolean;
optOut?: boolean;
Expand All @@ -22,16 +32,41 @@ const isDirectTraffic = (current: Campaign) => {
return Object.values(current).every((value) => !value);
};

const isEmptyCampaign = (campaign: Campaign) => {
const campaignWithoutReferrer = { ...campaign, referring_domain: undefined, referrer: undefined };
return Object.values(campaignWithoutReferrer).every((value) => !value);
};

export const isNewCampaign = (
current: Campaign,
previous: Campaign | undefined,
options: Options,
logger: ILogger,
isNewSession = true,
topLevelDomain?: string,
) => {
const { referrer, referring_domain, ...currentCampaign } = current;
const { referrer: _previous_referrer, referring_domain: prevReferringDomain, ...previousCampaign } = previous || {};

const { excludeInternalReferrers } = options;

if (excludeInternalReferrers) {
const condition = getExcludeInternalReferrersCondition(excludeInternalReferrers, logger);
if (
!(condition instanceof TypeError) &&
current.referring_domain &&
isInternalReferrer(current.referring_domain, topLevelDomain)
) {
if (condition === 'always') {
debugLogInternalReferrerExclude(condition, current.referring_domain, logger);
return false;
} else if (condition === 'ifEmptyCampaign' && isEmptyCampaign(current)) {
debugLogInternalReferrerExclude(condition, current.referring_domain, logger);
return false;
}
}
}

if (isExcludedReferrer(options.excludeReferrers, current.referring_domain)) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
logger.debug(`This is not a new campaign because ${current.referring_domain} is in the exclude referrer list.`);
Expand Down Expand Up @@ -65,6 +100,13 @@ export const isExcludedReferrer = (excludeReferrers: (string | RegExp)[] = [], r
);
};

export const isSubdomainOf = (subDomain: string, domain: string) => {
const cookieDomainWithLeadingDot = domain.startsWith('.') ? domain : `.${domain}`;
const subDomainWithLeadingDot = subDomain.startsWith('.') ? subDomain : `.${subDomain}`;
if (subDomainWithLeadingDot.endsWith(cookieDomainWithLeadingDot)) return true;
return false;
};

export const createCampaignEvent = (campaign: Campaign, options: Options) => {
const campaignParameters: Campaign = {
// This object definition allows undefined keys to be iterated on
Expand Down Expand Up @@ -93,3 +135,104 @@ export const getDefaultExcludedReferrers = (cookieDomain: string | undefined) =>
}
return [];
};

/**
* Parses the excludeInternalReferrers configuration to determine the condition on which to
* exclude internal referrers for campaign attribution.
*
* If the config is invalid type, log and return a TypeError.
*
* (this does explicit type checking so don't have to rely on TS compiler to catch invalid types)
*
* @param excludeInternalReferrers - attribution.excludeInternalReferrers configuration
* @param logger - logger instance to log error when TypeError
* @returns The condition if the config is valid, TypeError if the config is invalid.
*/
const getExcludeInternalReferrersCondition = (
excludeInternalReferrers: ExcludeInternalReferrersOptions | boolean,
logger: ILogger,
): ExcludeInternalReferrersOptions['condition'] | TypeError => {
if (excludeInternalReferrers === true) {
return EXCLUDE_INTERNAL_REFERRERS_CONDITIONS.always;
}
if (typeof excludeInternalReferrers === 'object') {
const { condition } = excludeInternalReferrers;
if (typeof condition === 'string' && Object.keys(EXCLUDE_INTERNAL_REFERRERS_CONDITIONS).includes(condition)) {
return condition;
} else if (typeof condition === 'undefined') {
return EXCLUDE_INTERNAL_REFERRERS_CONDITIONS.always;
}
}
const errorMessage = `Invalid configuration provided for attribution.excludeInternalReferrers: ${JSON.stringify(
excludeInternalReferrers,
)}`;
logger.error(errorMessage);
return new TypeError(errorMessage);
};

// helper function to log debug message when internal referrer is excluded
// (added this to prevent code duplication and improve readability)
function debugLogInternalReferrerExclude(
condition: ExcludeInternalReferrersOptions['condition'],
referringDomain: string,
logger: ILogger,
) {
const baseMessage = `This is not a new campaign because referring_domain=${referringDomain} is on the same domain as the current page and it is configured to exclude internal referrers`;
if (condition === 'always') {
logger.debug(baseMessage);
} else if (condition === 'ifEmptyCampaign') {
logger.debug(`${baseMessage} with empty campaign parameters`);
}
}

const KNOWN_2LDS = [
'co.uk',
'gov.uk',
'ac.uk',
'co.jp',
'ne.jp',
'or.jp',
'co.kr',
'or.kr',
'go.kr',
'com.au',
'net.au',
'org.au',
'com.br',
'net.br',
'org.br',
'com.cn',
'net.cn',
'org.cn',
'com.mx',
'github.io',
'gitlab.io',
'cloudfront.net',
'herokuapp.com',
'appspot.com',
'azurewebsites.net',
'firebaseapp.com',
];

export const getDomain = (hostname: string) => {
const parts = hostname.split('.');
let tld = parts[parts.length - 1];
let name = parts[parts.length - 2];
if (KNOWN_2LDS.find((tld) => hostname.endsWith(`.${tld}`))) {
tld = parts[parts.length - 2] + '.' + parts[parts.length - 1];
name = parts[parts.length - 3];
}

if (!name) return tld;

return `${name}.${tld}`;
};

const isInternalReferrer = (referringDomain: string, topLevelDomain?: string) => {
const globalScope = getGlobalScope();
/* istanbul ignore if */
if (!globalScope) return false;
// if referring domain is subdomain of config.cookieDomain, return true
const internalDomain = (topLevelDomain || '').trim() || getDomain(globalScope.location.hostname);
return isSubdomainOf(referringDomain, internalDomain);
};
14 changes: 12 additions & 2 deletions packages/analytics-browser/src/attribution/web-attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class WebAttribution {
sessionTimeout: number;
lastEventTime?: number;
logger: ILogger;

topLevelDomain?: string;
constructor(options: Options, config: BrowserConfig) {
this.options = {
initialEmptyValue: 'EMPTY',
Expand All @@ -37,6 +37,7 @@ export class WebAttribution {
this.sessionTimeout = config.sessionTimeout;
this.lastEventTime = config.lastEventTime;
this.logger = config.loggerProvider;
this.topLevelDomain = config.topLevelDomain;
config.loggerProvider.log('Installing web attribution tracking.');
}

Expand All @@ -49,7 +50,16 @@ export class WebAttribution {
[this.currentCampaign, this.previousCampaign] = await this.fetchCampaign();
const isEventInNewSession = !this.lastEventTime ? true : isNewSession(this.sessionTimeout, this.lastEventTime);

if (isNewCampaign(this.currentCampaign, this.previousCampaign, this.options, this.logger, isEventInNewSession)) {
if (
isNewCampaign(
this.currentCampaign,
this.previousCampaign,
this.options,
this.logger,
isEventInNewSession,
this.topLevelDomain,
)
) {
this.shouldTrackNewCampaign = true;
await this.storage.set(this.storageKey, this.currentCampaign);
}
Expand Down
13 changes: 11 additions & 2 deletions packages/analytics-browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export class BrowserConfig extends Config implements IBrowserConfig {
public diagnosticsSampleRate: number = 0,
public diagnosticsClient?: IDiagnosticsClient,
public remoteConfig?: RemoteConfigOptions,
public topLevelDomain?: string,
) {
super({ apiKey, storageProvider, transportProvider: createTransport(transport) });
this._cookieStorage = cookieStorage;
Expand Down Expand Up @@ -134,6 +135,7 @@ export class BrowserConfig extends Config implements IBrowserConfig {
this.remoteConfig = this.remoteConfig || {};
this.remoteConfig.fetchRemoteConfig = _fetchRemoteConfig;
this.fetchRemoteConfig = _fetchRemoteConfig;
this.topLevelDomain = topLevelDomain;
}

get cookieStorage() {
Expand Down Expand Up @@ -293,9 +295,15 @@ export const useBrowserConfig = async (
): Promise<IBrowserConfig> => {
// Step 1: Create identity storage instance
const identityStorage = options.identityStorage || DEFAULT_IDENTITY_STORAGE;
let topLevelDomain = '';

// use the getTopLevelDomain function to find the TLD only if identity storage
// is cookie (because getTopLevelDomain() uses cookies)
if (identityStorage === DEFAULT_IDENTITY_STORAGE) {
topLevelDomain = await getTopLevelDomain();
}
const cookieOptions = {
domain:
identityStorage !== DEFAULT_IDENTITY_STORAGE ? '' : options.cookieOptions?.domain ?? (await getTopLevelDomain()),
domain: options.cookieOptions?.domain ?? topLevelDomain,
expiration: 365,
sameSite: 'Lax' as const,
secure: false,
Expand Down Expand Up @@ -401,6 +409,7 @@ export const useBrowserConfig = async (
earlyConfig?.diagnosticsSampleRate ?? amplitudeInstance._diagnosticsSampleRate,
diagnosticsClient,
options.remoteConfig,
topLevelDomain,
);

if (!(await browserConfig.storageProvider.isEnabled())) {
Expand Down
2 changes: 2 additions & 0 deletions packages/analytics-browser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ export {
ReferrerParameters,
UTMParameters,
ValidPropertyType,
ExcludeInternalReferrersOptions,
EXCLUDE_INTERNAL_REFERRERS_CONDITIONS,
} from '@amplitude/analytics-core';
Loading
Loading