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
56 changes: 46 additions & 10 deletions packages/audience/core/src/attribution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,19 +195,40 @@ describe('getAttributionNetwork', () => {
['FACEBOOK', 'meta'],
['tiktok', 'tiktok'],
['google', 'google'],
['youtube', 'google'],
['reddit', 'reddit'],
['x', 'x'],
['twitter', 'x'],
])('classifies utm_source=%s with a paid utm_medium as %s', (source, expected) => {
['linkedin', 'other'],
])('classifies gated utm_source=%s with a paid utm_medium as %s', (source, expected) => {
expect(getAttributionNetwork({ utm_source: source, utm_medium: 'cpc' })).toBe(expected);
});

it.each(['cpc', 'ppc', 'paid', 'paid_social', 'paidsocial', 'CPC'])(
'treats utm_medium=%s as a paid medium',
(medium) => {
expect(getAttributionNetwork({ utm_source: 'facebook', utm_medium: medium })).toBe('meta');
},
);
it.each([
['amazon_ads', 'amazon', 'amazon'],
['amazon_ads', undefined, 'amazon'],
['AMAZON_ADS', 'social', 'amazon'],
['adwords', 'dsc_5653372', 'google'],
['ironsource', '618640', 'other'],
])('classifies dedicated utm_source=%s (medium=%s) on source alone as %s', (source, medium, expected) => {
expect(getAttributionNetwork({ utm_source: source, utm_medium: medium })).toBe(expected);
});

it.each([
'cpc',
'cpm',
'ppc',
'paid',
'paid_social',
'paidsocial',
'ads',
'sponsored_post',
'pmax_cpc',
'pmax_cpa',
'CPC',
])('treats utm_medium=%s as a paid medium', (medium) => {
expect(getAttributionNetwork({ utm_source: 'facebook', utm_medium: medium })).toBe('meta');
});

it.each([
['fbclid', 'meta'],
Expand All @@ -224,6 +245,11 @@ describe('getAttributionNetwork', () => {
expect(getAttributionNetwork({ [key]: '123' })).toBe('other');
});

it('classifies google from a paid UTM even without a click ID', () => {
expect(getAttributionNetwork({ gclid: '1', utm_source: 'google', utm_medium: 'cpc' })).toBe('google');
expect(getAttributionNetwork({ utm_source: 'google', utm_medium: 'cpc' })).toBe('google');
});

it('classifies an empty snapshot as organic', () => {
expect(getAttributionNetwork({})).toBe('organic');
});
Expand All @@ -236,12 +262,12 @@ describe('getAttributionNetwork', () => {
expect(getAttributionNetwork({ utm_source: 'newsletter', utm_medium: 'cpc' })).toBe('organic');
});

it('classifies utm_source alone without utm_medium as organic', () => {
it('classifies a gated utm_source alone without utm_medium as organic', () => {
expect(getAttributionNetwork({ utm_source: 'facebook' })).toBe('organic');
});

it.each(['organic', 'social', 'referral', 'email'])(
'classifies a matching utm_source with non-paid utm_medium=%s as organic',
'classifies a gated utm_source with non-paid utm_medium=%s as organic',
(medium) => {
expect(getAttributionNetwork({ utm_source: 'facebook', utm_medium: medium })).toBe('organic');
},
Expand Down Expand Up @@ -270,11 +296,21 @@ describe('getAttributionNetwork from the current URL (no snapshot)', () => {
expect(getAttributionNetwork()).toBe(expected);
});

it('classifies a paid utm_source/medium on the URL', () => {
it('classifies a paid gated utm_source/medium on the URL', () => {
setLocation('https://example.com/?utm_source=facebook&utm_medium=cpc');
expect(getAttributionNetwork()).toBe('meta');
});

it('classifies a dedicated utm_source on the URL as amazon', () => {
setLocation('https://example.com/?utm_source=amazon_ads&utm_medium=amazon');
expect(getAttributionNetwork()).toBe('amazon');
});

it('classifies a paid google UTM on the URL', () => {
setLocation('https://example.com/?utm_source=google&utm_medium=cpc');
expect(getAttributionNetwork()).toBe('google');
});

it('classifies no params on the URL as organic', () => {
setLocation('https://example.com/');
expect(getAttributionNetwork()).toBe('organic');
Expand Down
103 changes: 83 additions & 20 deletions packages/audience/core/src/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,41 +139,103 @@ export function clearAttribution(): void {
* network taxonomy used by Immutable's server-side attribution pipeline
* so client- and server-classified traffic agree on the same names.
*/
export type AttributionNetwork = 'meta' | 'tiktok' | 'google' | 'reddit' | 'x' | 'organic' | 'other';
export type AttributionNetwork =
| 'meta'
| 'tiktok'
| 'google'
| 'reddit'
| 'x'
| 'amazon'
| 'organic'
| 'other';

const META_SOURCES = ['facebook', 'instagram', 'meta', 'fb', 'ig'];
const X_SOURCES = ['x', 'twitter'];
/**
* `utm_source` values only ever used for paid campaigns, classified on source
* alone (no paid-medium gate). These platforms don't emit a traditional click
* ID, so the UTM value is the only paid signal they carry (e.g. Amazon ships
* `utm_source=amazon_ads` with `utm_medium=amazon`).
*/
const DEDICATED_PAID_SOURCES: Record<string, AttributionNetwork> = {
amazon_ads: 'amazon',
adwords: 'google',
ironsource: 'other',
};

/**
* `utm_source` values that also carry organic traffic (e.g. a shared Facebook
* post), so a match requires a corroborating paid `utm_medium` before the
* visit is treated as paid; otherwise organic shares get misclassified.
*/
const GATED_PAID_SOURCES: Record<string, AttributionNetwork> = {
facebook: 'meta',
fb: 'meta',
meta: 'meta',
instagram: 'meta',
ig: 'meta',
tiktok: 'tiktok',
google: 'google',
youtube: 'google',
reddit: 'reddit',
x: 'x',
twitter: 'x',
linkedin: 'other',
};

/**
* `utm_medium` values that indicate paid traffic. A `utm_source` match
* alone isn't sufficient to call a visit "paid" — e.g. `utm_source=facebook`
* also covers an organic post shared on Facebook — so the medium must
* corroborate paid intent unless a network click ID is present.
* `utm_medium` values that corroborate paid intent for {@link GATED_PAID_SOURCES}.
* Performance Max variants (`pmax_cpc`, `pmax_cpa`, ...) are matched by prefix
* in {@link isPaidMedium}.
*/
const PAID_MEDIUMS = ['cpc', 'ppc', 'paid', 'paid_social', 'paidsocial'];
const PAID_MEDIUMS = [
'paid',
'paid_social',
'paidsocial',
'cpc',
'cpm',
'ppc',
'ads',
'sponsored_post',
];

function isPaidSourceMatch(source: string | undefined, medium: string | undefined, sources: string[]): boolean {
return sources.includes(source ?? '') && PAID_MEDIUMS.includes(medium ?? '');
function isPaidMedium(medium: string | undefined): boolean {
const value = medium ?? '';
return PAID_MEDIUMS.includes(value) || value.startsWith('pmax');
}

/**
* Classify from `utm_source` / `utm_medium` when no ad-network click ID is
* present. Dedicated sources classify on source alone; gated sources require a
* paid medium. Returns `undefined` when the source doesn't map to a paid
* network, letting the caller fall through to `organic`.
*/
function networkFromUtm(source: string | undefined, medium: string | undefined): AttributionNetwork | undefined {
if (!source) return undefined;
const dedicated = DEDICATED_PAID_SOURCES[source];
if (dedicated) return dedicated;
const gated = GATED_PAID_SOURCES[source];
if (gated && isPaidMedium(medium)) return gated;
return undefined;
}

/**
* Shared classifier for {@link getAttributionNetwork}. `source` / `medium`
* are expected pre-lowercased; `hasParam` reports whether a given click-ID
* key is present, letting the same logic run against a URL or an
* {@link Attribution} snapshot.
* {@link Attribution} snapshot. Click IDs are the strongest signal and take
* precedence over UTM, matching the server-side matcher's ordering.
*/
function classifyNetwork(
source: string | undefined,
medium: string | undefined,
hasParam: (key: AttributionKey) => boolean,
): AttributionNetwork {
if (hasParam('fbclid') || isPaidSourceMatch(source, medium, META_SOURCES)) return 'meta';
if (hasParam('ttclid') || isPaidSourceMatch(source, medium, ['tiktok'])) return 'tiktok';
if (hasParam('gclid') || hasParam('dclid') || isPaidSourceMatch(source, medium, ['google'])) return 'google';
if (hasParam('rdt_cid') || isPaidSourceMatch(source, medium, ['reddit'])) return 'reddit';
if (hasParam('twclid') || isPaidSourceMatch(source, medium, X_SOURCES)) return 'x';
if (hasParam('fbclid')) return 'meta';
if (hasParam('ttclid')) return 'tiktok';
if (hasParam('gclid') || hasParam('dclid')) return 'google';
if (hasParam('rdt_cid')) return 'reddit';
if (hasParam('twclid')) return 'x';
if (hasParam('msclkid') || hasParam('li_fat_id')) return 'other';
return 'organic';
return networkFromUtm(source, medium) ?? 'organic';
}

/**
Expand All @@ -192,9 +254,10 @@ function classifyNetwork(
* standalone form only where no {@link Audience} instance exists (e.g. a page
* with just an ad pixel); when you have an instance, prefer
* `Audience.getAttributionNetwork()`, which classifies from its cached snapshot.
* @returns The matched network, `'organic'` when no UTM or click ID is
* present, or `'other'` when a recognised click ID (e.g. `msclkid`,
* `li_fat_id`) doesn't map to a named network.
* @returns The matched network, `'organic'` when no paid signal is present, or
* `'other'` when the signal is recognised but doesn't map to a named network
* (e.g. `msclkid` / `li_fat_id`, or a source like `ironsource`/`linkedin` with
* no first-class network of its own).
*/
export function getAttributionNetwork(attribution?: Attribution): AttributionNetwork {
if (attribution) {
Expand Down
Loading