diff --git a/packages/audience/core/src/attribution.test.ts b/packages/audience/core/src/attribution.test.ts index 2ae406ff62..eb7dd7c139 100644 --- a/packages/audience/core/src/attribution.test.ts +++ b/packages/audience/core/src/attribution.test.ts @@ -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'], @@ -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'); }); @@ -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'); }, @@ -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'); diff --git a/packages/audience/core/src/attribution.ts b/packages/audience/core/src/attribution.ts index e7efe974a2..af4377c8c3 100644 --- a/packages/audience/core/src/attribution.ts +++ b/packages/audience/core/src/attribution.ts @@ -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 = { + 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 = { + 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'; } /** @@ -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) {