Skip to content

analytics: report referrer and traffic source in both analytics versions - #348

Open
MO-Thibault wants to merge 3 commits into
masterfrom
analytics-traffic-source
Open

MO-Thibault wants to merge 3 commits into
masterfrom
analytics-traffic-source

Conversation

@MO-Thibault

@MO-Thibault MO-Thibault commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds two fields to the auction payload built by both analytics implementations, so we can tell where a session's traffic came from alongside the bid data we already collect.

  • referrer — the referring hostname only.
  • trafficSource — one of direct, internal, paid, campaign, email, organic_search, social, referral.

Touched in both places:

File Change
lib/core/traffic-source.ts New. getTrafficSource() returns { referrer, trafficSource }
lib/addons/prebid/analytics.ts Imports the helper, spreads both fields into witnessData
lib/addons/prototypes/analytics.js Imports the same helper, spreads both fields into its payload
lib/addons/prebid/analytics.md Payload field list updated
lib/core/traffic-source.test.ts New. 19 tests covering every category and the session caching

Motivation and design notes

Four decisions worth a reviewer's attention:

First-touch per tab. document.referrer becomes the site's own hostname after the first internal click. Classifying on every event would report internal for every auction past the first pageview, which is useless. The first non-internal result is cached in sessionStorage under optableTrafficSource and reused for the rest of the session. A cached direct is the exception: it is the weakest signal (in-app browsers and no-referrer policies land there too), so a later non-internal result in the same tab replaces it.

Internal is checked first, on the registrable name. A referrer is internal when its registrable name matches the page's, so news.site.com arriving on www.site.com is internal rather than referral. The check runs before the UTM and click-id checks, so UTM-tagged links between the site's own pages are not reported as campaign.

Hostname, not full URL. Only the referring hostname is reported. Full referring URLs can carry query strings with personal data. This also costs nothing in practice, since browsers default to strict-origin-when-cross-origin and usually only give us the origin anyway.

Named trafficSource, not source. The same payload already carries optableSources, meaning Optable data sources. Two fields called source and optableSources in one payload would be read as related when they are not.

Both analytics files share the one helper. Bundles import lib/dist/addons/prototypes/analytics through webpack, and tsc emits lib/dist/core/traffic-source.js alongside it, so the import resolves the same way it does for the prebid analytics.

Known limits

  • siteName() approximates the registrable domain with a short list of two-part suffixes rather than the public suffix list. Good enough to separate google.co.uk from co, not exact.
  • In-app browsers (Meta, TikTok) and sites sending Referrer-Policy: no-referrer produce an empty referrer, so some real social traffic lands in direct unless the link carries a click id. Expected, not a bug, but it matters when reading the numbers.
  • Matching internal on the registrable name means site.com and site.org count as the same site. Acceptable for a same-brand property, but worth knowing.

Test plan

  • pnpm build-lib (tsc) clean.
  • pnpm exec jest: 488 tests across 29 suites pass, including the 82 existing prebid analytics tests and the 19 new traffic-source tests (100% line coverage on traffic-source.ts).
  • pnpm exec prettier --check clean on all changed files.
  • ESLint is not installed in this repo, so it was not run.

🤖 Generated with Claude Code

Adds two fields to the auction payload built by lib/addons/prebid/analytics.ts
and lib/addons/prototypes/analytics.js:

- referrer: the referring hostname only, never the full referring URL, which
  can carry query strings with personal data.
- trafficSource: direct, internal, paid, campaign, email, organic_search,
  social or referral, derived from the referrer plus the campaign and click-id
  parameters on the landing URL.

Both are first-touch per tab. document.referrer becomes the site's own hostname
after the first internal click, so the first non-internal result is cached in
sessionStorage under optableTrafficSource and reused for the rest of the
session. Without that, every auction after the first pageview would report
internal.

The logic lives in lib/core/traffic-source.ts for the typed path and is
inlined in the prototype file, which is self-contained and imports no modules.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MO-Thibault
MO-Thibault requested review from a team as code owners September 19, 2026 18:48
Comment thread lib/addons/prototypes/analytics.js Outdated
// self-contained and pulls in no modules.
const TRAFFIC_SOURCE_KEY = "optableTrafficSource";
const SEARCH_SITES = ["google", "bing", "yahoo", "duckduckgo", "ecosia", "baidu", "yandex", "qwant", "startpage"];
const SOCIAL_SITES = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why duplicated? Can't we use the logic in one place traffic-source.ts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9e729f0. It now imports getTrafficSource from lib/core/traffic-source. I had assumed this file was self-contained, but bundles import it from lib/dist through webpack and tsc emits core/traffic-source.js next to it, so the import resolves the same way as in the prebid analytics.

@@ -0,0 +1,105 @@
const TRAFFIC_SOURCE_KEY = "optableTrafficSource";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add test suite for this file

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added lib/core/traffic-source.test.ts in 9e729f0: 19 tests covering every category, the internal-before-UTM order, subdomain matching, and the session caching rules. 100% line coverage on the file.

Comment thread lib/core/traffic-source.ts Outdated
const referrer = referrerHost();
const properties: TrafficSourceProperties = { referrer, trafficSource: classify(referrer) };

if (properties.trafficSource !== "internal") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this blokc on direct too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocked, but changed in 9e729f0. Blocking it would make every page after a direct landing report internal, since nothing would be cached. Instead direct is still cached, but a later non-internal source in the same tab replaces it (GA4 behaves this way too). Every other cached source stays first-touch.

Comment thread lib/core/traffic-source.ts Outdated
if (medium === "email") return "email";
if (params.has("utm_source")) return "campaign";
if (!host) return "direct";
if (host === window.location.hostname) return "internal";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer this I think. Host exact match will lead to a lot of referal false positives

if (siteName(host) === siteName(window.location.hostname)) return "internal";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done in 9e729f0. news.site.com on www.site.com is now internal.

Comment thread lib/core/traffic-source.ts Outdated
if (medium === "email") return "email";
if (params.has("utm_source")) return "campaign";
if (!host) return "direct";
if (host === window.location.hostname) return "internal";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagged by claude I think relevant. We do utm check before internal so internal referal will get marked as campaign. Should move internal check to top

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, moved to the top in 9e729f0. An internal link with UTM params now reports internal.

- prototypes/analytics.js imports getTrafficSource from lib/core instead of
  carrying an inlined copy. Consumers import it from lib/dist through a
  bundler, where core/traffic-source.js is emitted alongside it.
- Internal is checked first, so UTM-tagged links between the site's own pages
  are no longer reported as campaign.
- Internal matches on the registrable name, so another subdomain of the same
  site is internal rather than referral.
- A cached direct is replaced by a later non-internal source in the same tab.
- Add traffic-source.test.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@MO-Thibault
MO-Thibault requested a review from mosherBT October 14, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants