@shware/analytics 10.1.1 → 10.2.1

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.
package/README.md CHANGED
@@ -63,22 +63,209 @@ changes. The framework `Analytics` components wire this up.
63
63
  ## Sessions and attribution
64
64
 
65
65
  How the events this SDK sends are meant to be read on the server side, for attribution. Three
66
- layers, each derived from the one below it, none needing a table of its own:
67
-
68
- - **Session** — `event where name = 'session_start'`. The SDK opens a session after 30 minutes
69
- without an event and sends exactly one `session_start` at the head of the batch that opened it,
70
- carrying the tags of the event that opened it: the landing page's URL, utm parameters and click
71
- ids, captured when that event happened rather than when the batch went out. A partial unique
72
- index on `event (session_id) where name = 'session_start'` makes that row the session's one
73
- record on the server; there is no session table.
74
- - **Touchpoint** — a view over those rows that reads each session's tags as `channel`, `medium`
75
- and `campaign`. An explicit `utm_source` wins; then a click id in the landing URL (`fbclid`,
76
- `gclid`, … — never the cookie an earlier click left); then a landing page reserved for one
77
- channel's ads. The rules for what counts as which channel live here and nowhere else.
78
- - **Attribution** — a view over touchpoints that credits a session with no touch of its own to
79
- the same person's most recent paid touch, across devices (visitors sharing a `user_id`), within a
80
- window. Last paid touch, first touch and multi-touch are alternative models at this layer over
81
- the same touchpoints.
66
+ layers, each derived from the one below it:
67
+
68
+ - **Session** — one row per `session_start`. The SDK opens a session after 30 minutes without an
69
+ event and sends exactly one `session_start` at the head of the batch that opened it, carrying
70
+ the tags of the event that opened it: the landing page's URL, utm parameters and click ids,
71
+ captured when that event happened rather than when the batch went out. A partial unique index on
72
+ `event (session_id) where name = 'session_start'` makes that row the session's one record. The
73
+ server reads its tags once, when it writes the session, with `classifyTouch`
74
+ (`@shware/analytics/attribution`) and stores the touch on the session row; a rule change is
75
+ applied to history by reclassifying.
76
+ - **Touchpoint** — one row per touch: every session with its stored touch, plus the touches people
77
+ report (a survey answer, a call on a channel's number). Nothing is derived at query time.
78
+ - **Attribution** — one row per session, credited to the strongest touch of the same person
79
+ (across devices) within a window, the session's own touch included. See the rules below.
80
+
81
+ ### How a session is read as a touch (`classifyTouch`)
82
+
83
+ The channel and medium come from the first rule that says something:
84
+
85
+ | Order | Signal | `channel` | `medium` |
86
+ | ----- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
87
+ | 1 | `utm_source` in the URL | the source, aliases folded (`fb`, `ig` → `meta`) | `utm_medium`, or `(not set)` |
88
+ | 2 | A click id in the URL (`gclid`, `fbclid`, `rdt_cid`, …; never the cookie an earlier click left) | the id's platform | `cpc` |
89
+ | 3 | An ad landing page, `/lp/<channel>` | that channel | `cpc` |
90
+ | 4 | The product's own rules (`TouchRule`, e.g. a referral link) | as the rule says | as the rule says |
91
+ | 5 | The referrer's host | a known search engine, social network, video site or AI assistant folded to its channel; any other host kept as is | `organic` / `social` / `video` / `ai` / `referral` |
92
+ | 6 | Nothing | `(direct)` | `(none)` |
93
+
94
+ One exception: an ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`), or the
95
+ channel's ad landing page, proves the click was paid, so the medium becomes `cpc` when it was left
96
+ out or puts the touch in an organic group — a Reddit ad tagged `utm_source=reddit&utm_medium=social`
97
+ that carries `rdt_cid`. A declared paid medium is kept as it is. `fbclid` never proves anything:
98
+ Meta puts it on organic links too; nor does another channel's id (an ad link copied and shared
99
+ under a utm of its own).
100
+
101
+ `channel_group` is GA4's default channel grouping of the two (`channelGroupOf`): `paid_search`,
102
+ `organic_search`, `paid_social`, `email`, ….
103
+
104
+ The touch's `priority` (`TOUCH_PRIORITY`, lower is stronger) decides which touch wins in the
105
+ attribution layer:
106
+
107
+ | Priority | Touches |
108
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
109
+ | **1 — campaign** | A utm, click id or ad landing page whose group is paid, display, affiliate or unassigned; a product rule that says so (a referral link); a reported touch staff stand behind (`phone`, `manual`) |
110
+ | **2 — referrer** | A touch read from the referrer (organic search, social, other sites); **and** a utm, click id or ad landing page whose group comes out `organic_*`, `referral` or `email` (`utm_source=chatgpt.com` on ChatGPT's organic citations, `utm_medium=organic`, a newsletter) |
111
+ | **3 — claimed** | A reported touch that is the user's claim (`survey`, `promo_code`) |
112
+ | none | Direct: no touch |
113
+
114
+ When a product rule matched as well as a campaign rule, the stronger tier holds: a referral link
115
+ shared under `utm_source=facebook` is named by the utm (`meta`, organic social) and still ranks as
116
+ the referral programme declares.
117
+
118
+ In short: a link someone paid for is 1; what the browser brought, a tag that calls itself organic,
119
+ and our own emails are 2; what the user says is 3. Email is 2 because it reaches people we already
120
+ know, often because an ad brought them, and its links (a welcome mail, a trial reminder) come
121
+ between that ad and the purchase: it must not take the ad's credit, and with no ad in the window
122
+ it still wins as the latest touch.
123
+
124
+ ### How a session is credited (the attribution view)
125
+
126
+ For every session, among the same person's touches within the window (30 days in the reference
127
+ views), **the session's own touch included**:
128
+
129
+ 1. the lowest `priority` wins;
130
+ 2. among equals, the latest wins;
131
+ 3. on a full tie, the session's own touch wins.
132
+
133
+ | `touch_kind` | Meaning |
134
+ | ------------ | -------------------------------------------------------------------- |
135
+ | `own` | The session's own touch won |
136
+ | `inherited` | A direct session, credited to an earlier touch |
137
+ | `overridden` | The session had a touch of its own, but an earlier, stronger one won |
138
+ | `none` | A direct session with no touch in the window |
139
+
140
+ | # | What happened | The current session is credited to | `touch_kind` | Why |
141
+ | --- | ---------------------------------------------------------------------------------- | ---------------------------------- | -------------------- | ----------------------------------- |
142
+ | 1 | Day 1 a Meta ad click, day 2 a branded Google search | **Meta** | `overridden` | 1 beats 2 |
143
+ | 2 | Day 1 a Meta ad click, day 2 typed in | **Meta** | `inherited` | direct inherits |
144
+ | 3 | Day 1 an organic search, day 2 a Meta ad click | **Meta** | `own` | its own touch is stronger |
145
+ | 4 | Day 1 a Meta ad click, day 3 a Google ad click | **Google** | `own` | both 1, the latest wins |
146
+ | 5 | Day 1 a Meta ad click, day 2 a ChatGPT citation with its utm | **Meta** | `overridden` | ChatGPT's organic utm ranks 2 |
147
+ | 6 | Day 1 an organic search, day 5 a ChatGPT citation | **ChatGPT** | `own` | both 2, the latest wins |
148
+ | 7 | An ad click 45 days ago, an organic search today | **Organic search** | `own` | the ad is out of the window |
149
+ | 8 | A Reddit ad tagged `utm_medium=social` with `rdt_cid` | **reddit / cpc** | `own` | the click id proves a paid click |
150
+ | 9 | Day 1 a Meta ad click, day 2 a newsletter link (`utm_medium=email`) | **Meta** | `overridden` | email ranks 2 |
151
+ | 10 | Day 1 an organic search, day 2 a newsletter link, no ad in the window | **Email** | `own` | both 2, the latest wins |
152
+ | 11 | A survey answer "a podcast" (backdated to the first visit), then an organic search | **Organic search** | `own` | 2 beats 3; a claim only fills a gap |
153
+ | 12 | Day 1 a ChatGPT citation, day 2 a branded Google search | **Organic search** | `own` | both 2, the latest wins |
154
+ | 13 | Day 1 a ChatGPT citation, day 2 a ChatGPT ad click (`oppref`) | **chatgpt / cpc** | `own` | the ad is 1 |
155
+ | 14 | Day 1 a Meta ad click, day 2 a ChatGPT citation, day 3 typed in | **Meta** | `inherited` | 1 beats the citation's 2 |
156
+ | 15 | The ChatGPT app opens a link with neither referrer nor utm | **Direct**, or what came before | `none` / `inherited` | nothing to read |
157
+
158
+ **How this differs from GA4.** GA4's last non-direct click credits the latest touch that is not
159
+ direct, whatever it was: cases 1, 5 and 9 go to the search, ChatGPT and the email. Here an ad click is not
160
+ overwritten by the organic visit that follows it within the window, which is what a paid ROAS
161
+ panel expects. The touchpoint layer still has every session's own touch, so the GA4-style figure
162
+ is one query away, and `overridden` measures the difference.
163
+
164
+ **Retargeting.** No click-based model can tell whether an ad caused the visit, and retargeting is
165
+ where that shows: someone who would have come back anyway clicks a retargeting ad on the way in.
166
+ For a returning customer who was going to buy anyway:
167
+
168
+ | What happened | GA4 last non-direct click | This model |
169
+ | ----------------------------------------------------------------- | ------------------------- | ---------- |
170
+ | Clicks a retargeting ad, buys in that session | the ad | the ad |
171
+ | Clicks a retargeting ad, types the site in the next day and buys | the ad (direct inherits) | the ad |
172
+ | Clicks a retargeting ad, searches the brand the next day and buys | **the search** | the ad |
173
+
174
+ GA4's model only removes the third row, and that row is the credit a prospecting ad deserves (the
175
+ ad made the brand known, the search followed); the first two rows, where most of retargeting's
176
+ inflation is, stay in both. So crediting the search is not a fix. Read retargeting through the
177
+ report instead of the model:
178
+
179
+ 1. **Acquisition by first touch.** A person's first touch (`user_attribution` in the reference
180
+ views) is settled long before any retargeting reaches them, so new users and their lifetime
181
+ value by first channel leave retargeting out by construction. Judge acquisition ROAS there.
182
+ 2. **Keep retargeting apart.** Name retargeting campaigns so they can be told apart
183
+ (`utm_campaign` with an `rt_` prefix, say), and read the session-level attribution of those
184
+ campaigns split into new and returning people: a high share of returning people is credit that
185
+ was probably not incremental.
186
+ 3. **Measure the increment when the spend justifies it.** A holdout (Meta's Conversion Lift, a
187
+ geo split) is the only way to answer whether the ad caused the purchase.
188
+
189
+ Special cases in the model ("an ad clicked by a paying customer does not override") are not
190
+ worth it: the rules get harder to explain, and the first two rows remain.
191
+
192
+ ### AI assistants (GEO)
193
+
194
+ Visits an AI assistant sends — a citation in ChatGPT, Perplexity, Gemini, Claude, Copilot,
195
+ DeepSeek and the others in `REFERRER_SITES` — are `organic_ai`, read from the referrer or from the
196
+ assistant's own utm (`utm_source=chatgpt.com`). They rank with organic search, priority 2, on
197
+ purpose: both are earned, not bought, and come from a question the user asked.
198
+
199
+ - **Not above SEO**: a citation would otherwise take the credit from the ad clicked before it, and
200
+ from the search that followed it.
201
+ - **Not below SEO**: "ask ChatGPT, then search the brand" would then always go to the search, and
202
+ GEO would read lower than it is.
203
+ - **Apart from ChatGPT's ads**: a click on a ChatGPT ad carries `oppref` and is paid, priority 1
204
+ (`paid_other`); the citations next to it stay `organic_ai`.
205
+
206
+ GA4's default grouping has no AI group and files these visits under Referral; `organic_ai` is an
207
+ addition so GEO and SEO can be compared side by side.
208
+
209
+ What the data cannot show, and how to read around it:
210
+
211
+ 1. **Dark AI traffic.** The assistants' desktop and mobile apps, and links copied into a browser,
212
+ often arrive with neither referrer nor utm, and are counted as direct (case 15). GEO is always
213
+ undercounted.
214
+ 2. **Discovery credited to search.** Someone who learns the brand from an assistant and searches
215
+ it the next day is credited to the search (case 12), as in every last-touch model.
216
+ 3. **Read GEO by first touch** (`user_attribution` in the reference views), where the assistant
217
+ that introduced the brand keeps the credit, and **ask**: a sign-up survey ("where did you hear
218
+ about us?" with the assistants as options) stored as a reported touch (`survey`, priority 3)
219
+ never overrides a tracked touch, but measures how much of the direct traffic the assistants
220
+ brought.
221
+
222
+ ### Compared with other tools
223
+
224
+ How other tools credit a visit, as far as their documented defaults go (they change often; check
225
+ the vendor's documentation before relying on a detail):
226
+
227
+ | Tool | Main model | Direct visits | Priority between channels | Window | Across devices |
228
+ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------- |
229
+ | **GA4** | Data-driven by default; paid and organic last click, and Google paid channels last click, as options | Inherit the last non-direct source | Only in "Google paid channels last click", for Google Ads | 30 days (configurable) | Signed-in user id, Google signals |
230
+ | **PostHog** | A session's entry source (`$entry_utm_*`, `$entry_referring_domain`, grouped GA4-style) and a person's first touch (`$initial_*`); funnels break down by first or last touch | A direct session stays direct: no look-back | None | — | Merged persons via `identify` |
231
+ | **Mixpanel** | Picked per report: first touch, last touch, linear, U-shaped, time decay, participation | Only events with utm count as touches, so direct is skipped | None | Configurable look-back | Identity merge |
232
+ | **Amplitude** | Initial / latest utm user properties (latest changes only on a new utm), plus multi-touch models | Skipped | None | Configurable | Identity merge |
233
+ | **Adobe Analytics** | Marketing channels classified by ordered processing rules; first and last touch channel with expiry; Attribution IQ models incl. algorithmic | Usually set not to override | **Yes**: per channel, whether it overrides the last touch | Visit or days, configurable | Identity stitching |
234
+ | **Ad platforms** (Meta, Google Ads, …) | Each claims the conversions it touched (Meta: 7-day click, 1-day view by default; Google Ads: data-driven) | Not their concern | Only themselves, so platforms **claim the same conversion twice** | Their own | Their own accounts |
235
+ | **Mobile measurement partners** (AppsFlyer, Adjust) | Last click on install, with a **priority hierarchy** (click over view, device id match over probabilistic); organic when nothing in the window | Organic install | **Yes**, the core of the model | Click about 7 days, configurable; re-attribution windows | Device |
236
+ | **HubSpot** | A contact's original source (first touch) and multi-touch revenue models | Does not change the first touch | None | — | Contact |
237
+
238
+ Where this model stands:
239
+
240
+ | Trait | Shared with | Unlike |
241
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
242
+ | Direct visits inherit an earlier source | GA4, Mixpanel, Amplitude, Adobe — the common ground | PostHog's session-level source, which does not look back |
243
+ | Channels are ranked, a paid touch over an organic one | **Adobe's override rules, the MMPs' priority hierarchy** — the tools built for marketing and media-buying decisions | The product analytics tools (PostHog, Mixpanel, Amplitude), where every touch is equal |
244
+ | One touch per session | Every first- or last-touch model | Multi-touch models, which split the credit |
245
+ | A first-touch view per person | GA4's first user source, PostHog's and Amplitude's initial properties, HubSpot's original source | — |
246
+
247
+ So this is a rule-based last-touch model with channel priority: more favourable to ads than the
248
+ product analytics tools, in the same family as Adobe's and the MMPs' rules. Two consequences to
249
+ expect: a platform's own dashboard will report more conversions than this model credits it with
250
+ (it counts view-through, and every platform claims a shared conversion), and the sum of the
251
+ platforms' own figures exceeds the real total.
252
+
253
+ ### Other models
254
+
255
+ The touchpoint layer keeps every touch, so other models can be added next to this one as views,
256
+ without changing it:
257
+
258
+ - **Multi-touch (rule-based)**: the credit of one conversion is split across the touches on its
259
+ path, by a fixed rule — linear (equal shares), time decay (more to the recent ones), position
260
+ based (40% first, 40% last, 20% spread over the middle). Useful to see which channels open a
261
+ journey and which close it; the shares are a convention, not a measurement.
262
+ - **Data-driven (algorithmic)**: a model learns from converting and non-converting paths how much
263
+ each touch raises the chance of converting (Shapley values or Markov chains), and splits the
264
+ credit by that. GA4's and Google Ads' default; it needs a large volume of conversions to be
265
+ stable, and it is still a model of correlation, not of causation.
266
+ - **Incrementality**: only a holdout experiment (a conversion lift study, a geo split) measures
267
+ what an ad caused. It answers a different question from every model above, and is the one to
268
+ run when the spend on a channel is large enough to matter.
82
269
 
83
270
  What the SDK guarantees for this to hold:
84
271
 
@@ -2,6 +2,21 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_attribution_vocabulary = require("./vocabulary.cjs");
3
3
  //#region src/attribution/classify.ts
4
4
  const metaPlacements = new Set(require_attribution_vocabulary.META_PLACEMENTS);
5
+ /** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */
6
+ const ORGANIC_GROUPS = /* @__PURE__ */ new Set([
7
+ "organic_search",
8
+ "organic_social",
9
+ "organic_video",
10
+ "organic_ai",
11
+ "referral"
12
+ ]);
13
+ /**
14
+ * The groups a campaign rule ranks with a referrer in: the organic ones, and email. Email reaches
15
+ * people we already know — often because an ad brought them — and its links (a welcome mail, a
16
+ * trial reminder) come between that ad and the purchase, so it must not take the ad's credit.
17
+ * With no ad in the window it still wins as the latest touch.
18
+ */
19
+ const REFERRER_TIER_GROUPS = /* @__PURE__ */ new Set([...ORGANIC_GROUPS, "email"]);
5
20
  const referrerHostOf = /^https?:\/\/([^/:?#]+)/i;
6
21
  const channelsBy = (medium) => new Set(require_attribution_vocabulary.REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));
7
22
  const searchChannels = channelsBy("organic");
@@ -20,10 +35,12 @@ function text(tags, key) {
20
35
  }
21
36
  /**
22
37
  * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click
23
- * ids, as in GA4: a utm is written for this link by whoever placed it, while a click id can be
24
- * added by someone else — Meta puts `fbclid` on organic links too, and a shared or forwarded ad
25
- * link keeps the click id of the ad. When the two disagree, the utm is the one that was right:
26
- * an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.
38
+ * ids — unlike GA4, where a `gclid` wins over the utm unless the property sets "manual tagging
39
+ * overrides auto-tagging": a utm is written for this link by whoever placed it, while a click id
40
+ * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or
41
+ * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that
42
+ * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.
43
+ * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.
27
44
  */
28
45
  function utmChannel(tags) {
29
46
  const source = text(tags, "utm_source")?.toLowerCase().split(/[?&#]/)[0]?.replace(/\/+$/, "");
@@ -107,13 +124,19 @@ function channelGroupOf(channel, medium) {
107
124
  * The rules, in order, the first that says something naming the channel: an explicit
108
125
  * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
109
126
  * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
110
- * rule implies: `(not set)` for a utm_source alone — but `cpc` when an ad-only click id of the
111
- * same channel came with it (`'ads'` in `CLICK_ID_CHANNELS`) — `cpc` for a bare click id or ad landing page,
127
+ * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
112
128
  * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
113
- * for direct. `campaign` is `utm_campaign`, else what a product rule captured — even when the
114
- * utm named the channel, so a tagged referral link keeps its code. `priority` is the tier of
115
- * the rule that named the channel (`TOUCH_PRIORITY`): campaign touches, then the product's own
116
- * as they declare, then the referrer.
129
+ * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
130
+ * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
131
+ * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
132
+ * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a
133
+ * product rule captured — even when the utm named the channel, so a tagged referral link keeps
134
+ * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
135
+ * campaign touches, then the product's own as they declare, then the referrer — except that a
136
+ * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
137
+ * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
138
+ * not take the credit from an ad clicked earlier in the attribution window. When a product rule
139
+ * matched too, the stronger of the two tiers holds.
117
140
  */
118
141
  function classifyTouch(tags, options = {}) {
119
142
  const utm = utmChannel(tags);
@@ -122,15 +145,17 @@ function classifyTouch(tags, options = {}) {
122
145
  const product = productTouch(tags, options.rules ?? []);
123
146
  const referred = referrer(tags, options.ownHosts ?? []);
124
147
  const channel = utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? "(direct)";
125
- const medium = text(tags, "utm_medium")?.toLowerCase() ?? null ?? (utm ? adClickChannel(tags) === utm ? "cpc" : "(not set)" : click || landing ? "cpc" : product?.medium ?? referred?.medium ?? "(none)");
148
+ const namedMedium = text(tags, "utm_medium")?.toLowerCase() ?? null ?? (utm ? "(not set)" : click || landing ? "cpc" : product?.medium ?? referred?.medium ?? "(none)");
149
+ const medium = (adClickChannel(tags) === channel || landing === channel) && (namedMedium === "(not set)" || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium))) ? "cpc" : namedMedium;
150
+ const channelGroup = channelGroupOf(channel, medium);
126
151
  const campaign = text(tags, "utm_campaign") ?? product?.campaign ?? null;
127
- const priority = utm || click || landing ? require_attribution_vocabulary.TOUCH_PRIORITY.campaign : product ? product.priority : referred ? require_attribution_vocabulary.TOUCH_PRIORITY.referrer : null;
152
+ const ranked = [utm || click || landing ? REFERRER_TIER_GROUPS.has(channelGroup) ? require_attribution_vocabulary.TOUCH_PRIORITY.referrer : require_attribution_vocabulary.TOUCH_PRIORITY.campaign : null, product?.priority ?? null].filter((tier) => tier !== null);
128
153
  return {
129
154
  channel,
130
155
  medium,
131
- channel_group: channelGroupOf(channel, medium),
156
+ channel_group: channelGroup,
132
157
  campaign,
133
- priority
158
+ priority: ranked.length > 0 ? Math.min(...ranked) : referred ? require_attribution_vocabulary.TOUCH_PRIORITY.referrer : null
134
159
  };
135
160
  }
136
161
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"classify.cjs","names":["META_PLACEMENTS","REFERRER_SITES","SOURCE_ALIASES","CLICK_ID_CHANNELS","AD_LANDING_PAGE","REFERRERS_NOT_A_TOUCH","DISPLAY_MEDIUMS","PAID_MEDIUM","EMAIL_MEDIUM","EMAIL_MEDIUMS","TOUCH_PRIORITY"],"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.cjs","names":["META_PLACEMENTS","REFERRER_SITES","SOURCE_ALIASES","CLICK_ID_CHANNELS","AD_LANDING_PAGE","REFERRERS_NOT_A_TOUCH","DISPLAY_MEDIUMS","PAID_MEDIUM","EMAIL_MEDIUM","EMAIL_MEDIUMS","TOUCH_PRIORITY"],"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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"}
@@ -49,13 +49,19 @@ export declare function channelGroupOf(channel: string, medium: string): Channel
49
49
  * The rules, in order, the first that says something naming the channel: an explicit
50
50
  * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
51
51
  * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
52
- * rule implies: `(not set)` for a utm_source alone — but `cpc` when an ad-only click id of the
53
- * same channel came with it (`'ads'` in `CLICK_ID_CHANNELS`) — `cpc` for a bare click id or ad landing page,
52
+ * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
54
53
  * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
55
- * for direct. `campaign` is `utm_campaign`, else what a product rule captured — even when the
56
- * utm named the channel, so a tagged referral link keeps its code. `priority` is the tier of
57
- * the rule that named the channel (`TOUCH_PRIORITY`): campaign touches, then the product's own
58
- * as they declare, then the referrer.
54
+ * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
55
+ * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
56
+ * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
57
+ * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a
58
+ * product rule captured — even when the utm named the channel, so a tagged referral link keeps
59
+ * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
60
+ * campaign touches, then the product's own as they declare, then the referrer — except that a
61
+ * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
62
+ * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
63
+ * not take the credit from an ad clicked earlier in the attribution window. When a product rule
64
+ * matched too, the stronger of the two tiers holds.
59
65
  */
60
66
  export declare function classifyTouch(tags: TrackTags, options?: ClassifyOptions): Touch;
61
67
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"classify.d.cts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAoBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBA4FH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;wBAoCjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
1
+ {"version":3,"file":"classify.d.cts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAoBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBA6GH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA0CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
@@ -49,13 +49,19 @@ export declare function channelGroupOf(channel: string, medium: string): Channel
49
49
  * The rules, in order, the first that says something naming the channel: an explicit
50
50
  * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
51
51
  * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
52
- * rule implies: `(not set)` for a utm_source alone — but `cpc` when an ad-only click id of the
53
- * same channel came with it (`'ads'` in `CLICK_ID_CHANNELS`) — `cpc` for a bare click id or ad landing page,
52
+ * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
54
53
  * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
55
- * for direct. `campaign` is `utm_campaign`, else what a product rule captured — even when the
56
- * utm named the channel, so a tagged referral link keeps its code. `priority` is the tier of
57
- * the rule that named the channel (`TOUCH_PRIORITY`): campaign touches, then the product's own
58
- * as they declare, then the referrer.
54
+ * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
55
+ * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
56
+ * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
57
+ * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a
58
+ * product rule captured — even when the utm named the channel, so a tagged referral link keeps
59
+ * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
60
+ * campaign touches, then the product's own as they declare, then the referrer — except that a
61
+ * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
62
+ * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
63
+ * not take the credit from an ad clicked earlier in the attribution window. When a product rule
64
+ * matched too, the stronger of the two tiers holds.
59
65
  */
60
66
  export declare function classifyTouch(tags: TrackTags, options?: ClassifyOptions): Touch;
61
67
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"classify.d.mts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAoBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBA4FH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;wBAoCjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
1
+ {"version":3,"file":"classify.d.mts","names":[],"sources":["../../src/attribution/classify.ts"],"mappings":";;;;iBAoBiB;;EAEf;;EAEA;;EAEA,eAAe;;EAEf;;EAEA;;;YAIU,eAAe,KAAK;;;;;;;YAQpB,aAAa,MAAM,cAAc;iBAE5B;;;;;;EAMf,oBAAoB;;EAEpB,iBAAiB;;;;;;;;;;wBA6GH,eAAe,iBAAiB,iBAAiB;;;;;;;;;;;;;;;;;;;;;;wBA0CjD,cAAc,MAAM,WAAW,UAAS,kBAAuB"}
@@ -1,6 +1,21 @@
1
1
  import { AD_LANDING_PAGE, CLICK_ID_CHANNELS, DISPLAY_MEDIUMS, EMAIL_MEDIUM, EMAIL_MEDIUMS, META_PLACEMENTS, PAID_MEDIUM, REFERRERS_NOT_A_TOUCH, REFERRER_SITES, SOURCE_ALIASES, TOUCH_PRIORITY } from "./vocabulary.mjs";
2
2
  //#region src/attribution/classify.ts
3
3
  const metaPlacements = new Set(META_PLACEMENTS);
4
+ /** The groups no one paid for: a campaign rule landing in one of them ranks with a referrer. */
5
+ const ORGANIC_GROUPS = /* @__PURE__ */ new Set([
6
+ "organic_search",
7
+ "organic_social",
8
+ "organic_video",
9
+ "organic_ai",
10
+ "referral"
11
+ ]);
12
+ /**
13
+ * The groups a campaign rule ranks with a referrer in: the organic ones, and email. Email reaches
14
+ * people we already know — often because an ad brought them — and its links (a welcome mail, a
15
+ * trial reminder) come between that ad and the purchase, so it must not take the ad's credit.
16
+ * With no ad in the window it still wins as the latest touch.
17
+ */
18
+ const REFERRER_TIER_GROUPS = /* @__PURE__ */ new Set([...ORGANIC_GROUPS, "email"]);
4
19
  const referrerHostOf = /^https?:\/\/([^/:?#]+)/i;
5
20
  const channelsBy = (medium) => new Set(REFERRER_SITES.filter(([, m]) => m === medium).map(([channel]) => channel));
6
21
  const searchChannels = channelsBy("organic");
@@ -19,10 +34,12 @@ function text(tags, key) {
19
34
  }
20
35
  /**
21
36
  * 1. What the campaign said: `utm_source`, with the aliases folded. It comes before the click
22
- * ids, as in GA4: a utm is written for this link by whoever placed it, while a click id can be
23
- * added by someone else — Meta puts `fbclid` on organic links too, and a shared or forwarded ad
24
- * link keeps the click id of the ad. When the two disagree, the utm is the one that was right:
25
- * an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.
37
+ * ids — unlike GA4, where a `gclid` wins over the utm unless the property sets "manual tagging
38
+ * overrides auto-tagging": a utm is written for this link by whoever placed it, while a click id
39
+ * can be added by someone else — Meta puts `fbclid` on organic links too, and a shared or
40
+ * forwarded ad link keeps the click id of the ad. When the two disagree, the utm is the one that
41
+ * was right: an Instagram profile link carrying `fbclid`, a newsletter link copied from an ad.
42
+ * The one exception is an ad-only click id of the utm's own channel, see `classifyTouch`.
26
43
  */
27
44
  function utmChannel(tags) {
28
45
  const source = text(tags, "utm_source")?.toLowerCase().split(/[?&#]/)[0]?.replace(/\/+$/, "");
@@ -106,13 +123,19 @@ function channelGroupOf(channel, medium) {
106
123
  * The rules, in order, the first that says something naming the channel: an explicit
107
124
  * `utm_source`, a click id, an ad landing page, the product's own rules, the referrer's host;
108
125
  * `(direct)` when none does. `medium` is `utm_medium` as declared (lower-cased), else what that
109
- * rule implies: `(not set)` for a utm_source alone — but `cpc` when an ad-only click id of the
110
- * same channel came with it (`'ads'` in `CLICK_ID_CHANNELS`) — `cpc` for a bare click id or ad landing page,
126
+ * rule implies: `(not set)` for a utm_source alone, `cpc` for a bare click id or ad landing page,
111
127
  * the product rule's own, `organic` / `social` / `video` / `referral` for a referrer, `(none)`
112
- * for direct. `campaign` is `utm_campaign`, else what a product rule captured — even when the
113
- * utm named the channel, so a tagged referral link keeps its code. `priority` is the tier of
114
- * the rule that named the channel (`TOUCH_PRIORITY`): campaign touches, then the product's own
115
- * as they declare, then the referrer.
128
+ * for direct. An ad-only click id of the channel itself (`'ads'` in `CLICK_ID_CHANNELS`) or its ad
129
+ * landing page makes it `cpc` when the medium was left out or puts the touch in an organic group:
130
+ * the click was paid whatever the tag said — a Reddit ad tagged `utm_medium=social`, a ChatGPT ad
131
+ * keeping the `utm_source=chatgpt.com` of its organic links. `campaign` is `utm_campaign`, else what a
132
+ * product rule captured — even when the utm named the channel, so a tagged referral link keeps
133
+ * its code. `priority` is the tier of the rule that named the channel (`TOUCH_PRIORITY`):
134
+ * campaign touches, then the product's own as they declare, then the referrer — except that a
135
+ * campaign rule whose touch lands in an organic group, referral (`utm_source=chatgpt.com`,
136
+ * `utm_medium=organic`) or email ranks with a referrer: nobody paid for that click, and it should
137
+ * not take the credit from an ad clicked earlier in the attribution window. When a product rule
138
+ * matched too, the stronger of the two tiers holds.
116
139
  */
117
140
  function classifyTouch(tags, options = {}) {
118
141
  const utm = utmChannel(tags);
@@ -121,15 +144,17 @@ function classifyTouch(tags, options = {}) {
121
144
  const product = productTouch(tags, options.rules ?? []);
122
145
  const referred = referrer(tags, options.ownHosts ?? []);
123
146
  const channel = utm ?? click ?? landing ?? product?.channel ?? referred?.channel ?? "(direct)";
124
- const medium = text(tags, "utm_medium")?.toLowerCase() ?? null ?? (utm ? adClickChannel(tags) === utm ? "cpc" : "(not set)" : click || landing ? "cpc" : product?.medium ?? referred?.medium ?? "(none)");
147
+ const namedMedium = text(tags, "utm_medium")?.toLowerCase() ?? null ?? (utm ? "(not set)" : click || landing ? "cpc" : product?.medium ?? referred?.medium ?? "(none)");
148
+ const medium = (adClickChannel(tags) === channel || landing === channel) && (namedMedium === "(not set)" || ORGANIC_GROUPS.has(channelGroupOf(channel, namedMedium))) ? "cpc" : namedMedium;
149
+ const channelGroup = channelGroupOf(channel, medium);
125
150
  const campaign = text(tags, "utm_campaign") ?? product?.campaign ?? null;
126
- const priority = utm || click || landing ? TOUCH_PRIORITY.campaign : product ? product.priority : referred ? TOUCH_PRIORITY.referrer : null;
151
+ const ranked = [utm || click || landing ? REFERRER_TIER_GROUPS.has(channelGroup) ? TOUCH_PRIORITY.referrer : TOUCH_PRIORITY.campaign : null, product?.priority ?? null].filter((tier) => tier !== null);
127
152
  return {
128
153
  channel,
129
154
  medium,
130
- channel_group: channelGroupOf(channel, medium),
155
+ channel_group: channelGroup,
131
156
  campaign,
132
- priority
157
+ priority: ranked.length > 0 ? Math.min(...ranked) : referred ? TOUCH_PRIORITY.referrer : null
133
158
  };
134
159
  }
135
160
  //#endregion
@@ -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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shware/analytics",
3
- "version": "10.1.1",
3
+ "version": "10.2.1",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",