@shware/analytics 10.1.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +169 -16
  2. package/dist/attribution/classify.cjs +39 -14
  3. package/dist/attribution/classify.cjs.map +1 -1
  4. package/dist/attribution/classify.d.cts +12 -6
  5. package/dist/attribution/classify.d.cts.map +1 -1
  6. package/dist/attribution/classify.d.mts +12 -6
  7. package/dist/attribution/classify.d.mts.map +1 -1
  8. package/dist/attribution/classify.mjs +39 -14
  9. package/dist/attribution/classify.mjs.map +1 -1
  10. package/dist/attribution/vocabulary.cjs +6 -2
  11. package/dist/attribution/vocabulary.cjs.map +1 -1
  12. package/dist/attribution/vocabulary.d.cts +6 -2
  13. package/dist/attribution/vocabulary.d.cts.map +1 -1
  14. package/dist/attribution/vocabulary.d.mts +6 -2
  15. package/dist/attribution/vocabulary.d.mts.map +1 -1
  16. package/dist/attribution/vocabulary.mjs +6 -2
  17. package/dist/attribution/vocabulary.mjs.map +1 -1
  18. package/dist/server/google-data-manager.cjs +11 -5
  19. package/dist/server/google-data-manager.cjs.map +1 -1
  20. package/dist/server/google-data-manager.d.cts +6 -3
  21. package/dist/server/google-data-manager.d.cts.map +1 -1
  22. package/dist/server/google-data-manager.d.mts +6 -3
  23. package/dist/server/google-data-manager.d.mts.map +1 -1
  24. package/dist/server/google-data-manager.mjs +11 -5
  25. package/dist/server/google-data-manager.mjs.map +1 -1
  26. package/dist/server/linkedin-conversions-api.cjs +17 -6
  27. package/dist/server/linkedin-conversions-api.cjs.map +1 -1
  28. package/dist/server/linkedin-conversions-api.d.cts.map +1 -1
  29. package/dist/server/linkedin-conversions-api.d.mts.map +1 -1
  30. package/dist/server/linkedin-conversions-api.mjs +17 -6
  31. package/dist/server/linkedin-conversions-api.mjs.map +1 -1
  32. package/dist/server/meta-capi.cjs +25 -7
  33. package/dist/server/meta-capi.cjs.map +1 -1
  34. package/dist/server/meta-capi.d.cts +14 -1
  35. package/dist/server/meta-capi.d.cts.map +1 -1
  36. package/dist/server/meta-capi.d.mts +14 -1
  37. package/dist/server/meta-capi.d.mts.map +1 -1
  38. package/dist/server/meta-capi.mjs +25 -8
  39. package/dist/server/meta-capi.mjs.map +1 -1
  40. package/dist/server/meta-conversions-api.cjs +9 -12
  41. package/dist/server/meta-conversions-api.cjs.map +1 -1
  42. package/dist/server/meta-conversions-api.d.cts.map +1 -1
  43. package/dist/server/meta-conversions-api.d.mts.map +1 -1
  44. package/dist/server/meta-conversions-api.mjs +9 -12
  45. package/dist/server/meta-conversions-api.mjs.map +1 -1
  46. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"classify.mjs","names":[],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes\n * (`undefined`, `null`, an unexpanded `{{macro}}` other than Meta's placement, read elsewhere).\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids, as in GA4: a utm is written for this link by whoever placed it, while a click id can be\n * added by someone else — Meta puts `fbclid` on organic links too, and a shared or forwarded ad\n * link keeps the click id of the ad. When the two disagree, the utm is the one that was right:\n * an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Presence of the key is the signal, as it is for the pixel\n * that reads it.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => key in tags)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && key in tags)?.[1] ?? null;\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch.\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const host = url ? referrerHostOf.exec(url)?.[1]?.toLowerCase() : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, referral last — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone — but `cpc` when an ad-only click id of the\n * same channel came with it (`'ads'` in `CLICK_ID_CHANNELS`) — `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. `campaign` is `utm_campaign`, else what a product rule captured — even when the\n * utm named the channel, so a tagged referral link keeps its code. `priority` is the tier of\n * the rule that named the channel (`TOUCH_PRIORITY`): campaign touches, then the product's own\n * as they declare, then the referrer.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const medium =\n declaredMedium ??\n (utm\n ? adClickChannel(tags) === utm\n ? 'cpc'\n : MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const priority =\n utm || click || landing\n ? TOUCH_PRIORITY.campaign\n : product\n ? product.priority\n : referred\n ? TOUCH_PRIORITY.referrer\n : null;\n\n return { channel, medium, channel_group: channelGroupOf(channel, medium), campaign, priority };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"classify.mjs","names":[],"sources":["../../src/attribution/classify.ts"],"sourcesContent":["import type { TrackTags } from '../track/types';\nimport {\n AD_LANDING_PAGE,\n CLICK_ID_CHANNELS,\n type ChannelGroup,\n DIRECT_CHANNEL,\n DISPLAY_MEDIUMS,\n EMAIL_MEDIUM,\n EMAIL_MEDIUMS,\n MEDIUM_NOT_SET,\n META_PLACEMENTS,\n NO_MEDIUM,\n PAID_MEDIUM,\n REFERRERS_NOT_A_TOUCH,\n REFERRER_SITES,\n SOURCE_ALIASES,\n TOUCH_PRIORITY,\n} from './vocabulary';\n\n/** What a session's tags say about how it arrived: the touch, as the session row stores it. */\nexport interface Touch {\n /** The source, as GA calls it: a `Channel`, a `utm_source` outside the list, a referring host, or `(direct)`. */\n channel: string;\n /** How, as GA calls it: `utm_medium` as declared, else what the rule that named the channel implies; `(none)` for direct. */\n medium: string;\n /** GA4's default channel group of the two. */\n channel_group: ChannelGroup;\n /** `utm_campaign`, else what a product rule captured (a referral code); null when neither. */\n campaign: string | null;\n /** `TOUCH_PRIORITY` of the rule that named the channel; null for direct, which is no touch. */\n priority: number | null;\n}\n\n/** What a product rule names, when it recognises the session; the channel group is derived. */\nexport type ProductTouch = Omit<Touch, 'channel_group'>;\n\n/**\n * A product's own rule: a landing page convention, a code in the URL. Tried after the campaign\n * rules (utm, click id, ad landing page) and before the referrer, so a link shared on Facebook\n * or WhatsApp is credited to what the product recognises, not to the site it was shared on.\n * Returns null when the session is not one of its.\n */\nexport type TouchRule = (tags: TrackTags) => ProductTouch | null;\n\nexport interface ClassifyOptions {\n /**\n * The product's own hosts, as patterns over the lower-cased referrer host: a session that\n * starts from an internal link after the session timeout refers to them, which is navigation,\n * not acquisition. The SDK adds the payment and sign-in providers (`REFERRERS_NOT_A_TOUCH`).\n */\n ownHosts?: readonly RegExp[];\n /** The product's rules, in order; the first that names the session wins. */\n rules?: readonly TouchRule[];\n}\n\nconst metaPlacements = new Set<string>(META_PLACEMENTS);\n/** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */\nconst ORGANIC_GROUPS = new Set<ChannelGroup>([\n 'organic_search',\n 'organic_social',\n 'organic_video',\n 'organic_ai',\n 'referral',\n]);\n/**\n * The groups a campaign rule ranks with a referrer in: the organic ones, and email. Email reaches\n * people we already know — often because an ad brought them — and its links (a welcome mail, a\n * trial reminder) come between that ad and the purchase, so it must not take the ad's credit.\n * With no ad in the window it still wins as the latest touch.\n */\nconst REFERRER_TIER_GROUPS = new Set<ChannelGroup>([...ORGANIC_GROUPS, 'email']);\nconst referrerHostOf = /^https?:\\/\\/([^/:?#]+)/i;\nconst channelsBy = (medium: string) =>\n new Set<string>(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));\nconst searchChannels = channelsBy('organic');\nconst socialChannels = channelsBy('social');\nconst videoChannels = channelsBy('video');\nconst aiChannels = channelsBy('ai');\n\n/**\n * A tag as text; absent when missing, empty, or the strings a broken template writes\n * (`undefined`, `null`, an unexpanded `{{macro}}` other than Meta's placement, read elsewhere).\n */\nfunction text(tags: TrackTags, key: string): string | null {\n const value = tags[key];\n if (value === undefined || value === null) return null;\n const s = String(value);\n return s === '' || s === 'undefined' || s === 'null' ? null : s;\n}\n\n/**\n * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click\n * ids — unlike GA4, where a `gclid` wins over the utm unless the property sets \"manual tagging\n * overrides auto-tagging\": a utm is written for this link by whoever placed it, while a click id\n * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or\n * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that\n * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.\n * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.\n */\nfunction utmChannel(tags: TrackTags): string | null {\n // A source with the rest of the query glued on (`email&utm_medium=promo`, `toolify/`,\n // `x?utm_source=x`): a link built by hand or double-encoded. Keep the source.\n const source = text(tags, 'utm_source')?.toLowerCase().split(/[?&#]/)[0]?.replace(/\\/+$/, '');\n if (!source) return null;\n return (SOURCE_ALIASES as Record<string, string>)[source] ?? source;\n}\n\n/**\n * 2. A click id in this page's URL — the tags never fill one from the cookie an earlier click\n * left (that is `_gcl_aw`, `_fbc`, …). Presence of the key is the signal, as it is for the pixel\n * that reads it.\n */\nfunction clickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key]) => key in tags)?.[1] ?? null;\n}\n\n/** The channel of the first click id in the tags that its platform puts on ad clicks only. */\nfunction adClickChannel(tags: TrackTags): string | null {\n return CLICK_ID_CHANNELS.find(([key, , on]) => on === 'ads' && key in tags)?.[1] ?? null;\n}\n\n/** 3. A landing page reserved for one channel's ads. */\nfunction landingChannel(tags: TrackTags): string | null {\n const location = text(tags, 'page_location');\n return location ? (AD_LANDING_PAGE.exec(location)?.[1] ?? null) : null;\n}\n\n/** 4. The product's rules, in order. */\nfunction productTouch(tags: TrackTags, rules: readonly TouchRule[]): ProductTouch | null {\n for (const rule of rules) {\n const touch = rule(tags);\n if (touch) return touch;\n }\n return null;\n}\n\n/**\n * 5. The referrer's host: a known search engine or social network folded to its channel, any\n * other site kept as its host, and null for no referrer or one that is no touch.\n */\nfunction referrer(\n tags: TrackTags,\n ownHosts: readonly RegExp[]\n): { channel: string; medium: string } | null {\n const url = text(tags, 'page_referrer');\n const host = url ? referrerHostOf.exec(url)?.[1]?.toLowerCase() : undefined;\n if (!host) return null;\n if (REFERRERS_NOT_A_TOUCH.some((pattern) => pattern.test(host))) return null;\n if (ownHosts.some((pattern) => pattern.test(host))) return null;\n const site = REFERRER_SITES.find(([, , pattern]) => pattern.test(host));\n return site ? { channel: site[0], medium: site[1] } : { channel: host, medium: 'referral' };\n}\n\n/**\n * GA4's default channel group of a (channel, medium) pair, with GA4's rule that the source\n * decides too: `linkedin / (not set)` is Organic Social and `email / promo` is Email, because\n * GA4 matches its site lists on the source, not only the medium. Two additions to GA4: Meta's\n * placement names as a medium are a paid Meta click (see `META_PLACEMENTS`), and the AI\n * assistants have `organic_ai`. Checked in GA4's order — paid before organic, the organic groups before\n * email, referral last — so a pair that fits two rules lands where GA4 would put it.\n */\nexport function channelGroupOf(channel: string, medium: string): ChannelGroup {\n if (channel === DIRECT_CHANNEL) return 'direct';\n if ((DISPLAY_MEDIUMS as readonly string[]).includes(medium)) return 'display';\n // A Meta placement as the medium is a Meta ad whatever the source was tagged as.\n if (metaPlacements.has(medium)) return 'paid_social';\n if (PAID_MEDIUM.test(medium)) {\n if (socialChannels.has(channel) || videoChannels.has(channel)) return 'paid_social';\n if (searchChannels.has(channel)) return 'paid_search';\n return 'paid_other';\n }\n if (medium === 'social' || socialChannels.has(channel)) return 'organic_social';\n if (medium === 'video' || videoChannels.has(channel)) return 'organic_video';\n if (medium === 'ai' || aiChannels.has(channel)) return 'organic_ai';\n if (medium === 'organic' || searchChannels.has(channel)) return 'organic_search';\n if (EMAIL_MEDIUM.test(medium) || (EMAIL_MEDIUMS as readonly string[]).includes(channel))\n return 'email';\n if (medium === 'affiliate') return 'affiliate';\n if (medium === 'referral') return 'referral';\n return 'unassigned';\n}\n\n/**\n * Reads the tags a session arrived with (its `session_start` tags) as one touch. Run once, when\n * the session is written; the result is stored, and the views only read it.\n *\n * The rules, in order, the first that says something naming the channel: an explicit\n * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;\n * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that\n * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,\n * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`\n * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad\n * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:\n * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad\n * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a\n * product rule captured — even when the utm named the channel, so a tagged referral link keeps\n * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):\n * campaign touches, then the product's own as they declare, then the referrer — except that a\n * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,\n * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should\n * not take the credit from an ad clicked earlier in the attribution window. When a product rule\n * matched too, the stronger of the two tiers holds.\n */\nexport function classifyTouch(tags: TrackTags, options: ClassifyOptions = {}): Touch {\n const utm = utmChannel(tags);\n const click = clickChannel(tags);\n const landing = landingChannel(tags);\n const product = productTouch(tags, options.rules ?? []);\n const referred = referrer(tags, options.ownHosts ?? []);\n\n const channel =\n utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? DIRECT_CHANNEL;\n const declaredMedium = text(tags, 'utm_medium')?.toLowerCase() ?? null;\n const namedMedium =\n declaredMedium ??\n (utm\n ? MEDIUM_NOT_SET\n : click || landing\n ? 'cpc'\n : (product?.medium ?? referred?.medium ?? NO_MEDIUM));\n // What proves a paid click on this channel: an ad-only click id of it, or its ad landing page.\n // Another channel's says nothing about this one (an ad link copied and shared under a utm of\n // its own), and `fbclid` is on organic Meta links too, so neither counts here.\n const paid = adClickChannel(tags) === channel || landing === channel;\n const medium =\n paid &&\n (namedMedium === MEDIUM_NOT_SET || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium)))\n ? 'cpc'\n : namedMedium;\n const channelGroup = channelGroupOf(channel, medium);\n const campaign = text(tags, 'utm_campaign') ?? product?.campaign ?? null;\n const campaignTier =\n utm || click || landing\n ? REFERRER_TIER_GROUPS.has(channelGroup)\n ? TOUCH_PRIORITY.referrer\n : TOUCH_PRIORITY.campaign\n : null;\n // The stronger of the campaign rule's tier and the product rule's own: a referral link shared\n // under `utm_source=whatsapp` is named by the utm, and still ranks as the programme declares.\n const ranked = [campaignTier, product?.priority ?? null].filter((tier) => tier !== null);\n const priority =\n ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null;\n\n return { channel, medium, channel_group: channelGroup, campaign, priority };\n}\n"],"mappings":"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"}
@@ -484,12 +484,16 @@ const REPORTED_TOUCH_KINDS = [
484
484
  *
485
485
  * This ranks touches against each other. Within one session the rules that name its channel have
486
486
  * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —
487
- * and the first that says something wins; the session's priority is then the tier of that rule.
487
+ * and the first that says something wins; the session's priority is then the tier of that rule
488
+ * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).
488
489
  * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the
489
490
  * later one is credited, while one session carrying both is named by its utm.
490
491
  */
491
492
  const TOUCH_PRIORITY = {
492
- /** utm, click id, ad landing page: someone tagged that link (in that order within a session) */
493
+ /**
494
+ * utm, click id, ad landing page: someone tagged that link (in that order within a session) —
495
+ * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`
496
+ */
493
497
  campaign: 1,
494
498
  /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */
495
499
  verified: 1,
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.cjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as unassigned until it is added\n * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,\n * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.\n */\nexport const META_PLACEMENTS = [\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule.\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /** utm, click id, ad landing page: someone tagged that link (in that order within a session) */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"vocabulary.cjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as unassigned until it is added\n * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,\n * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.\n */\nexport const META_PLACEMENTS = [\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule\n * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /**\n * utm, click id, ad landing page: someone tagged that link (in that order within a session) —\n * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`\n */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
@@ -139,12 +139,16 @@ export type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];
139
139
  *
140
140
  * This ranks touches against each other. Within one session the rules that name its channel have
141
141
  * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —
142
- * and the first that says something wins; the session's priority is then the tier of that rule.
142
+ * and the first that says something wins; the session's priority is then the tier of that rule
143
+ * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).
143
144
  * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the
144
145
  * later one is credited, while one session carrying both is named by its utm.
145
146
  */
146
147
  export declare const TOUCH_PRIORITY: {
147
- /** utm, click id, ad landing page: someone tagged that link (in that order within a session) */
148
+ /**
149
+ * utm, click id, ad landing page: someone tagged that link (in that order within a session) —
150
+ * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`
151
+ */
148
152
  readonly campaign: 1;
149
153
  /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */
150
154
  readonly verified: 1;
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.d.cts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;YA4CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,uCAAA;;;;;;;;;;;;;qBA6Cd,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;qBAWX;;;;qBA2CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;qBAa3B;;WAED;;WAEA;;WAEA;;WAED;;;qBAIE"}
1
+ {"version":3,"file":"vocabulary.d.cts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;YA4CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,uCAAA;;;;;;;;;;;;;qBA6Cd,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;qBAWX;;;;qBA2CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;;qBAc3B;;;;;WAKD;;WAEA;;WAEA;;WAED;;;qBAIE"}
@@ -139,12 +139,16 @@ export type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];
139
139
  *
140
140
  * This ranks touches against each other. Within one session the rules that name its channel have
141
141
  * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —
142
- * and the first that says something wins; the session's priority is then the tier of that rule.
142
+ * and the first that says something wins; the session's priority is then the tier of that rule
143
+ * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).
143
144
  * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the
144
145
  * later one is credited, while one session carrying both is named by its utm.
145
146
  */
146
147
  export declare const TOUCH_PRIORITY: {
147
- /** utm, click id, ad landing page: someone tagged that link (in that order within a session) */
148
+ /**
149
+ * utm, click id, ad landing page: someone tagged that link (in that order within a session) —
150
+ * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`
151
+ */
148
152
  readonly campaign: 1;
149
153
  /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */
150
154
  readonly verified: 1;
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.d.mts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;YA4CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,uCAAA;;;;;;;;;;;;;qBA6Cd,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;qBAWX;;;;qBA2CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;qBAa3B;;WAED;;WAEA;;WAEA;;WAED;;;qBAIE"}
1
+ {"version":3,"file":"vocabulary.d.mts","names":[],"sources":["../../src/attribution/vocabulary.ts"],"mappings":";;;;;;;;qBAUa;;qBAEA;;qBAEA;;qBAGA;;;;;;;;;;;;YA4CD,kBAAkB;;qBAGjB;;;YAiBD,uBAAuB;;qBAGtB;WACP;WACA;WACA;WACC;WACD;WACM;WACC;WACL;WACG;WACK;WACH;WACF;WACM;WACI;WACX;WACS;WACJ;WACE;WACC;WACF;WACF;;;;;;;;;;;;;;;;;qBAkBD;;;;;;qBAyBA,iBAAe;YAEhB;;;;;;;;;qBAUC,qDAAc,uCAAA,mCAAA,mCAAA,oCAAA,iCAAA,qCAAA,mCAAA,iCAAA,iCAAA,oCAAA,kCAAA,kCAAA,wCAAA,2CAAA,uCAAA,4CAAA,uCAAA,wCAAA,wCAAA,uCAAA,uCAAA,qCAAA,kCAAA,yCAAA,uCAAA,uCAAA,0CAAA,yCAAA,uCAAA;;;;;;;;;;;;;qBA6Cd,iCAAqB,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA,QAAA;;qBAgCrB,aAAW;;;;;;;;;;;qBAWX;;;;qBA2CA;;qBAEA;;qBAEA,cAAY;;qBAGZ;YACD,sBAAsB;;;;;;;;qBASrB;YACD,4BAA4B;;;;;;;;;;;;;qBAc3B;;;;;WAKD;;WAEA;;WAEA;;WAED;;;qBAIE"}
@@ -483,12 +483,16 @@ const REPORTED_TOUCH_KINDS = [
483
483
  *
484
484
  * This ranks touches against each other. Within one session the rules that name its channel have
485
485
  * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —
486
- * and the first that says something wins; the session's priority is then the tier of that rule.
486
+ * and the first that says something wins; the session's priority is then the tier of that rule
487
+ * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).
487
488
  * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the
488
489
  * later one is credited, while one session carrying both is named by its utm.
489
490
  */
490
491
  const TOUCH_PRIORITY = {
491
- /** utm, click id, ad landing page: someone tagged that link (in that order within a session) */
492
+ /**
493
+ * utm, click id, ad landing page: someone tagged that link (in that order within a session) —
494
+ * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`
495
+ */
492
496
  campaign: 1,
493
497
  /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */
494
498
  verified: 1,
@@ -1 +1 @@
1
- {"version":3,"file":"vocabulary.mjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as unassigned until it is added\n * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,\n * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.\n */\nexport const META_PLACEMENTS = [\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule.\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /** utm, click id, ad landing page: someone tagged that link (in that order within a session) */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"vocabulary.mjs","names":[],"sources":["../../src/attribution/vocabulary.ts"],"sourcesContent":["import type { AdvertisingInfo } from '../track/types';\n\n/**\n * The vocabulary attribution is built from, shared by every product's `session` table and by the\n * dashboards that read it. Data for `classifyTouch` (./classify), which turns it into a channel;\n * a product's dashboards read the same names back. What stays with the product is its own hostnames\n * (referrer exclusions) and its landing-page conventions.\n */\n\n/** A session that arrived through nothing we can name. GA's spelling, so dashboards read as GA. */\nexport const DIRECT_CHANNEL = '(direct)';\n/** The medium of a direct session. */\nexport const NO_MEDIUM = '(none)';\n/** The medium of a tagged link that named a source but no medium. */\nexport const MEDIUM_NOT_SET = '(not set)';\n\n/** The channels the rules below can name. A `utm_source` outside this list is its own channel. */\nexport const CHANNELS = [\n 'meta',\n 'google',\n 'dv360',\n 'microsoft',\n 'tiktok',\n 'reddit',\n 'linkedin',\n 'snapchat',\n 'x',\n 'kakao',\n 'yandex',\n 'yahoo',\n 'duckduckgo',\n 'baidu',\n 'ecosia',\n 'naver',\n 'brave',\n 'pinterest',\n 'youtube',\n /** AI assistants that send people to sites: ChatGPT tags its links `utm_source=chatgpt.com`, the\n * others show as a referrer. Their own channel group, `organic_ai`, since GA4's default groups\n * would file them under Referral / Unassigned and the question \"how much does AI bring\" comes up. */\n 'chatgpt',\n 'perplexity',\n 'gemini',\n 'claude',\n 'copilot',\n 'grok',\n /** and the Chinese ones — DeepSeek, 豆包, Kimi, 通义, 腾讯元宝, 文心一言, 智谱清言 */\n 'deepseek',\n 'doubao',\n 'kimi',\n 'qwen',\n 'yuanbao',\n 'ernie',\n 'zhipu',\n /** the product's own referral programme: the session came through a member's link or code.\n * How a product recognises one (a `/refer/<code>` path, a code at sign-up) is its own rule. Not\n * `referral`: that word is the channel group of any outside site, and a channel of the same name\n * would be impossible to tell apart in queries. */\n 'referral_program',\n] as const;\n\nexport type Channel = (typeof CHANNELS)[number];\n\n/** GA4's default channel groups, as identifiers; a dashboard maps them to display names. */\nexport const CHANNEL_GROUPS = [\n 'direct',\n 'paid_search',\n 'paid_social',\n 'paid_other',\n 'display',\n 'organic_search',\n 'organic_social',\n 'organic_video',\n /** not in GA4's default grouping: sessions an AI assistant sent, by referrer or by ChatGPT's utm */\n 'organic_ai',\n 'referral',\n 'email',\n 'affiliate',\n 'unassigned',\n] as const;\n\nexport type ChannelGroup = (typeof CHANNEL_GROUPS)[number];\n\n/** `utm_source` spellings that mean the same channel: Meta's `{{site_source_name}}` values and the usual synonyms. */\nexport const SOURCE_ALIASES = {\n fb: 'meta',\n ig: 'meta',\n an: 'meta',\n msg: 'meta',\n th: 'meta',\n facebook: 'meta',\n instagram: 'meta',\n bing: 'microsoft',\n twitter: 'x',\n 'google ads': 'google',\n googleads: 'google',\n adwords: 'google',\n 'chatgpt.com': 'chatgpt',\n 'chat.openai.com': 'chatgpt',\n openai: 'chatgpt',\n 'perplexity.ai': 'perplexity',\n 'claude.ai': 'claude',\n 'copilot.com': 'copilot',\n 'deepseek.com': 'deepseek',\n 'doubao.com': 'doubao',\n 'kimi.com': 'kimi',\n} as const satisfies Record<string, Channel>;\n\n/**\n * Which click id belongs to which channel, in priority order, and whether the platform puts it on\n * its ad clicks only (`'ads'`) or on other links as well (`'any'`). Every key is a field this SDK\n * collects (`AdvertisingInfo`).\n *\n * The third column decides whether the id can say a click was paid when a `utm_source` names the\n * channel but no `utm_medium` says how (a Reddit ad tagged `utm_source=reddit` and nothing more).\n * `fbclid` is `'any'`: Meta decorates every outbound link with it, organic posts, profile links\n * and shares included. `twclid` and `ko_click_id` are `'any'` until it is clear they never\n * decorate an organic link.\n *\n * Only URL parameters, never the cookies the tags keep beside them (`_fbc`, `_gcl_aw`, … — every\n * underscore key of `AdvertisingInfo`): a click cookie lives up to 90 days after the click, so\n * counting it named every later visit — typed in, from search, from an email — a new ad click.\n */\nexport const CLICK_ID_CHANNELS = [\n ['fbclid', 'meta', 'any'],\n ['gclid', 'google', 'ads'],\n ['gbraid', 'google', 'ads'],\n ['wbraid', 'google', 'ads'],\n ['gad_source', 'google', 'ads'],\n ['gad_campaignid', 'google', 'ads'],\n ['dclid', 'dv360', 'ads'],\n ['msclkid', 'microsoft', 'ads'],\n ['ttclid', 'tiktok', 'ads'],\n ['rdt_cid', 'reddit', 'ads'],\n ['li_fat_id', 'linkedin', 'ads'],\n ['ScCid', 'snapchat', 'ads'],\n ['twclid', 'x', 'any'],\n ['ko_click_id', 'kakao', 'any'],\n ['yclid', 'yandex', 'ads'],\n ['oppref', 'chatgpt', 'ads'],\n ['epik', 'pinterest', 'ads'],\n] as const satisfies readonly (readonly [keyof AdvertisingInfo, Channel, 'ads' | 'any'])[];\n\n/**\n * A landing page reserved for one channel's ads, for ads that lost their parameters:\n * `/lp/<channel>`, with or without host, query and hash; the capture is the channel. A product\n * that names its landing pages differently replaces this.\n */\nexport const AD_LANDING_PAGE = /^(?:https?:\\/\\/[^/]+)?\\/lp\\/([a-z]+)(?:[/?#]|$)/;\n\nexport type ReferrerMedium = 'organic' | 'social' | 'video' | 'ai';\n\n/**\n * Referrer hosts that name a channel, as regular expressions over the lower-cased host,\n * with the medium GA4 gives that kind of site. Anything else that refers is `referral`, kept as\n * its host. A country TLD is one or two labels (`google.com`, `google.co.uk`, `google.com.hk`),\n * never an open tail, so `google.com.evil.io` is not Google. In match order: the AI assistants\n * first, because `gemini.google.com`, `copilot.microsoft.com` and `yiyan.baidu.com` would\n * otherwise be Google, Microsoft and Baidu search.\n */\nexport const REFERRER_SITES = [\n ['chatgpt', 'ai', /(^|\\.)(chatgpt\\.com|chat\\.openai\\.com)$/],\n ['perplexity', 'ai', /(^|\\.)perplexity\\.ai$/],\n ['gemini', 'ai', /^gemini\\.google\\.com$/],\n ['claude', 'ai', /(^|\\.)claude\\.ai$/],\n ['copilot', 'ai', /^copilot\\.microsoft\\.com$|(^|\\.)copilot\\.com$/],\n ['grok', 'ai', /(^|\\.)(grok\\.com|x\\.ai)$/],\n ['deepseek', 'ai', /(^|\\.)deepseek\\.com$/],\n ['doubao', 'ai', /(^|\\.)doubao\\.com$/],\n ['kimi', 'ai', /(^|\\.)(kimi\\.com|kimi\\.moonshot\\.cn|moonshot\\.cn)$/],\n ['qwen', 'ai', /^tongyi\\.aliyun\\.com$|(^|\\.)(tongyi\\.com|qianwen\\.com|qwen\\.ai)$/],\n ['yuanbao', 'ai', /^yuanbao\\.tencent\\.com$/],\n ['ernie', 'ai', /^yiyan\\.baidu\\.com$|(^|\\.)ernie\\.baidu\\.com$/],\n ['zhipu', 'ai', /(^|\\.)(chatglm\\.cn|zhipuai\\.cn|bigmodel\\.cn)$/],\n ['google', 'organic', /(^|\\.)google\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['microsoft', 'organic', /(^|\\.)bing\\.com$/],\n ['yahoo', 'organic', /(^|\\.)yahoo\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['duckduckgo', 'organic', /(^|\\.)duckduckgo\\.com$/],\n ['baidu', 'organic', /(^|\\.)baidu\\.com$/],\n ['yandex', 'organic', /(^|\\.)yandex\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['ecosia', 'organic', /(^|\\.)ecosia\\.org$/],\n ['naver', 'organic', /(^|\\.)naver\\.com$/],\n ['brave', 'organic', /^search\\.brave\\.com$/],\n ['meta', 'social', /(^|\\.)(facebook\\.com|instagram\\.com|fb\\.com|threads\\.net)$/],\n ['x', 'social', /(^|\\.)(twitter\\.com|x\\.com|t\\.co)$/],\n ['linkedin', 'social', /(^|\\.)(linkedin\\.com|lnkd\\.in)$/],\n ['reddit', 'social', /(^|\\.)reddit\\.com$/],\n ['tiktok', 'social', /(^|\\.)tiktok\\.com$/],\n ['pinterest', 'social', /(^|\\.)pinterest\\.[a-z]{2,}(\\.[a-z]{2,3})?$/],\n ['snapchat', 'social', /(^|\\.)snapchat\\.com$/],\n ['youtube', 'video', /(^|\\.)(youtube\\.com|youtu\\.be)$/],\n] as const satisfies readonly (readonly [Channel, ReferrerMedium, RegExp])[];\n\n/**\n * Referrer hosts that are navigation, not acquisition, when a session happens to start on the way\n * back from them: the local dev host; the payment providers a visitor is bounced through; the\n * sign-in providers whose login lives on a host of its own (Google, Apple, Microsoft, WeChat,\n * Kakao, LINE, Naver, Yahoo, Twitch, X's OAuth 1.0a endpoint); and the hosted auth services a\n * product may sit behind (Auth0, Okta, Supabase, Firebase, Clerk). A product appends its own\n * hostnames — a session that starts from an internal link after the session timeout refers to\n * them. Matched before `REFERRER_SITES`, so `nid.naver.com` and `login.yahoo.com` are not read as\n * a search. Providers whose login shares the host with their content — Facebook, X's OAuth 2.0,\n * LinkedIn, GitHub, Discord — cannot be told apart by host (referrer policies strip the path) and\n * are left as the referrers they usually are. Regular expressions over the lower-cased host.\n */\nexport const REFERRERS_NOT_A_TOUCH = [\n /^localhost(:|$)/,\n // payment\n /(^|\\.)stripe\\.com$/,\n /^pay\\.google\\.com$/,\n /(^|\\.)paypal\\.com$/,\n /(^|\\.)alipay\\.com$/,\n /(^|\\.)paddle\\.com$/,\n /(^|\\.)lemonsqueezy\\.com$/,\n // app stores\n /^apps\\.apple\\.com$/,\n /^play\\.google\\.com$/,\n // sign-in providers with a login host of their own\n /^accounts\\.google\\.com$/,\n /^appleid\\.apple\\.com$/,\n /^login\\.(microsoftonline|live)\\.com$/,\n /^open\\.weixin\\.qq\\.com$/,\n /^(kauth|accounts)\\.kakao\\.com$/,\n /^access\\.line\\.me$/,\n /^nid\\.naver\\.com$/,\n /^login\\.yahoo\\.com$/,\n /^id\\.twitch\\.tv$/,\n /^api\\.twitter\\.com$/,\n // hosted auth services\n /(^|\\.)auth0\\.com$/,\n /(^|\\.)okta\\.com$/,\n /(^|\\.)supabase\\.co$/,\n /(^|\\.)firebaseapp\\.com$/,\n /(^|\\.)clerk\\.accounts\\.dev$/,\n] as const;\n\n/** GA4's paid mediums — cpc, cpm, ppc, retargeting, paid_social, … — plus Performance Max. */\nexport const PAID_MEDIUM = /^(.*cp.*|ppc|retargeting|paid.*|pmax|performance_max)$/;\n/**\n * Meta's `{{placement}}` values, lower-cased, which an ad URL template of `utm_medium={{placement}}`\n * puts in the medium. Only an ad carries one — an organic post arrives as a referrer, with no utm\n * at all — so a medium in this list is a paid Meta click whatever the source says (a template with\n * `utm_source=meta-websitekeyinfo` was seen). Listed one by one rather than matched by prefix, so\n * that a hand-tagged `instagram_stories` post cannot be mistaken for an ad only because of how it\n * is spelled … which means a placement Meta adds later shows up as unassigned until it is added\n * here. Meta's documented values plus the ones seen in the wild (`facebook_mobile_reels`,\n * `facebook_notification`, `an`), and the literal macro for a template that did not expand it.\n */\nexport const META_PLACEMENTS = [\n 'facebook_desktop_feed',\n 'facebook_mobile_feed',\n 'facebook_mobile_reels',\n 'facebook_reels',\n 'facebook_reels_overlay',\n 'facebook_right_column',\n 'facebook_marketplace',\n 'facebook_video_feeds',\n 'facebook_stories',\n 'facebook_search',\n 'facebook_instream_video',\n 'facebook_instant_article',\n 'facebook_groups_feed',\n 'facebook_profile_feed',\n 'facebook_notification',\n 'facebook_notifications',\n 'facebook_business_explore',\n 'instagram_feed',\n 'instagram_stories',\n 'instagram_reels',\n 'instagram_reels_overlay',\n 'instagram_explore',\n 'instagram_explore_home',\n 'instagram_search',\n 'instagram_shop',\n 'instagram_profile_feed',\n 'instagram_profile_reels',\n 'instagram_igtv',\n 'messenger_inbox',\n 'messenger_stories',\n 'messenger_sponsored_messages',\n 'threads_feed',\n 'whatsapp_status',\n 'audience_network_classic',\n 'audience_network_rewarded_video',\n 'audience_network_native_banner_and_interstitial',\n 'an',\n 'others',\n /** the macro itself, when Meta did not expand it (written in a field it does not substitute, or a\n * preview click): still only ever produced by a Meta ad URL, so still a paid Meta click */\n '{{placement}}',\n] as const;\nexport const DISPLAY_MEDIUMS = ['display', 'banner', 'expandable', 'interstitial'] as const;\n/** GA4's email spellings, as a source or a medium. */\nexport const EMAIL_MEDIUMS = ['email', 'e-mail', 'e_mail', 'e mail'] as const;\n/** A medium that says email or newsletter anywhere in it: `outbound email`, `cold_email`, `newsletter`, and GA4's four. */\nexport const EMAIL_MEDIUM = /(^|[^a-z])(e[-_ ]?mail|newsletter)([^a-z]|$)/;\n\n/** Where a touch came from: a tracked session, or a report by the user or staff. */\nexport const TOUCH_SOURCES = ['tracked', 'reported'] as const;\nexport type TouchSource = (typeof TOUCH_SOURCES)[number];\n\n/**\n * How a reported touch was learned:\n * - survey: the user answered \"how did you hear about us\" (onboarding, checkout)\n * - promo_code: the user redeemed a code that belongs to a channel\n * - phone: a call came in on a channel's dedicated number, marked by staff or the phone system\n * - manual: staff recorded what a customer told them\n */\nexport const REPORTED_TOUCH_KINDS = ['survey', 'promo_code', 'phone', 'manual'] as const;\nexport type ReportedTouchKind = (typeof REPORTED_TOUCH_KINDS)[number];\n\n/**\n * How much a touch is worth against another when attribution has to choose between sessions;\n * lower wins, and the latest wins among equals. Stored on the session when it is classified, so\n * renumbering means reclassifying.\n *\n * This ranks touches against each other. Within one session the rules that name its channel have\n * their own order — `utm_source`, then a click id, then the ad landing page, then the referrer —\n * and the first that says something wins; the session's priority is then the tier of that rule\n * (a campaign rule whose touch is organic by its own tag ranking as a referrer, see `classifyTouch`).\n * So two sessions tagged with utm and gclid respectively are equal here (both `campaign`) and the\n * later one is credited, while one session carrying both is named by its utm.\n */\nexport const TOUCH_PRIORITY = {\n /**\n * utm, click id, ad landing page: someone tagged that link (in that order within a session) —\n * unless the tag itself calls the touch organic, a referral or email, which then ranks as `referrer`\n */\n campaign: 1,\n /** a reported touch staff stand behind: a call on a channel's number, a recorded conversation */\n verified: 1,\n /** the browser said where the visitor came from; nobody chose it */\n referrer: 2,\n /** a reported touch that is the user's claim: a survey answer, a code that may have been shared */\n claimed: 3,\n} as const satisfies Record<string, number>;\n\n/** Which reported kinds are staff facts and which are the user's claims. */\nexport const REPORTED_TOUCH_PRIORITY = {\n phone: TOUCH_PRIORITY.verified,\n manual: TOUCH_PRIORITY.verified,\n survey: TOUCH_PRIORITY.claimed,\n promo_code: TOUCH_PRIORITY.claimed,\n} as const satisfies Record<ReportedTouchKind, number>;\n"],"mappings":"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"}
@@ -33,10 +33,11 @@ const ENDPOINT = "https://datamanager.googleapis.com/v1/events:ingest";
33
33
  /**
34
34
  * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the
35
35
  * username's dots and everything from '+' on are removed; for every other domain they are kept.
36
- * https://developers.google.com/data-manager/api/devguides/format-data
36
+ * All whitespace goes — "leading, trailing and intermediate".
37
+ * https://developers.google.com/data-manager/api/devguides/concepts/formatting
37
38
  */
38
39
  function normalizeEmail(input) {
39
- const email = input.trim().toLowerCase();
40
+ const email = input.replace(/\s+/g, "").toLowerCase();
40
41
  const at = email.lastIndexOf("@");
41
42
  if (at === -1) return email;
42
43
  let username = email.slice(0, at);
@@ -63,8 +64,10 @@ function getUserIdentifiers(data) {
63
64
  }
64
65
  /**
65
66
  * Builds one Event, or undefined when no conversion action is configured for the event's name.
66
- * At most one click identifier is sent, gclid first: it is the deterministic per-click id,
67
- * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike
67
+ * The gclid goes with the gbraid when there are both — "we recommend setting both the GCLID and
68
+ * GBRAID" — and otherwise one click id: gclid, else gbraid, else wbraid (nothing documents a
69
+ * wbraid next to a gclid). https://developers.google.com/google-ads/api/docs/conversions/upload-clicks
70
+ * Unlike
68
71
  * the legacy upload, an event with no click id at all is still worth sending when it carries
69
72
  * user identifiers — that is the enhanced-conversions match path.
70
73
  */
@@ -81,7 +84,10 @@ function getDataManagerEvent(event, config, data = {}, actionType = "webpage") {
81
84
  currency: normalizeCurrency(event.properties?.currency)
82
85
  };
83
86
  const { gclid, gbraid, wbraid } = require_server_click_ids.googleClickIds(event.tags);
84
- if (gclid) dmEvent.adIdentifiers = { gclid };
87
+ if (gclid) dmEvent.adIdentifiers = gbraid ? {
88
+ gclid,
89
+ gbraid
90
+ } : { gclid };
85
91
  else if (gbraid) dmEvent.adIdentifiers = { gbraid };
86
92
  else if (wbraid) dmEvent.adIdentifiers = { wbraid };
87
93
  const userIdentifiers = getUserIdentifiers(data);
@@ -1 +1 @@
1
- {"version":3,"file":"google-data-manager.cjs","names":["createHash","resolveActionSource","googleClickIds","IGNORED_EVENTS","fetch"],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { googleClickIds } from './click-ids';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\n/**\n * The kind of conversion action the events go to, which decides `eventSource`:\n * - `webpage` (default): the action the gtag tag reports to, as in the hybrid deployment this\n * module is built for. Its `eventSource` is optional and, when set, \"must be WEB\" — any other\n * value fails the request, and the request with it. Web events say `WEB`, others say nothing.\n * - `offline`: an action created for uploads (`UPLOAD_CLICKS`), where `eventSource` is required\n * and any value goes: `WEB`, `APP` or `OTHER` from the event's platform.\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/online/send-events\n */\nexport type GoogleAdsActionType = 'webpage' | 'offline';\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n /** The kind of conversion action `config` points at; `webpage` by default. */\n actionType?: GoogleAdsActionType;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n /** Absent for a non-web event sent to a `webpage` action, which accepts `WEB` only. */\n eventSource?: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {},\n actionType: GoogleAdsActionType = 'webpage'\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource:\n source === 'web'\n ? 'WEB'\n : actionType === 'webpage'\n ? undefined\n : source === 'app'\n ? 'APP'\n : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = googleClickIds(event.tags);\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data, options.actionType))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"google-data-manager.cjs","names":["createHash","resolveActionSource","googleClickIds","IGNORED_EVENTS","fetch"],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\nimport { googleClickIds } from './click-ids';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\n/**\n * The kind of conversion action the events go to, which decides `eventSource`:\n * - `webpage` (default): the action the gtag tag reports to, as in the hybrid deployment this\n * module is built for. Its `eventSource` is optional and, when set, \"must be WEB\" — any other\n * value fails the request, and the request with it. Web events say `WEB`, others say nothing.\n * - `offline`: an action created for uploads (`UPLOAD_CLICKS`), where `eventSource` is required\n * and any value goes: `WEB`, `APP` or `OTHER` from the event's platform.\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/online/send-events\n */\nexport type GoogleAdsActionType = 'webpage' | 'offline';\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n /** The kind of conversion action `config` points at; `webpage` by default. */\n actionType?: GoogleAdsActionType;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * All whitespace goes — \"leading, trailing and intermediate\".\n * https://developers.google.com/data-manager/api/devguides/concepts/formatting\n */\nexport function normalizeEmail(input: string): string {\n const email = input.replace(/\\s+/g, '').toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n /** Absent for a non-web event sent to a `webpage` action, which accepts `WEB` only. */\n eventSource?: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * The gclid goes with the gbraid when there are both — \"we recommend setting both the GCLID and\n * GBRAID\" — and otherwise one click id: gclid, else gbraid, else wbraid (nothing documents a\n * wbraid next to a gclid). https://developers.google.com/google-ads/api/docs/conversions/upload-clicks\n * Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {},\n actionType: GoogleAdsActionType = 'webpage'\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource:\n source === 'web'\n ? 'WEB'\n : actionType === 'webpage'\n ? undefined\n : source === 'app'\n ? 'APP'\n : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = googleClickIds(event.tags);\n if (gclid) dmEvent.adIdentifiers = gbraid ? { gclid, gbraid } : { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data, options.actionType))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":"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"}
@@ -42,7 +42,8 @@ export interface GoogleAdsConversionsOptions {
42
42
  /**
43
43
  * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the
44
44
  * username's dots and everything from '+' on are removed; for every other domain they are kept.
45
- * https://developers.google.com/data-manager/api/devguides/format-data
45
+ * All whitespace goes — "leading, trailing and intermediate".
46
+ * https://developers.google.com/data-manager/api/devguides/concepts/formatting
46
47
  */
47
48
  export declare function normalizeEmail(input: string): string;
48
49
  /** One entry of the `events` array. Field names are the REST (camelCase) spellings. */
@@ -66,8 +67,10 @@ export interface DataManagerEvent {
66
67
  }
67
68
  /**
68
69
  * Builds one Event, or undefined when no conversion action is configured for the event's name.
69
- * At most one click identifier is sent, gclid first: it is the deterministic per-click id,
70
- * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike
70
+ * The gclid goes with the gbraid when there are both — "we recommend setting both the GCLID and
71
+ * GBRAID" — and otherwise one click id: gclid, else gbraid, else wbraid (nothing documents a
72
+ * wbraid next to a gclid). https://developers.google.com/google-ads/api/docs/conversions/upload-clicks
73
+ * Unlike
71
74
  * the legacy upload, an event with no click id at all is still worth sending when it carries
72
75
  * user identifiers — that is the enhanced-conversions match path.
73
76
  */
@@ -1 +1 @@
1
- {"version":3,"file":"google-data-manager.d.cts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAsCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;;;;;;;;;;YAYU;iBAEK;;EAEf;EACA,UAAU;;EAEV,aAAa;;;;;;;wBAQC,eAAe;;iBA2Cd;EACf;EACA;;EAEA;;EAEA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;wBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,kBACN,aAAY,sBACX;iBA+Cc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
1
+ {"version":3,"file":"google-data-manager.d.cts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAsCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;;;;;;;;;;YAYU;iBAEK;;EAEf;EACA,UAAU;;EAEV,aAAa;;;;;;;;wBASC,eAAe;;iBA2Cd;EACf;EACA;;EAEA;;EAEA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;;;wBAYhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,kBACN,aAAY,sBACX;iBA+Cc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
@@ -42,7 +42,8 @@ export interface GoogleAdsConversionsOptions {
42
42
  /**
43
43
  * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the
44
44
  * username's dots and everything from '+' on are removed; for every other domain they are kept.
45
- * https://developers.google.com/data-manager/api/devguides/format-data
45
+ * All whitespace goes — "leading, trailing and intermediate".
46
+ * https://developers.google.com/data-manager/api/devguides/concepts/formatting
46
47
  */
47
48
  export declare function normalizeEmail(input: string): string;
48
49
  /** One entry of the `events` array. Field names are the REST (camelCase) spellings. */
@@ -66,8 +67,10 @@ export interface DataManagerEvent {
66
67
  }
67
68
  /**
68
69
  * Builds one Event, or undefined when no conversion action is configured for the event's name.
69
- * At most one click identifier is sent, gclid first: it is the deterministic per-click id,
70
- * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike
70
+ * The gclid goes with the gbraid when there are both — "we recommend setting both the GCLID and
71
+ * GBRAID" — and otherwise one click id: gclid, else gbraid, else wbraid (nothing documents a
72
+ * wbraid next to a gclid). https://developers.google.com/google-ads/api/docs/conversions/upload-clicks
73
+ * Unlike
71
74
  * the legacy upload, an event with no click id at all is still worth sending when it carries
72
75
  * user identifiers — that is the enhanced-conversions match path.
73
76
  */
@@ -1 +1 @@
1
- {"version":3,"file":"google-data-manager.d.mts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAsCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;;;;;;;;;;YAYU;iBAEK;;EAEf;EACA,UAAU;;EAEV,aAAa;;;;;;;wBAQC,eAAe;;iBA2Cd;EACf;EACA;;EAEA;;EAEA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;wBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,kBACN,aAAY,sBACX;iBA+Cc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
1
+ {"version":3,"file":"google-data-manager.d.mts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;YAsCY,4BAA4B,OAAO;iBAE9B;;EAEf;;EAEA;;EAEA;;;;;;iBAOe;EACf;EACA;;;;;;;;;;;YAYU;iBAEK;;EAEf;EACA,UAAU;;EAEV,aAAa;;;;;;;;wBASC,eAAe;;iBA2Cd;EACf;EACA;;EAEA;;EAEA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;;;wBAYhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,kBACN,aAAY,sBACX;iBA+Cc;EACf;;;;;;;;;wBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}