From eed05c412c54c9a4713cba5a745248a60ff9fc69 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 19 Oct 2026 11:03:14 -0700 Subject: [PATCH] Flag suspect first-session traffic without excluding analytics --- docs/analytics/missing-page-views.sql | 31 +++ docs/analytics/traffic-quality.md | 23 +++ src/routes/__root.tsx | 3 +- src/server.ts | 7 + src/utils/analytics/traffic-quality.server.ts | 84 ++++++++ src/utils/analytics/traffic-quality.ts | 152 ++++++++++++++ tests/traffic-quality.test.ts | 191 ++++++++++++++++++ 7 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 docs/analytics/missing-page-views.sql create mode 100644 docs/analytics/traffic-quality.md create mode 100644 src/utils/analytics/traffic-quality.server.ts create mode 100644 src/utils/analytics/traffic-quality.ts create mode 100644 tests/traffic-quality.test.ts diff --git a/docs/analytics/missing-page-views.sql b/docs/analytics/missing-page-views.sql new file mode 100644 index 000000000..7bbde6097 --- /dev/null +++ b/docs/analytics/missing-page-views.sql @@ -0,0 +1,31 @@ +-- Replace YOUR_PROJECT.YOUR_DATASET with the linked GA4 export dataset. +-- Includes a day on either side to avoid truncating sessions at midnight. +WITH events AS ( + SELECT user_pseudo_id, + (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'ga_session_id') AS session_id, + event_timestamp, event_date, event_name, + device.web_info.browser AS browser, + device.web_info.browser_version AS browser_version, + (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_location') AS page_location, + (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'page_referrer') AS page_referrer + FROM `YOUR_PROJECT.YOUR_DATASET.events_*` + WHERE _TABLE_SUFFIX BETWEEN '20261004' AND '20261019' +), sessions AS ( + SELECT user_pseudo_id, session_id, + MIN(event_date) AS first_date, + COUNTIF(event_name = 'page_view') AS page_views, + STRING_AGG(DISTINCT event_name, ', ' ORDER BY event_name) AS event_names, + ARRAY_AGG(STRUCT(browser, browser_version, page_location, page_referrer) + ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS first_event + FROM events + WHERE user_pseudo_id IS NOT NULL AND session_id IS NOT NULL + GROUP BY user_pseudo_id, session_id +) +SELECT first_date, event_names, first_event.browser, first_event.browser_version, + REGEXP_EXTRACT(first_event.page_location, r'^https?://[^/]+([^?#]*)') AS first_path, + REGEXP_EXTRACT(first_event.page_referrer, r'^https?://([^/]+)') AS referrer_host, + COUNT(*) AS sessions +FROM sessions +WHERE page_views = 0 AND first_date BETWEEN '20261005' AND '20261018' +GROUP BY ALL +ORDER BY sessions DESC; diff --git a/docs/analytics/traffic-quality.md b/docs/analytics/traffic-quality.md new file mode 100644 index 000000000..5779ba316 --- /dev/null +++ b/docs/analytics/traffic-quality.md @@ -0,0 +1,23 @@ +# Traffic quality rollout + +Flag-only, rule v1. No traffic is blocked, no GA data filter is enabled, and no partner totals are changed. `automated` and `suspect` are heuristic labels, not verified bot identities. + +`src/utils/analytics/traffic-quality.ts` configures current Chrome major 155 and a minimum version gap of 7, matching the October 19 spec. Review monthly and validate again when changing the baseline. Reduced `.0.0.0` versions alone are normal Chrome behavior. + +The head bootstrap wraps explicit GA events before React starts. It uses Google's supported `gtag('get')` fields `session_number`, `session_id`, and `client_id`, never parses GA cookies. It labels the existing events without adding an event or page view. First-session Direct means an empty document referrer and no UTM, ad-click, or cross-domain linker parameters on the document's entry URL. Windows/Linux get `automated`; macOS gets `suspect`. Other known sessions get `unflagged`. Missing identity/session data or a lookup timeout gets `unknown`. Events are released after at most five seconds even if getters fail. + +The last classification is stored locally against GA's client/session identity to keep it across full-page navigations and tabs. This adds no independent visitor ID. If storage is unavailable, the page still classifies, but subsequent full navigations may be unflagged because of their referrer. A missing referrer cannot prove traffic provenance, and missing/reset GA storage cannot prove a person has never visited before. + +Every explicit event rechecks GA's session. Automatic events may have `unknown` or no custom label, so a long-lived page cannot carry an earlier first-session flag into a returning session. In BigQuery, classify each session using its explicit event labels and join by user_pseudo_id plus ga_session_id. Do not filter individual events and assume you have filtered whole sessions. Sessions containing only automatic events remain unknown. GA UI custom dimensions `traffic_quality` and `traffic_quality_rule` should be registered with event scope; they are present in BigQuery without registration. + +Flagged documents send a diagnostic to `/_a/traffic-quality`. Cloudflare logs `traffic_quality_diagnostic` with tier, Chrome major, webdriver, headless UA, ASN/network owner, country, and bot score/verified-bot status when available. Network data stays in Worker logs, not GA. No raw IP, cookie, page URL, client ID, or session ID is logged. DNT/GPC suppress this diagnostic. These are diagnostic samples per document/session, not session counts. Browser-reported signals and labels can be spoofed. Cloudflare bot metadata depends on the account's enabled features. Check Worker log retention/export before the seven-day review. + +## Seven-day review + +Use seven complete property-timezone days after deployment. Keep both tiers in totals until the review is complete. Compare engagement by session label, confirm flagged sessions have ga_session_number=1 and no organic attribution, and compare unflagged returning engagement with the baseline. Tier 1 under 5% is a validation target, not a classification condition. Show excluded volume separately if reporting exclusion is approved later. + +## Missing page views + +Historical findings are not yet verified because the analytics MCP has no application-default credentials and this session has no BigQuery connection. September 24 commit b7c3ba522 disabled config page views and introduced the React page tracker to stop duplicates. The inline GA loader and React page tracker can run independently. Failed/interrupted hydration is one hypothesis, not a confirmed explanation. Partner impressions are sent by React effects/IntersectionObserver and need session-level comparison with page views. + +Run `missing-page-views.sql` against the actual BigQuery export after replacing the table reference. It uses complete sessions across the requested window, groups the events they contain, and shows entry path/referrer/browser. It does not infer missing-page-view sessions by subtracting two independently aggregated GA reports. Check dates around September 24 and October 1 as well as October 5–18. This rollout does not change page-view behavior. diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 8bd63e8f2..72236715d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -56,6 +56,7 @@ import { } from '~/utils/analytics/scarf' import { createPartnerPlacementSessionSeed } from '~/utils/partner-placement' import { twMerge } from 'tailwind-merge' +import { TRAFFIC_QUALITY_BOOTSTRAP } from '~/utils/analytics/traffic-quality' import { DOCS_WIDTH_BOOTSTRAP } from '~/utils/docs-width' const GOOGLE_ANALYTICS_ID = 'G-JMT1Z50SPS' @@ -67,7 +68,7 @@ const THEME_BOOTSTRAP = `(function(){try{var t=localStorage.getItem('theme')||'a // the bootstrap idempotent: without it the head script can execute twice on a // single load, sending a second config (and a second page_view) and injecting // a second gtag.js loader. -const GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){if(window.__tanstackGaBootstrapped)return;window.__tanstackGaBootstrapped=true;var id='${GOOGLE_ANALYTICS_ID}';var src='${GOOGLE_ANALYTICS_SCRIPT_SRC}';window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('js',new Date());window.gtag('config',id,{send_page_view:false,transport_url:window.location.origin+'${GOOGLE_ANALYTICS_PROXY_PREFIX}'});var loaded=false;var load=function(){if(loaded)return;var parent=document.head||document.documentElement;if(!parent){window.setTimeout(load,100);return}loaded=true;var script=document.createElement('script');script.async=true;script.src=src;script.setAttribute('data-ga-loader','true');parent.appendChild(script)};if(typeof window.requestIdleCallback==='function'){window.requestIdleCallback(load,{timeout:3000});return}if(document.readyState==='complete'){window.setTimeout(load,1500);return}window.addEventListener('load',function(){window.setTimeout(load,1500)},{once:true})})();` +const GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){if(window.__tanstackGaBootstrapped)return;window.__tanstackGaBootstrapped=true;var id='${GOOGLE_ANALYTICS_ID}';var src='${GOOGLE_ANALYTICS_SCRIPT_SRC}';window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('set',{traffic_quality:'unknown',traffic_quality_rule:'v1'});window.gtag('js',new Date());window.gtag('config',id,{send_page_view:false,transport_url:window.location.origin+'${GOOGLE_ANALYTICS_PROXY_PREFIX}'});${TRAFFIC_QUALITY_BOOTSTRAP}var loaded=false;var load=function(){if(loaded)return;var parent=document.head||document.documentElement;if(!parent){window.setTimeout(load,100);return}loaded=true;var script=document.createElement('script');script.async=true;script.src=src;script.setAttribute('data-ga-loader','true');parent.appendChild(script)};if(typeof window.requestIdleCallback==='function'){window.requestIdleCallback(load,{timeout:3000});return}if(document.readyState==='complete'){window.setTimeout(load,1500);return}window.addEventListener('load',function(){window.setTimeout(load,1500)},{once:true})})();` const DOCUMENT_CACHE_HEADERS = { 'Cache-Control': 'public, max-age=0, must-revalidate', 'Cloudflare-CDN-Cache-Control': 'no-store', diff --git a/src/server.ts b/src/server.ts index 6e17c7688..8b46c8a23 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,4 @@ +import { receiveTrafficQuality } from './utils/analytics/traffic-quality.server' import { timeRequestWork, withDocsRequestTimings, @@ -230,6 +231,12 @@ const server = createServerEntry( logRequestStart(context) try { + if (url.pathname === '/_a/traffic-quality') { + return applyHostingHeaders( + await receiveTrafficQuality(request), + url, + ) + } const scarfResponse = await proxyScarfClipboardEvent(request, url) if (scarfResponse) { logRequestEnd(context, scarfResponse.status, { diff --git a/src/utils/analytics/traffic-quality.server.ts b/src/utils/analytics/traffic-quality.server.ts new file mode 100644 index 000000000..500a184fa --- /dev/null +++ b/src/utils/analytics/traffic-quality.server.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import { classifyTraffic } from './traffic-quality' + +const diagnosticSchema = z.object({ + quality: z.enum(['automated', 'suspect']), + webdriver: z.boolean(), +}) +const networkSchema = z.object({ + asn: z.number().optional(), + asOrganization: z.string().optional(), + country: z.string().optional(), + botManagement: z + .object({ + score: z.number().optional(), + verifiedBot: z.boolean().optional(), + }) + .optional(), +}) + +export async function receiveTrafficQuality(request: Request) { + const headers = { 'Cache-Control': 'no-store' } + if (request.method !== 'POST') + return new Response(null, { status: 405, headers }) + const url = new URL(request.url) + if ( + request.headers.get('origin') !== url.origin || + url.hostname !== 'tanstack.com' + ) { + return new Response(null, { status: 403, headers }) + } + if ( + request.headers.get('dnt') === '1' || + request.headers.get('sec-gpc') === '1' + ) { + return new Response(null, { status: 204, headers }) + } + // Bound the body even when the client omits Content-Length. + const reader = request.body?.getReader() + if (!reader) return new Response(null, { status: 400, headers }) + let raw = '' + let size = 0 + const decoder = new TextDecoder() + while (true) { + const chunk = await reader.read() + if (chunk.done) break + size += chunk.value.byteLength + if (size > 512) { + await reader.cancel() + return new Response(null, { status: 413, headers }) + } + raw += decoder.decode(chunk.value, { stream: true }) + } + raw += decoder.decode() + let body: unknown + try { + body = JSON.parse(raw) + } catch { + return new Response(null, { status: 400, headers }) + } + const parsed = diagnosticSchema.safeParse(body) + const ua = request.headers.get('user-agent') || '' + if ( + !parsed.success || + classifyTraffic({ + userAgent: ua, + sessionNumber: 1, + referrer: '', + url: url.origin, + }) !== parsed.data.quality + ) { + return new Response(null, { status: 400, headers }) + } + const network = networkSchema.safeParse('cf' in request ? request.cf : {}) + // Network attribution only. Never log the IP, cookies, or GA identifiers. + console.info('traffic_quality_diagnostic', { + rule: 'v1', + quality: parsed.data.quality, + webdriver: parsed.data.webdriver, + headless: /HeadlessChrome\//.test(ua), + chromeMajor: Number(/(?:Chrome|HeadlessChrome)\/(\d+)/.exec(ua)?.[1]), + ...(network.success ? network.data : {}), + }) + return new Response(null, { status: 204, headers }) +} diff --git a/src/utils/analytics/traffic-quality.ts b/src/utils/analytics/traffic-quality.ts new file mode 100644 index 000000000..d3ad02744 --- /dev/null +++ b/src/utils/analytics/traffic-quality.ts @@ -0,0 +1,152 @@ +// Reviewed 2026-10-19. Update monthly and revalidate the cohort after changes. +export const TRAFFIC_QUALITY_CONFIG = { currentChromeMajor: 155, versionGap: 7 } + +export function classifyTraffic( + input: { + userAgent: string + referrer: string + url: string + sessionNumber: unknown + }, + config = TRAFFIC_QUALITY_CONFIG, +) { + if (input.sessionNumber !== 1 && input.sessionNumber !== '1') { + return Number(input.sessionNumber) > 1 ? 'unflagged' : 'unknown' + } + const url = new URL(input.url) + if ( + input.referrer || + [...url.searchParams.keys()].some( + (key) => + /^utm_/i.test(key) || + /^(gclid|dclid|gbraid|wbraid|msclkid|fbclid|_gl)$/i.test(key), + ) + ) + return 'unflagged' + const ua = input.userAgent + // Chrome's reduced version is normal. Only the complete conjunction is flagged. + if (/Edg\/|OPR\/|SamsungBrowser\/|Android|CrOS|CriOS\//.test(ua)) + return 'unflagged' + const chrome = /(?:Chrome|HeadlessChrome)\/(\d+)\.0\.0\.0(?:\s|$)/.exec(ua) + if ( + !chrome || + Number(chrome[1]) > config.currentChromeMajor - config.versionGap + ) + return 'unflagged' + if (/Windows NT|X11;.*Linux|Linux x86_64/.test(ua)) return 'automated' + if (/Macintosh;.*Mac OS X/.test(ua)) return 'suspect' + return 'unflagged' +} + +// Inline source stays independent of bundler-generated function helpers. +const INSTALL_TRAFFIC_QUALITY = `function installTrafficQuality(id, classify, config) { + const send = window.gtag + if (!send) return + const entry = { + userAgent: navigator.userAgent, + referrer: document.referrer, + url: location.href, + } + let diagnosedSession = '' + let pending = [] + let resolving = false + send('set', { traffic_quality: 'unknown', traffic_quality_rule: 'v1' }) + window.gtag = (...args) => { + if (args[0] !== 'event') { + send(...args) + return + } + pending.push(args) + if (resolving) return + resolving = true + let done = false + let sessionNumber + let sessionId + let clientId + let received = 0 + const finish = () => { + if (done) return + done = true + clearTimeout(timer) + const key = + typeof clientId === 'string' && + (typeof sessionId === 'string' || typeof sessionId === 'number') + ? \`\${clientId}:\${sessionId}\` + : '' + let quality = classify({ ...entry, sessionNumber }, config) + if (!key) quality = 'unknown' + // Use GA's session identity, never a separate "new visitor" cookie. + try { + const saved = JSON.parse( + localStorage.getItem('tanstack:traffic-quality') || 'null', + ) + if ( + key && + saved?.key === key && + ['automated', 'suspect', 'unflagged'].includes(saved.quality) + ) + quality = saved.quality + else if (key && quality !== 'unknown') + localStorage.setItem( + 'tanstack:traffic-quality', + JSON.stringify({ key, quality }), + ) + } catch { + /* Storage may be disabled. Classification still works for this document. */ + } + const events = pending + pending = [] + resolving = false + for (const event of events) { + send(event[0], event[1], { ...event[2], traffic_quality: quality, traffic_quality_rule: 'v1' }) + } + if ( + key !== diagnosedSession && + (quality === 'automated' || quality === 'suspect') + ) { + diagnosedSession = key + // No client ID, session ID, URL, or IP is sent in this diagnostic body. + if ( + navigator.doNotTrack !== '1' && + !( + 'globalPrivacyControl' in navigator && + navigator.globalPrivacyControl + ) + ) { + navigator.sendBeacon( + '/_a/traffic-quality', + new Blob( + [ + JSON.stringify({ + quality, + webdriver: navigator.webdriver === true, + }), + ], + { type: 'application/json' }, + ), + ) + } + } + } + const timer = setTimeout(finish, 5000) + const complete = () => { + if (++received === 3) finish() + } + send('get', id, 'session_number', (value) => { + sessionNumber = value + complete() + }) + send('get', id, 'session_id', (value) => { + sessionId = value + complete() + }) + send('get', id, 'client_id', (value) => { + clientId = value + complete() + }) + } +} + +` + +export const TRAFFIC_QUALITY_BOOTSTRAP = `(${INSTALL_TRAFFIC_QUALITY})('G-JMT1Z50SPS',${classifyTraffic.toString()},${JSON.stringify(TRAFFIC_QUALITY_CONFIG)});` diff --git a/tests/traffic-quality.test.ts b/tests/traffic-quality.test.ts new file mode 100644 index 000000000..0cc654137 --- /dev/null +++ b/tests/traffic-quality.test.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import vm from 'node:vm' +import { + classifyTraffic, + TRAFFIC_QUALITY_BOOTSTRAP, +} from '../src/utils/analytics/traffic-quality.ts' +import { receiveTrafficQuality } from '../src/utils/analytics/traffic-quality.server.ts' + +const windows = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/148.0.0.0 Safari/537.36' +const input = { + userAgent: windows, + referrer: '', + url: 'https://tanstack.com/', + sessionNumber: 1, +} +test('only the complete first-session direct old Chrome fingerprint is flagged', () => { + assert.equal(classifyTraffic(input), 'automated') + assert.equal( + classifyTraffic({ + ...input, + userAgent: windows.replace( + 'Windows NT 10.0; Win64; x64', + 'Macintosh; Intel Mac OS X 10_15_7', + ), + }), + 'suspect', + ) + assert.equal( + classifyTraffic({ + ...input, + userAgent: windows.replace( + 'Windows NT 10.0; Win64; x64', + 'X11; Linux x86_64', + ), + }), + 'automated', + ) + for (const change of [ + { sessionNumber: 2 }, + { referrer: 'https://google.com/' }, + { url: 'https://tanstack.com/?utm_source=email' }, + { url: 'https://tanstack.com/?gclid=123' }, + { userAgent: windows.replace('148.0.0.0', '149.0.0.0') }, + { userAgent: windows.replace('148.0.0.0', '148.0.1.2') }, + { userAgent: `${windows} Edg/148.0.0.0` }, + { + userAgent: windows.replace( + 'Windows NT 10.0; Win64; x64', + 'Linux; Android 10', + ), + }, + ]) + assert.equal(classifyTraffic({ ...input, ...change }), 'unflagged') + assert.equal( + classifyTraffic({ ...input, sessionNumber: undefined }), + 'unknown', + ) + assert.equal( + classifyTraffic(input, { currentChromeMajor: 154, versionGap: 7 }), + 'unflagged', + ) +}) + +test('head bootstrap labels the first event, preserves the session across pages and rechecks returning sessions', () => { + const storage = new Map() + let session = 100 + let number = 1 + const emitted: Array<{ event: unknown; quality: unknown }> = [] + const run = (referrer: string) => { + const context = vm.createContext({ + URL, + Blob, + setTimeout, + clearTimeout, + location: { href: input.url }, + document: { referrer }, + navigator: { + userAgent: windows, + webdriver: true, + sendBeacon: () => true, + }, + localStorage: { + getItem: (key: string) => storage.get(key) || null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + window: { + gtag: (command: string, ...args: any[]) => { + if (command === 'get') + args[2]( + args[1] === 'session_number' + ? number + : args[1] === 'session_id' + ? session + : 'client', + ) + if (command === 'event') + emitted.push({ event: args[0], quality: args[1].traffic_quality }) + }, + }, + }) + vm.runInContext(TRAFFIC_QUALITY_BOOTSTRAP, context) + vm.runInContext("window.gtag('event','page_view',{})", context) + } + run('') + run('https://tanstack.com/') + session++ + number++ + run('') + assert.deepEqual( + emitted.map((event) => event.quality), + ['automated', 'automated', 'unflagged'], + ) +}) + +test('edge diagnostics validate inputs and log network attribution without visitor identifiers', async () => { + const logs: unknown[][] = [] + const original = console.info + console.info = (...args) => { + logs.push(args) + } + try { + const request = new Request('https://tanstack.com/_a/traffic-quality', { + method: 'POST', + headers: { + origin: 'https://tanstack.com', + 'user-agent': windows, + 'cf-connecting-ip': '192.0.2.1', + cookie: 'secret', + }, + body: JSON.stringify({ quality: 'automated', webdriver: true }), + }) + Object.defineProperty(request, 'cf', { + value: { + asn: 64500, + asOrganization: 'Example Network', + botManagement: { score: 2, verifiedBot: false }, + }, + }) + assert.equal((await receiveTrafficQuality(request)).status, 204) + assert.match(JSON.stringify(logs), /Example Network/) + assert.doesNotMatch(JSON.stringify(logs), /192\.0\.2|secret/) + const invalid = new Request('https://tanstack.com/_a/traffic-quality', { + method: 'POST', + headers: { origin: 'https://elsewhere.com' }, + body: '{}', + }) + assert.equal((await receiveTrafficQuality(invalid)).status, 403) + const oversize = new Request('https://tanstack.com/_a/traffic-quality', { + method: 'POST', + headers: { origin: 'https://tanstack.com' }, + body: 'x'.repeat(513), + }) + assert.equal((await receiveTrafficQuality(oversize)).status, 413) + assert.equal(logs.length, 1) + } finally { + console.info = original + } +}) + +test('unavailable GA getters release queued events as unknown without manufacturing a first visit', () => { + let timeout = () => {} + const emitted: unknown[] = [] + const context = vm.createContext({ + URL, + Blob, + setTimeout: (callback: () => void) => { + timeout = callback + return 1 + }, + clearTimeout: () => {}, + location: { href: input.url }, + document: { referrer: '' }, + navigator: { userAgent: windows }, + localStorage: { getItem: () => null }, + window: { + gtag: (command: string, ...args: any[]) => { + if (command === 'event') emitted.push(args[1].traffic_quality) + }, + }, + }) + vm.runInContext(TRAFFIC_QUALITY_BOOTSTRAP, context) + vm.runInContext( + "window.gtag('event','page_view',{});window.gtag('event','partner_viewed',{})", + context, + ) + assert.equal(emitted.length, 0) + timeout() + assert.deepEqual(emitted, ['unknown', 'unknown']) +})