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
31 changes: 31 additions & 0 deletions docs/analytics/missing-page-views.sql
Original file line number Diff line number Diff line change
@@ -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;
23 changes: 23 additions & 0 deletions docs/analytics/traffic-quality.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { receiveTrafficQuality } from './utils/analytics/traffic-quality.server'
import {
timeRequestWork,
withDocsRequestTimings,
Expand Down Expand Up @@ -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, {
Expand Down
84 changes: 84 additions & 0 deletions src/utils/analytics/traffic-quality.server.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
152 changes: 152 additions & 0 deletions src/utils/analytics/traffic-quality.ts
Original file line number Diff line number Diff line change
@@ -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)});`
Loading
Loading