Skip to content

Commit de1e4a1

Browse files
authored
Added UTM query param parsing to frontend scripts (#24827)
ref https://linear.app/ghost/issue/PROD-2560/ - added utm_ params parsing to the url-attribution helper library; this lib returns the referrer/attribution data we use for memberships in ghost - refactored url-attribution script file to be easier to read/make sense of; updated tests accordingly (tried to decouple a bit w/ implementation) - updated member-attribution script to pass along utm data; this isn't wired up on the backend yet The `url-attribution` helpers are used by the `member-attribution` and `ghost-stats` scripts, which are our frontend scripts for handling attribution (member signups + web traffic). We need to update these to pass along the various `utm_{param}` query params in order to support expanding our attribution and analytics data sets. This change allows us to pass along the data, and we'll need follow-up PRs to handle ingesting that data and properly storing it. I've included a bit of a refactor here to try and make better sense of the url-attribution script. This has some confusion because historically it has been used for member attribution which has a confusing relationship with referrer data. We haven't been clear about what we call this. __Testing__ In terms of testing, we should only need to check that the frontend scripts store the additional data. W/r to member-attribution, you can check `sessionStroage` and ensure the `utm_{param}` fields exist in the JSON data. For ghost-stats, you can look at the request payload sent to the traffic analytics proxy.
1 parent 5a50bfc commit de1e4a1

File tree

4 files changed

+144
-117
lines changed

4 files changed

+144
-117
lines changed

ghost/core/core/frontend/src/ghost-stats/ghost-stats.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getCountryForTimezone } from 'countries-and-timezones';
2-
import { getReferrer, parseReferrer } from '../utils/url-attribution';
2+
import { getReferrer, parseReferrerData } from '../utils/url-attribution';
33
import { processPayload } from '../utils/privacy';
44
import { BrowserService } from './browser-service';
55

@@ -161,8 +161,13 @@ export class GhostStats {
161161
const navigator = this.browser.getNavigator();
162162
const location = this.browser.getLocation();
163163

164-
const referrerData = parseReferrer(location?.href);
165-
referrerData.url = getReferrer(location?.href) || referrerData.url; // ensure the referrer.url is set for parsing
164+
const referrerData = parseReferrerData(location?.href);
165+
// WORKAROUND: The downstream referrer-parser library requires the 'url' field to be populated
166+
// even when attribution comes from query params (e.g., ?ref=ghost-newsletter) with no document.referrer.
167+
// We use getReferrer() to get the primary attribution source and fall back to document.referrer.
168+
// This means 'url' might contain non-URL values like "ghost-newsletter" when there's no actual referrer.
169+
// TODO: Refactor the referrer-parser to handle query param attribution without requiring this hack.
170+
referrerData.url = getReferrer(location?.href) || referrerData.url;
166171

167172
// Debounce tracking to avoid duplicates and ensure page has settled
168173
this.browser.setTimeout(() => {

ghost/core/core/frontend/src/member-attribution/member-attribution.js

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-env browser */
22
/* eslint-disable no-console */
33
const urlAttribution = require('../utils/url-attribution');
4-
const parseReferrer = urlAttribution.parseReferrer;
4+
const parseReferrerData = urlAttribution.parseReferrerData;
55
const getReferrer = urlAttribution.getReferrer;
66

77
// Location where we want to store the history in sessionStorage
@@ -79,26 +79,34 @@ const LIMIT = 15;
7979
history = [];
8080
}
8181

82-
// Get detailed referrer information using parseReferrer
82+
// Get detailed referrer information using parseReferrerData
8383
let referrerData;
8484
try {
85-
referrerData = parseReferrer(window.location.href);
85+
referrerData = parseReferrerData(window.location.href);
8686
} catch (e) {
8787
console.error('[Member Attribution] Parsing referrer failed', e);
8888
referrerData = {source: null, medium: null, url: null};
8989
}
9090

91-
// Get referrer components from the parsed data
92-
const referrerSource = referrerData.source;
93-
const referrerMedium = referrerData.medium;
91+
// Store all attribution data together
92+
// We'll spread this object when creating history entries
93+
const attributionData = {
94+
referrerSource: referrerData.source,
95+
referrerMedium: referrerData.medium,
96+
utmSource: referrerData.utmSource,
97+
utmMedium: referrerData.utmMedium,
98+
utmCampaign: referrerData.utmCampaign,
99+
utmTerm: referrerData.utmTerm,
100+
utmContent: referrerData.utmContent
101+
};
94102

95103
// Use the getReferrer helper to handle same-domain referrer filtering
96104
// This will return null if the referrer is from the same domain
97105
let referrerUrl;
98106
try {
99107
referrerUrl = getReferrer(window.location.href);
100108
// If no referrer value returned by getReferrer but we have a document.referrer,
101-
// use the original URL from parseReferrer
109+
// use the original URL from parseReferrerData
102110
if (!referrerUrl && referrerData.url) {
103111
referrerUrl = referrerData.url;
104112
}
@@ -117,8 +125,7 @@ const LIMIT = 15;
117125
time: currentTime,
118126
id: params.get('attribution_id'),
119127
type: params.get('attribution_type'),
120-
referrerSource,
121-
referrerMedium,
128+
...attributionData,
122129
referrerUrl
123130
});
124131

@@ -138,19 +145,22 @@ const LIMIT = 15;
138145
history.push({
139146
path: currentPath,
140147
time: currentTime,
141-
referrerSource,
142-
referrerMedium,
148+
...attributionData,
143149
referrerUrl
144150
});
145151
} else if (history.length > 0) {
146-
history[history.length - 1].time = currentTime;
147-
// Update referrer information for same path if available (e.g. when opening a link on same path via external referrer)
148-
if (referrerSource) {
149-
history[history.length - 1].referrerSource = referrerSource;
150-
history[history.length - 1].referrerMedium = referrerMedium;
151-
}
152+
const lastEntry = history[history.length - 1];
153+
lastEntry.time = currentTime;
154+
155+
// Update with any new attribution data (filters out null/undefined values)
156+
Object.entries(attributionData).forEach(([key, value]) => {
157+
if (value) {
158+
lastEntry[key] = value;
159+
}
160+
});
161+
152162
if (referrerUrl) {
153-
history[history.length - 1].referrerUrl = referrerUrl;
163+
lastEntry.referrerUrl = referrerUrl;
154164
}
155165
}
156166

ghost/core/core/frontend/src/utils/url-attribution.js

Lines changed: 53 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,63 +3,76 @@
33
*/
44

55
/**
6-
* Parses URL parameters to extract attribution information
7-
*
8-
* @param {string} url - The URL to parse
9-
* @returns {Object} Parsed attribution data
6+
* @typedef {Object} AttributionData
7+
* @property {string|null} source - Primary attribution source (ref || source || utm_source)
8+
* @property {string|null} medium - UTM medium parameter
9+
* @property {string|null} url - Browser's document.referrer
10+
* @property {string|null} utmSource - UTM source parameter
11+
* @property {string|null} utmMedium - UTM medium parameter
12+
* @property {string|null} utmTerm - UTM term/keyword parameter
13+
* @property {string|null} utmCampaign - UTM campaign parameter
14+
* @property {string|null} utmContent - UTM content/variant parameter
1015
*/
11-
export function parseReferrer(url) {
12-
// Extract current URL parameters
13-
const currentUrl = new URL(url || window.location.href);
14-
15-
// Parse source parameters
16-
const refParam = currentUrl.searchParams.get('ref');
17-
const sourceParam = currentUrl.searchParams.get('source');
18-
const utmSourceParam = currentUrl.searchParams.get('utm_source');
19-
const utmMediumParam = currentUrl.searchParams.get('utm_medium');
16+
17+
/**
18+
* Extracts attribution parameters from URL search params
19+
* @private
20+
* @param {URLSearchParams} searchParams - The search params to parse
21+
* @returns {AttributionData} Parsed attribution data with all UTM parameters
22+
*/
23+
function extractParams(searchParams) {
24+
const refParam = searchParams.get('ref');
25+
const sourceParam = searchParams.get('source');
26+
const utmSourceParam = searchParams.get('utm_source');
27+
const utmMediumParam = searchParams.get('utm_medium');
28+
const utmTermParam = searchParams.get('utm_term');
29+
const utmCampaignParam = searchParams.get('utm_campaign');
30+
const utmContentParam = searchParams.get('utm_content');
2031

2132
// Determine primary source
2233
const referrerSource = refParam || sourceParam || utmSourceParam || null;
2334

24-
// Check portal hash if needed
25-
if (!referrerSource && currentUrl.hash && currentUrl.hash.includes('#/portal')) {
26-
return parsePortalHash(currentUrl);
27-
}
28-
2935
return {
3036
source: referrerSource,
3137
medium: utmMediumParam || null,
32-
url: window.document.referrer || null
38+
url: window.document.referrer || null,
39+
utmSource: utmSourceParam || null,
40+
utmMedium: utmMediumParam || null,
41+
utmTerm: utmTermParam || null,
42+
utmCampaign: utmCampaignParam || null,
43+
utmContent: utmContentParam || null
3344
};
3445
}
3546

3647
/**
37-
* Parses attribution data from portal hash URLs
48+
* Parses URL parameters to extract complete referrer/attribution data
3849
*
39-
* @param {URL} url - URL object with a portal hash
40-
* @returns {Object} Parsed attribution data
50+
* @param {string} url - The URL to parse (defaults to current URL)
51+
* @returns {AttributionData} Complete attribution data including all UTM parameters
4152
*/
42-
export function parsePortalHash(url) {
43-
const hashUrl = new URL(url.href.replace('/#/portal', ''));
44-
const refParam = hashUrl.searchParams.get('ref');
45-
const sourceParam = hashUrl.searchParams.get('source');
46-
const utmSourceParam = hashUrl.searchParams.get('utm_source');
47-
const utmMediumParam = hashUrl.searchParams.get('utm_medium');
53+
export function parseReferrerData(url) {
54+
// Extract current URL parameters
55+
const currentUrl = new URL(url || window.location.href);
56+
let searchParams = currentUrl.searchParams;
4857

49-
return {
50-
source: refParam || sourceParam || utmSourceParam || null,
51-
medium: utmMediumParam || null,
52-
url: window.document.referrer || null
53-
};
58+
// Handle portal hash URLs - extract params from hash instead
59+
if (currentUrl.hash && currentUrl.hash.includes('#/portal')) {
60+
const hashUrl = new URL(currentUrl.href.replace('/#/portal', ''));
61+
searchParams = hashUrl.searchParams;
62+
}
63+
64+
return extractParams(searchParams);
5465
}
5566

5667
/**
57-
* Gets the final referrer value based on parsed data
58-
*
59-
* @param {Object} referrerData - Parsed referrer data
60-
* @returns {string|null} Final referrer value or null
68+
* Selects the primary referrer value from parsed attribution data
69+
* Prioritizes: source → medium → url
70+
* Filters out same-domain referrers
71+
* @private
72+
* @param {AttributionData} referrerData - Parsed referrer data
73+
* @returns {string|null} Primary referrer value or null
6174
*/
62-
export function getFinalReferrer(referrerData) {
75+
function selectPrimaryReferrer(referrerData) {
6376
const { source, medium, url } = referrerData;
6477
const finalReferrer = source || medium || url || null;
6578

@@ -87,6 +100,6 @@ export function getFinalReferrer(referrerData) {
87100
* @returns {string|null} Final referrer value
88101
*/
89102
export function getReferrer(url) {
90-
const referrerData = parseReferrer(url);
91-
return getFinalReferrer(referrerData);
103+
const referrerData = parseReferrerData(url);
104+
return selectPrimaryReferrer(referrerData);
92105
}

0 commit comments

Comments
 (0)