Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions lib/addons/prebid/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,20 @@ Each sampled auction sends an `optable.prebid.auction` event with, among others:
(with per-bidder `hasLiEids` and `liSources`), `intentiqEIDs`, `growthcodeEIDs`,
`transunionEIDs` (with per-bidder `iiqSources`, `gcSources`, `tuSources`),
`bidWon`, `missed`, `adUnitCode` (first bid's ad unit), `totalBids` (received
bids with a `cpm`), `url`, `tenant`, `prebidjsVersion`, `sessionDepth`,
`pageAuctionsCount`, `originSlug`, and the parsed `userAgent`/`device`.
bids with a `cpm`), `url`, `referrer`, `trafficSource`, `tenant`,
`prebidjsVersion`, `sessionDepth`, `pageAuctionsCount`, `originSlug`, and the
parsed `userAgent`/`device`.

`referrer` is the referring hostname only, never the full referring URL.
`trafficSource` classifies it as one of `direct`, `internal`, `paid`,
`campaign`, `email`, `organic_search`, `social` or `referral`. Both are
first-touch per tab: the first non-internal result is cached in `sessionStorage`
under `optableTrafficSource` and reused for the rest of the session, because
`document.referrer` becomes the site's own hostname after the first internal
click. A cached `direct` is the exception: a later non-internal result in the
same tab replaces it. A referrer counts as `internal` when its registrable name
matches the page's (`news.site.com` on `www.site.com`), and that check runs
before the UTM and click-id checks.

Competitor EIDs are the EID sources each vendor delivered, matched on the tags it
leaves in the bid request (rules in `COMPETITOR_EIDS`, shared with the V1 add-on):
Expand Down
2 changes: 2 additions & 0 deletions lib/addons/prebid/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import type { WitnessProperties } from "../../edge/witness";
import type OptableSDK from "../../sdk";
import { buildRequest } from "../../core/network";
import { getTrafficSource } from "../../core/traffic-source";

import * as Bowser from "bowser";

Expand Down Expand Up @@ -544,6 +545,7 @@ class OptablePrebidAnalytics {
})),
missed: missed && oSourcesSet.size === 0,
url: `${window.location.hostname}${window.location.pathname}`,
...getTrafficSource(),
tenant: this.optableInstance?.dcn?.node ?? "unknown",
// eslint-disable-next-line no-undef
optableWrapperVersion: SDK_WRAPPER_VERSION || "unknown",
Expand Down
4 changes: 4 additions & 0 deletions lib/addons/prototypes/analytics.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/* eslint-disable no-param-reassign */
import { getTrafficSource } from "../../core/traffic-source";

const STATUS = {
REQUESTED: "REQUESTED",
RECEIVED: "RECEIVED",
NO_BID: "NO_BID",
TIMEOUT: "TIMEOUT",
};

// Competitor EIDs, matched on the tags each vendor leaves in the bid request.
// Publisher wrappers can strip inserter, so each rule also checks matcher or source.
const COMPETITOR_EIDS = {
Expand Down Expand Up @@ -402,6 +405,7 @@ class OptablePrebidAnalytics {
transunionEIDs: Array.from(tSourcesSet),
missed: missed && oSourcesSet.size === 0,
url: `${window.location.hostname}${window.location.pathname}`,
...getTrafficSource(),
tenant: this.config.tenant,
optableWrapperVersion: SDK_WRAPPER_VERSION, // eslint-disable-line no-undef
prebidjsVersion: this.prebidInstance?.version || "unknown",
Expand Down
131 changes: 131 additions & 0 deletions lib/core/traffic-source.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { getTrafficSource, TRAFFIC_SOURCE_KEY } from "./traffic-source";

function visit(url: string, referrer = "") {
const { hostname, search } = new URL(url);
Object.defineProperty(window, "location", {
value: { hostname, search },
writable: true,
configurable: true,
});
Object.defineProperty(document, "referrer", { value: referrer, configurable: true });
}

beforeEach(() => {
sessionStorage.clear();
});

describe("getTrafficSource - classification", () => {
it("reports direct when there is no referrer", () => {
visit("https://www.site.com/");
expect(getTrafficSource()).toEqual({ referrer: "", trafficSource: "direct" });
});

it("reports direct when the referrer is not a valid URL", () => {
visit("https://www.site.com/", "not a url");
expect(getTrafficSource()).toEqual({ referrer: "", trafficSource: "direct" });
});

it("reports internal for the same host", () => {
visit("https://www.site.com/b", "https://www.site.com/a");
expect(getTrafficSource().trafficSource).toBe("internal");
});

it("reports internal for another subdomain of the same site", () => {
visit("https://www.site.com/b", "https://news.site.com/a");
expect(getTrafficSource().trafficSource).toBe("internal");
});

it("reports internal for subdomains under a two-part suffix", () => {
visit("https://www.site.co.uk/b", "https://news.site.co.uk/a");
expect(getTrafficSource().trafficSource).toBe("internal");
});

it("reports internal before campaign when an internal link carries UTM params", () => {
visit("https://www.site.com/b?utm_source=newsletter&utm_medium=email", "https://www.site.com/a");
expect(getTrafficSource().trafficSource).toBe("internal");
});

it("reports paid for a click id", () => {
visit("https://www.site.com/?gclid=abc", "https://www.google.com/");
expect(getTrafficSource()).toEqual({ referrer: "www.google.com", trafficSource: "paid" });
});

it("reports paid for a paid utm_medium, case-insensitively", () => {
visit("https://www.site.com/?utm_source=fb&utm_medium=CPC");
expect(getTrafficSource().trafficSource).toBe("paid");
});

it("reports email for utm_medium=email", () => {
visit("https://www.site.com/?utm_source=newsletter&utm_medium=email");
expect(getTrafficSource().trafficSource).toBe("email");
});

it("reports campaign for utm_source without a paid or email medium", () => {
visit("https://www.site.com/?utm_source=partner", "https://partner.com/");
expect(getTrafficSource().trafficSource).toBe("campaign");
});

it("reports organic_search for a search engine", () => {
visit("https://www.site.com/", "https://www.google.co.uk/");
expect(getTrafficSource()).toEqual({ referrer: "www.google.co.uk", trafficSource: "organic_search" });
});

it("reports social for a social network", () => {
visit("https://www.site.com/", "https://t.co/xyz");
expect(getTrafficSource()).toEqual({ referrer: "t.co", trafficSource: "social" });
});

it("reports referral for any other site", () => {
visit("https://www.site.com/", "https://blog.example.org/post?email=a@b.c");
expect(getTrafficSource()).toEqual({ referrer: "blog.example.org", trafficSource: "referral" });
});
});

describe("getTrafficSource - session caching", () => {
it("keeps the first external source across internal navigation", () => {
visit("https://www.site.com/a", "https://www.google.com/");
expect(getTrafficSource().trafficSource).toBe("organic_search");

visit("https://www.site.com/b", "https://www.site.com/a");
expect(getTrafficSource()).toEqual({ referrer: "www.google.com", trafficSource: "organic_search" });
});

it("does not replace a cached external source with a later one", () => {
visit("https://www.site.com/a", "https://www.google.com/");
getTrafficSource();

visit("https://www.site.com/b", "https://www.facebook.com/");
expect(getTrafficSource().trafficSource).toBe("organic_search");
});

it("does not cache internal", () => {
visit("https://www.site.com/b", "https://www.site.com/a");
expect(getTrafficSource().trafficSource).toBe("internal");
expect(sessionStorage.getItem(TRAFFIC_SOURCE_KEY)).toBeNull();
});

it("keeps a cached direct across internal navigation", () => {
visit("https://www.site.com/a");
getTrafficSource();

visit("https://www.site.com/b", "https://www.site.com/a");
expect(getTrafficSource()).toEqual({ referrer: "", trafficSource: "direct" });
});

it("replaces a cached direct with a later external source", () => {
visit("https://www.site.com/a");
getTrafficSource();

visit("https://www.site.com/b", "https://www.facebook.com/");
expect(getTrafficSource()).toEqual({ referrer: "www.facebook.com", trafficSource: "social" });

visit("https://www.site.com/c", "https://www.site.com/b");
expect(getTrafficSource().trafficSource).toBe("social");
});

it("recomputes when the cached value is malformed", () => {
sessionStorage.setItem(TRAFFIC_SOURCE_KEY, "{not json");
visit("https://www.site.com/", "https://www.bing.com/");
expect(getTrafficSource().trafficSource).toBe("organic_search");
});
});
115 changes: 115 additions & 0 deletions lib/core/traffic-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
const TRAFFIC_SOURCE_KEY = "optableTrafficSource";
Comment thread
mosherBT marked this conversation as resolved.

const SEARCH_SITES = ["google", "bing", "yahoo", "duckduckgo", "ecosia", "baidu", "yandex", "qwant", "startpage"];
const SOCIAL_SITES = [
"facebook",
"fb",
"instagram",
"twitter",
"x",
"t",
"linkedin",
"lnkd",
"reddit",
"pinterest",
"tiktok",
"threads",
"snapchat",
"youtube",
];
const PAID_CLICK_IDS = ["gclid", "gbraid", "wbraid", "fbclid", "msclkid", "ttclid", "twclid", "dclid"];
const PAID_MEDIUMS = ["cpc", "ppc", "paid", "paidsocial", "paid_social", "display", "cpm"];

// Suffixes that take two labels, so the registrable name is the third from the
// right (google.co.uk -> google) rather than the second (-> co).
const TWO_PART_SUFFIXES = ["co.uk", "com.au", "co.jp", "co.in", "com.br", "co.nz", "com.mx", "co.za", "co.kr"];

type TrafficSource = "direct" | "internal" | "paid" | "campaign" | "email" | "organic_search" | "social" | "referral";

type TrafficSourceProperties = {
referrer: string;
trafficSource: TrafficSource;
};

// Registrable name of a host: "news.google.co.uk" -> "google", "t.co" -> "t".
// Approximate, since resolving this exactly needs the public suffix list.
function siteName(host: string): string {
const labels = host.toLowerCase().split(".");
if (labels.length < 2) return labels[0] || "";

const index = TWO_PART_SUFFIXES.includes(labels.slice(-2).join(".")) ? labels.length - 3 : labels.length - 2;
return labels[index] || "";
}

function referrerHost(): string {
if (!document.referrer) return "";

try {
return new URL(document.referrer).hostname;
} catch {
return "";
}
}

// Internal is checked first so that UTM-tagged links between the site's own
// pages are not reported as campaign traffic. Matching on the registrable name
// rather than the exact host keeps www.site.com -> news.site.com internal.
function classify(host: string): TrafficSource {
const site = siteName(host);
if (host && site === siteName(window.location.hostname)) return "internal";

const params = new URLSearchParams(window.location.search);
const medium = (params.get("utm_medium") || "").toLowerCase();

if (PAID_CLICK_IDS.some((id) => params.has(id)) || PAID_MEDIUMS.includes(medium)) return "paid";
if (medium === "email") return "email";
if (params.has("utm_source")) return "campaign";
if (!host) return "direct";

if (SEARCH_SITES.includes(site)) return "organic_search";
if (SOCIAL_SITES.includes(site)) return "social";

return "referral";
}

/**
* Referring host and classified traffic source for the current page.
*
* The result is first-touch per tab: the first non-internal classification is
* cached in `sessionStorage` and reused for the rest of the session. Without
* that, `document.referrer` becomes the site's own hostname after the first
* internal click and every later event would report `internal`.
*
* A cached `direct` is the one exception. It is the weakest signal (in-app
* browsers and `no-referrer` policies also land there), so a later non-internal
* classification in the same tab replaces it.
*
* Only the referring hostname is reported, never the full referring URL, which
* can carry query strings with personal data.
*/
function getTrafficSource(): TrafficSourceProperties {
let cached: TrafficSourceProperties | null = null;
try {
const stored = window.sessionStorage?.getItem(TRAFFIC_SOURCE_KEY);
if (stored) cached = JSON.parse(stored) as TrafficSourceProperties;
} catch {
// sessionStorage unavailable or holding malformed JSON, fall through and recompute.
}
if (cached && cached.trafficSource !== "direct") return cached;

const referrer = referrerHost();
const properties: TrafficSourceProperties = { referrer, trafficSource: classify(referrer) };

if (properties.trafficSource === "internal") return cached ?? properties;

try {
window.sessionStorage?.setItem(TRAFFIC_SOURCE_KEY, JSON.stringify(properties));
} catch {
// Storage blocked or full, the value is still returned for this event.
}

return properties;
}

export type { TrafficSource, TrafficSourceProperties };
export { getTrafficSource, TRAFFIC_SOURCE_KEY };
Loading