@shware/analytics 10.1.0 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -16
- package/dist/attribution/classify.cjs +39 -14
- package/dist/attribution/classify.cjs.map +1 -1
- package/dist/attribution/classify.d.cts +12 -6
- package/dist/attribution/classify.d.cts.map +1 -1
- package/dist/attribution/classify.d.mts +12 -6
- package/dist/attribution/classify.d.mts.map +1 -1
- package/dist/attribution/classify.mjs +39 -14
- package/dist/attribution/classify.mjs.map +1 -1
- package/dist/attribution/vocabulary.cjs +6 -2
- package/dist/attribution/vocabulary.cjs.map +1 -1
- package/dist/attribution/vocabulary.d.cts +6 -2
- package/dist/attribution/vocabulary.d.cts.map +1 -1
- package/dist/attribution/vocabulary.d.mts +6 -2
- package/dist/attribution/vocabulary.d.mts.map +1 -1
- package/dist/attribution/vocabulary.mjs +6 -2
- package/dist/attribution/vocabulary.mjs.map +1 -1
- package/dist/server/google-data-manager.cjs +11 -5
- package/dist/server/google-data-manager.cjs.map +1 -1
- package/dist/server/google-data-manager.d.cts +6 -3
- package/dist/server/google-data-manager.d.cts.map +1 -1
- package/dist/server/google-data-manager.d.mts +6 -3
- package/dist/server/google-data-manager.d.mts.map +1 -1
- package/dist/server/google-data-manager.mjs +11 -5
- package/dist/server/google-data-manager.mjs.map +1 -1
- package/dist/server/linkedin-conversions-api.cjs +17 -6
- package/dist/server/linkedin-conversions-api.cjs.map +1 -1
- package/dist/server/linkedin-conversions-api.d.cts.map +1 -1
- package/dist/server/linkedin-conversions-api.d.mts.map +1 -1
- package/dist/server/linkedin-conversions-api.mjs +17 -6
- package/dist/server/linkedin-conversions-api.mjs.map +1 -1
- package/dist/server/meta-capi.cjs +25 -7
- package/dist/server/meta-capi.cjs.map +1 -1
- package/dist/server/meta-capi.d.cts +14 -1
- package/dist/server/meta-capi.d.cts.map +1 -1
- package/dist/server/meta-capi.d.mts +14 -1
- package/dist/server/meta-capi.d.mts.map +1 -1
- package/dist/server/meta-capi.mjs +25 -8
- package/dist/server/meta-capi.mjs.map +1 -1
- package/dist/server/meta-conversions-api.cjs +9 -12
- package/dist/server/meta-conversions-api.cjs.map +1 -1
- package/dist/server/meta-conversions-api.d.cts.map +1 -1
- package/dist/server/meta-conversions-api.d.mts.map +1 -1
- package/dist/server/meta-conversions-api.mjs +9 -12
- package/dist/server/meta-conversions-api.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,22 +63,175 @@ 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
|
|
67
|
-
|
|
68
|
-
- **Session** —
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
- **Attribution** —
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
|
|
154
|
+
**How this differs from GA4.** GA4's last non-direct click credits the latest touch that is not
|
|
155
|
+
direct, whatever it was: cases 1, 5 and 9 go to the search, ChatGPT and the email. Here an ad click is not
|
|
156
|
+
overwritten by the organic visit that follows it within the window, which is what a paid ROAS
|
|
157
|
+
panel expects. The touchpoint layer still has every session's own touch, so the GA4-style figure
|
|
158
|
+
is one query away, and `overridden` measures the difference.
|
|
159
|
+
|
|
160
|
+
**Retargeting.** No click-based model can tell whether an ad caused the visit, and retargeting is
|
|
161
|
+
where that shows: someone who would have come back anyway clicks a retargeting ad on the way in.
|
|
162
|
+
For a returning customer who was going to buy anyway:
|
|
163
|
+
|
|
164
|
+
| What happened | GA4 last non-direct click | This model |
|
|
165
|
+
| ----------------------------------------------------------------- | ------------------------- | ---------- |
|
|
166
|
+
| Clicks a retargeting ad, buys in that session | the ad | the ad |
|
|
167
|
+
| Clicks a retargeting ad, types the site in the next day and buys | the ad (direct inherits) | the ad |
|
|
168
|
+
| Clicks a retargeting ad, searches the brand the next day and buys | **the search** | the ad |
|
|
169
|
+
|
|
170
|
+
GA4's model only removes the third row, and that row is the credit a prospecting ad deserves (the
|
|
171
|
+
ad made the brand known, the search followed); the first two rows, where most of retargeting's
|
|
172
|
+
inflation is, stay in both. So crediting the search is not a fix. Read retargeting through the
|
|
173
|
+
report instead of the model:
|
|
174
|
+
|
|
175
|
+
1. **Acquisition by first touch.** A person's first touch (`user_attribution` in the reference
|
|
176
|
+
views) is settled long before any retargeting reaches them, so new users and their lifetime
|
|
177
|
+
value by first channel leave retargeting out by construction. Judge acquisition ROAS there.
|
|
178
|
+
2. **Keep retargeting apart.** Name retargeting campaigns so they can be told apart
|
|
179
|
+
(`utm_campaign` with an `rt_` prefix, say), and read the session-level attribution of those
|
|
180
|
+
campaigns split into new and returning people: a high share of returning people is credit that
|
|
181
|
+
was probably not incremental.
|
|
182
|
+
3. **Measure the increment when the spend justifies it.** A holdout (Meta's Conversion Lift, a
|
|
183
|
+
geo split) is the only way to answer whether the ad caused the purchase.
|
|
184
|
+
|
|
185
|
+
Special cases in the model ("an ad clicked by a paying customer does not override") are not
|
|
186
|
+
worth it: the rules get harder to explain, and the first two rows remain.
|
|
187
|
+
|
|
188
|
+
### Compared with other tools
|
|
189
|
+
|
|
190
|
+
How other tools credit a visit, as far as their documented defaults go (they change often; check
|
|
191
|
+
the vendor's documentation before relying on a detail):
|
|
192
|
+
|
|
193
|
+
| Tool | Main model | Direct visits | Priority between channels | Window | Across devices |
|
|
194
|
+
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------- | --------------------------------- |
|
|
195
|
+
| **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 |
|
|
196
|
+
| **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` |
|
|
197
|
+
| **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 |
|
|
198
|
+
| **Amplitude** | Initial / latest utm user properties (latest changes only on a new utm), plus multi-touch models | Skipped | None | Configurable | Identity merge |
|
|
199
|
+
| **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 |
|
|
200
|
+
| **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 |
|
|
201
|
+
| **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 |
|
|
202
|
+
| **HubSpot** | A contact's original source (first touch) and multi-touch revenue models | Does not change the first touch | None | — | Contact |
|
|
203
|
+
|
|
204
|
+
Where this model stands:
|
|
205
|
+
|
|
206
|
+
| Trait | Shared with | Unlike |
|
|
207
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
208
|
+
| Direct visits inherit an earlier source | GA4, Mixpanel, Amplitude, Adobe — the common ground | PostHog's session-level source, which does not look back |
|
|
209
|
+
| 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 |
|
|
210
|
+
| One touch per session | Every first- or last-touch model | Multi-touch models, which split the credit |
|
|
211
|
+
| A first-touch view per person | GA4's first user source, PostHog's and Amplitude's initial properties, HubSpot's original source | — |
|
|
212
|
+
|
|
213
|
+
So this is a rule-based last-touch model with channel priority: more favourable to ads than the
|
|
214
|
+
product analytics tools, in the same family as Adobe's and the MMPs' rules. Two consequences to
|
|
215
|
+
expect: a platform's own dashboard will report more conversions than this model credits it with
|
|
216
|
+
(it counts view-through, and every platform claims a shared conversion), and the sum of the
|
|
217
|
+
platforms' own figures exceeds the real total.
|
|
218
|
+
|
|
219
|
+
### Other models
|
|
220
|
+
|
|
221
|
+
The touchpoint layer keeps every touch, so other models can be added next to this one as views,
|
|
222
|
+
without changing it:
|
|
223
|
+
|
|
224
|
+
- **Multi-touch (rule-based)**: the credit of one conversion is split across the touches on its
|
|
225
|
+
path, by a fixed rule — linear (equal shares), time decay (more to the recent ones), position
|
|
226
|
+
based (40% first, 40% last, 20% spread over the middle). Useful to see which channels open a
|
|
227
|
+
journey and which close it; the shares are a convention, not a measurement.
|
|
228
|
+
- **Data-driven (algorithmic)**: a model learns from converting and non-converting paths how much
|
|
229
|
+
each touch raises the chance of converting (Shapley values or Markov chains), and splits the
|
|
230
|
+
credit by that. GA4's and Google Ads' default; it needs a large volume of conversions to be
|
|
231
|
+
stable, and it is still a model of correlation, not of causation.
|
|
232
|
+
- **Incrementality**: only a holdout experiment (a conversion lift study, a geo split) measures
|
|
233
|
+
what an ad caused. It answers a different question from every model above, and is the one to
|
|
234
|
+
run when the spend on a channel is large enough to matter.
|
|
82
235
|
|
|
83
236
|
What the SDK guarantees for this to hold:
|
|
84
237
|
|
|
@@ -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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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.
|
|
114
|
-
*
|
|
115
|
-
* the
|
|
116
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
56
|
-
*
|
|
57
|
-
* the
|
|
58
|
-
*
|
|
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;;;;;;;;;;
|
|
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
|
|
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.
|
|
56
|
-
*
|
|
57
|
-
* the
|
|
58
|
-
*
|
|
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;;;;;;;;;;
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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.
|
|
113
|
-
*
|
|
114
|
-
* the
|
|
115
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
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
|