diff --git a/docs/analytics/session-summary.sql b/docs/analytics/session-summary.sql new file mode 100644 index 000000000..7bb659360 --- /dev/null +++ b/docs/analytics/session-summary.sql @@ -0,0 +1,111 @@ +-- Run daily after the GA daily export. Rebuild four start-date partitions to +-- include late arrivals. A one-time backfill can widen start_date/end_date. +DECLARE start_date DATE DEFAULT DATE_SUB(CURRENT_DATE('UTC'), INTERVAL 4 DAY); +DECLARE end_date DATE DEFAULT CURRENT_DATE('UTC'); + +CREATE TABLE IF NOT EXISTS `tanstack-analytics.reporting.sessions` +( + session_date DATE, + user_pseudo_id STRING, + session_id INT64, + first_event_at TIMESTAMP, + last_event_at TIMESTAMP, + hostname STRING, + landing_path STRING, + referrer_host STRING, + source STRING, + medium STRING, + default_channel_group STRING, + channel_group STRING, + country STRING, + browser STRING, + is_new_user BOOL, + engaged BOOL, + engagement_time_msec INT64, + page_views INT64, + starter_views INT64, + starter_generations INT64, + starter_activations INT64, + partner_views INT64, + partner_clicks INT64, + distinct_partners_clicked INT64, + traffic_quality STRING +) +PARTITION BY session_date +CLUSTER BY hostname, source, country +OPTIONS (require_partition_filter = TRUE, + description = 'One row per GA client/session. UTC session-start date. Rebuild recent partitions for late events.'); + +CREATE TEMP TABLE refreshed AS +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_name, + geo.country, + device.web_info.browser, + session_traffic_source_last_click.cross_channel_campaign.source AS source, + session_traffic_source_last_click.cross_channel_campaign.medium AS medium, + session_traffic_source_last_click.cross_channel_campaign.default_channel_group AS default_channel_group, + (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, + (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'partner_id') AS partner_id, + (SELECT value.string_value FROM UNNEST(event_params) WHERE key = 'traffic_quality') AS traffic_quality, + (SELECT COALESCE(value.int_value, SAFE_CAST(value.string_value AS INT64)) FROM UNNEST(event_params) WHERE key = 'session_engaged') AS session_engaged, + (SELECT value.int_value FROM UNNEST(event_params) WHERE key = 'engagement_time_msec') AS engagement_time_msec + FROM `tanstack-analytics.analytics_257297153.events_*` + -- A day either side accommodates property-timezone boundaries. + WHERE _TABLE_SUFFIX BETWEEN FORMAT_DATE('%Y%m%d', DATE_SUB(start_date, INTERVAL 1 DAY)) + AND FORMAT_DATE('%Y%m%d', DATE_ADD(end_date, INTERVAL 1 DAY)) +), sessions AS ( + SELECT DATE(TIMESTAMP_SECONDS(session_id), 'UTC') AS session_date, + user_pseudo_id, session_id, + TIMESTAMP_MICROS(MIN(event_timestamp)) AS first_event_at, + TIMESTAMP_MICROS(MAX(event_timestamp)) AS last_event_at, + ARRAY_AGG(STRUCT(NET.HOST(page_location) AS hostname, + REGEXP_EXTRACT(page_location, r'^https?://[^/]+([^?#]*)') AS landing_path, + NET.HOST(page_referrer) AS referrer_host, country, browser) + ORDER BY event_timestamp LIMIT 1)[OFFSET(0)] AS entry, + ARRAY_AGG(source IGNORE NULLS ORDER BY event_timestamp DESC LIMIT 1)[SAFE_OFFSET(0)] AS source, + ARRAY_AGG(medium IGNORE NULLS ORDER BY event_timestamp DESC LIMIT 1)[SAFE_OFFSET(0)] AS medium, + ARRAY_AGG(default_channel_group IGNORE NULLS ORDER BY event_timestamp DESC LIMIT 1)[SAFE_OFFSET(0)] AS default_channel_group, + COUNTIF(event_name = 'first_visit') > 0 AS is_new_user, + COUNTIF(session_engaged = 1) > 0 AS engaged, + SUM(COALESCE(engagement_time_msec, 0)) AS engagement_time_msec, + COUNTIF(event_name = 'page_view') AS page_views, + COUNTIF(event_name = 'application_starter_viewed') AS starter_views, + COUNTIF(event_name = 'application_starter_generated') AS starter_generations, + COUNTIF(event_name = 'application_starter_activated') AS starter_activations, + COUNTIF(event_name = 'partner_viewed') AS partner_views, + COUNTIF(event_name = 'partner_clicked') AS partner_clicks, + COUNT(DISTINCT IF(event_name = 'partner_clicked', partner_id, NULL)) AS distinct_partners_clicked, + CASE + WHEN COUNTIF(traffic_quality = 'automated') > 0 THEN 'automated' + WHEN COUNTIF(traffic_quality = 'datacenter') > 0 THEN 'datacenter' + WHEN COUNTIF(traffic_quality = 'suspect') > 0 THEN 'suspect' + WHEN COUNTIF(traffic_quality = 'unflagged') > 0 THEN 'unflagged' + ELSE 'unknown' + END AS traffic_quality + FROM events + WHERE user_pseudo_id IS NOT NULL AND session_id IS NOT NULL + AND DATE(TIMESTAMP_SECONDS(session_id), 'UTC') >= start_date + AND DATE(TIMESTAMP_SECONDS(session_id), 'UTC') < end_date + GROUP BY session_date, user_pseudo_id, session_id +) +SELECT session_date, user_pseudo_id, session_id, first_event_at, last_event_at, + entry.hostname, entry.landing_path, entry.referrer_host, + source, medium, default_channel_group, + IF(source = 'search.brave.com', 'Organic Search', default_channel_group) AS channel_group, + entry.country, entry.browser, is_new_user, engaged, engagement_time_msec, + page_views, starter_views, starter_generations, starter_activations, + partner_views, partner_clicks, distinct_partners_clicked, + IF(distinct_partners_clicked >= 10, 'automated', traffic_quality) AS traffic_quality +FROM sessions; + +-- Replacement is atomic. Never leave a partition empty after a failed scan. +BEGIN TRANSACTION; +DELETE FROM `tanstack-analytics.reporting.sessions` +WHERE session_date >= start_date AND session_date < end_date; +INSERT INTO `tanstack-analytics.reporting.sessions` SELECT * FROM refreshed; +COMMIT TRANSACTION; diff --git a/docs/analytics/traffic-quality.md b/docs/analytics/traffic-quality.md index 5779ba316..56a49986a 100644 --- a/docs/analytics/traffic-quality.md +++ b/docs/analytics/traffic-quality.md @@ -1,6 +1,6 @@ # 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. +Flag-only, rule v2. 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. @@ -8,13 +8,19 @@ The head bootstrap wraps explicit GA events before React starts. It uses Google' 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. +Rule v2 also counts distinct partner IDs clicked within the GA session. The tenth different partner and subsequent events are labeled `automated`, including after full-page navigation. Repeated clicks on one partner do not advance the count. Storage is bounded to ten IDs. Earlier events cannot be relabeled in GA4; reporting must exclude the whole session using its strongest label. + +The collection proxy labels explicit and automatic events using Cloudflare request metadata. Verified bots and bot score 1 get `automated`; scores 2 through 29 get `suspect`. Known hosting ASNs (Amazon 16509/14618, Google Cloud 396982, Microsoft 8075, DigitalOcean 14061, OVH 16276, Hetzner 24940) and Cloudflare corporate proxies get `datacenter`. This label identifies network origin, not proof of automation. Real people using VPNs or corporate proxies may be included. No country-based rule is used, and missing metadata does not imply a human visitor. Existing client `automated` labels take precedence. No IP or ASN is sent to GA. + +Cloudflare's [bot-score guidance](https://developers.cloudflare.com/bots/concepts/bot-score/) defines the score ranges. ASN coverage is deliberately limited and should be reviewed against the network diagnostic samples, not treated as a complete proxy database. + +Explicit events queued during a GA session lookup share that lookup. The next event after it completes starts a new lookup. Automatic events do not inherit the browser's previous session classification, but the collection proxy can label them from the current request's network metadata. In BigQuery, classify each session using its strongest event label 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 unless the proxy supplies a network label. 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. +Use seven complete property-timezone days after deployment. Keep all labels in totals until the review is complete. Compare engagement by session label and compare unflagged returning engagement with the baseline. The browser-version heuristic applies only to first-session Direct traffic; network and scanner labels can apply to returning or organically attributed sessions. Validate those signals separately. The original browser-heuristic target of under 5% automated sessions is a validation target, not a classification condition. Show excluded volume separately if reporting exclusion is approved later. ## Missing page views diff --git a/src/components/application-starter/useApplicationStarter.tsx b/src/components/application-starter/useApplicationStarter.tsx index 3ab8d0fff..805402fcf 100644 --- a/src/components/application-starter/useApplicationStarter.tsx +++ b/src/components/application-starter/useApplicationStarter.tsx @@ -158,6 +158,13 @@ export function useApplicationStarter({ defaultApplicationStarterSessionContext, ) + const hasTrackedView = React.useRef(false) + React.useEffect(() => { + if (hasTrackedView.current) return + hasTrackedView.current = true + trackEvent('application_starter_viewed', sessionContextRef.current) + }, []) + const setSessionIdea = React.useCallback((label: string) => { sessionContextRef.current = { ...sessionContextRef.current, diff --git a/src/components/markdown/PartnerList.tsx b/src/components/markdown/PartnerList.tsx index ae926ebcd..65915fe8f 100644 --- a/src/components/markdown/PartnerList.tsx +++ b/src/components/markdown/PartnerList.tsx @@ -8,7 +8,7 @@ export function PartnerList({ }) { return (
- +
) } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 72236715d..84d568659 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -68,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('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 GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){if(location.hostname!=='tanstack.com'||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:'v2'});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 8b46c8a23..58d44aa1d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,8 @@ +import { readTextBody } from './utils/api-boundary.server' +import { + getNetworkTrafficQuality, + labelAnalyticsCollection, +} from './utils/analytics/collection.server' import { receiveTrafficQuality } from './utils/analytics/traffic-quality.server' import { timeRequestWork, @@ -137,8 +142,18 @@ async function proxyAnalyticsRequest(request: Request, url: URL) { return null } + const quality = + url.pathname === '/_a/g/collect' + ? getNetworkTrafficQuality(request) + : undefined + if (url.pathname === '/_a/g/collect') { - upstreamUrl.search = url.search + if (url.hostname !== 'tanstack.com') + return new Response(null, { status: 204 }) + upstreamUrl.search = labelAnalyticsCollection( + new URLSearchParams(url.search), + quality, + ).toString() } const init: RequestInit = { @@ -148,7 +163,28 @@ async function proxyAnalyticsRequest(request: Request, url: URL) { } if (request.method !== 'GET' && request.method !== 'HEAD') { - init.body = request.body + if (url.pathname === '/_a/g/collect') { + const result = await readTextBody(request, 64 * 1024) + if (!result.success) + return new Response(null, { status: result.error.status }) + const body = result.text + init.body = body + ? body + .split('\n') + .map((line) => + line + ? labelAnalyticsCollection( + new URLSearchParams(line), + quality, + upstreamUrl.searchParams.get('ep.traffic_quality'), + ).toString() + : line, + ) + .join('\n') + : body + } else { + init.body = request.body + } } const response = await fetch(upstreamUrl, init) diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 20d6b3aba..e53b013ef 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -118,6 +118,15 @@ function buildEventPayload(props: object): EventPayload { payload[key] = normalized } + if ( + typeof window !== 'undefined' && + (window.location.pathname === '/stats/npm' || + window.location.pathname.startsWith('/stats/npm/')) + ) { + payload.page_location = window.location.origin + window.location.pathname + payload.page_path = window.location.pathname + } + return payload } diff --git a/src/utils/analytics/collection.server.ts b/src/utils/analytics/collection.server.ts new file mode 100644 index 000000000..0069db813 --- /dev/null +++ b/src/utils/analytics/collection.server.ts @@ -0,0 +1,58 @@ +import { z } from 'zod' + +const networkSchema = z.object({ + asn: z.number().optional(), + botManagement: z + .object({ + score: z.number().optional(), + verifiedBot: z.boolean().optional(), + corporateProxy: z.boolean().optional(), + }) + .nullish(), +}) + +// Hosting networks, not a list of proven bots. VPN users can share these ASNs. +const datacenterASNs = new Set([ + 16509, 14618, 396982, 8075, 14061, 16276, 24940, +]) + +export function getNetworkTrafficQuality(request: Request) { + const parsed = networkSchema.safeParse('cf' in request ? request.cf : {}) + if (!parsed.success) return undefined + const { asn, botManagement: bot } = parsed.data + if (bot?.verifiedBot || bot?.score === 1) return 'automated' + if (bot?.corporateProxy || (asn !== undefined && datacenterASNs.has(asn))) + return 'datacenter' + if (bot?.score !== undefined && bot.score >= 2 && bot.score < 30) + return 'suspect' + return undefined +} + +export function labelAnalyticsCollection( + params: URLSearchParams, + networkQuality: ReturnType, + sharedQuality: string | null = null, +) { + const clientQuality = params.get('ep.traffic_quality') ?? sharedQuality + if (networkQuality && clientQuality !== 'automated') { + params.set('ep.traffic_quality', networkQuality) + } + params.set('ep.traffic_quality_rule', 'v2') + // Automatic GA events also carry the document URL. Normalize at collection + // so chart state never leaks through scroll, engagement, or click events. + const location = params.get('dl') + if (location) { + try { + const url = new URL(location) + if ( + url.pathname === '/stats/npm' || + url.pathname.startsWith('/stats/npm/') + ) { + params.set('dl', url.origin + url.pathname) + } + } catch { + // Preserve malformed upstream values rather than breaking collection. + } + } + return params +} diff --git a/src/utils/analytics/events.ts b/src/utils/analytics/events.ts index 0b4af009f..d0a2044e1 100644 --- a/src/utils/analytics/events.ts +++ b/src/utils/analytics/events.ts @@ -19,6 +19,7 @@ export type PartnerPlacement = | 'docs_rail' | 'blog_rail' | 'grid' + | 'blog_grid' | 'home_grid' | 'library_grid' | 'embed_grid' @@ -121,6 +122,10 @@ export type AnalyticsEvent = placement: PartnerPlacement } } + | { + name: 'application_starter_viewed' + props: ApplicationStarterSessionContext + } | { name: 'application_starter_analyzed' props: ApplicationStarterSessionContext & { diff --git a/src/utils/analytics/providers/google.ts b/src/utils/analytics/providers/google.ts index 71da9f12b..244a87160 100644 --- a/src/utils/analytics/providers/google.ts +++ b/src/utils/analytics/providers/google.ts @@ -12,6 +12,7 @@ export const googleAnalyticsProvider: AnalyticsProvider = { if ( import.meta.env.MODE === 'staging' || typeof window === 'undefined' || + window.location.hostname !== 'tanstack.com' || !window.gtag ) { return diff --git a/src/utils/analytics/traffic-quality.ts b/src/utils/analytics/traffic-quality.ts index d3ad02744..eaf2aac07 100644 --- a/src/utils/analytics/traffic-quality.ts +++ b/src/utils/analytics/traffic-quality.ts @@ -48,9 +48,11 @@ const INSTALL_TRAFFIC_QUALITY = `function installTrafficQuality(id, classify, co url: location.href, } let diagnosedSession = '' + let clickSession = '' + let clickedPartners = new Set() let pending = [] let resolving = false - send('set', { traffic_quality: 'unknown', traffic_quality_rule: 'v1' }) + send('set', { traffic_quality: 'unknown', traffic_quality_rule: 'v2' }) window.gtag = (...args) => { if (args[0] !== 'event') { send(...args) @@ -75,7 +77,10 @@ const INSTALL_TRAFFIC_QUALITY = `function installTrafficQuality(id, classify, co : '' let quality = classify({ ...entry, sessionNumber }, config) if (!key) quality = 'unknown' - // Use GA's session identity, never a separate "new visitor" cookie. + if (key !== clickSession) { + clickSession = key + clickedPartners = new Set() + } try { const saved = JSON.parse( localStorage.getItem('tanstack:traffic-quality') || 'null', @@ -84,28 +89,34 @@ const INSTALL_TRAFFIC_QUALITY = `function installTrafficQuality(id, classify, co 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 }), - ) + for (const partner of saved.partners || []) clickedPartners.add(partner) + } } 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 (event[1] === 'partner_clicked' && typeof event[2]?.partner_id === 'string' && clickedPartners.size < 10) { + clickedPartners.add(event[2].partner_id) + } + if (clickedPartners.size >= 10) quality = 'automated' + send(event[0], event[1], { ...event[2], traffic_quality: quality, traffic_quality_rule: 'v2' }) } + try { + if (key && quality !== 'unknown') localStorage.setItem( + 'tanstack:traffic-quality', + JSON.stringify({ key, quality, partners: [...clickedPartners] }), + ) + } catch {} if ( + clickedPartners.size < 10 && 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' && !( diff --git a/tests/analytics-collection.test.ts b/tests/analytics-collection.test.ts new file mode 100644 index 000000000..3e217e7d1 --- /dev/null +++ b/tests/analytics-collection.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + getNetworkTrafficQuality, + labelAnalyticsCollection, +} from '../src/utils/analytics/collection.server' + +function networkRequest(cf: object) { + const request = new Request('https://tanstack.com/_a/g/collect') + Object.defineProperty(request, 'cf', { value: cf }) + return request +} + +test('edge flags hosting networks separately from bots and leaves unknown networks alone', () => { + assert.equal( + getNetworkTrafficQuality(networkRequest({ asn: 16509 })), + 'datacenter', + ) + assert.equal( + getNetworkTrafficQuality( + networkRequest({ asn: 16509, botManagement: null }), + ), + 'datacenter', + ) + assert.equal( + getNetworkTrafficQuality(networkRequest({ asn: 7922 })), + undefined, + ) + assert.equal(getNetworkTrafficQuality(networkRequest({})), undefined) + assert.equal( + getNetworkTrafficQuality(networkRequest({ botManagement: { score: 1 } })), + 'automated', + ) + assert.equal( + getNetworkTrafficQuality(networkRequest({ botManagement: { score: 29 } })), + 'suspect', + ) + assert.equal( + getNetworkTrafficQuality(networkRequest({ botManagement: { score: 30 } })), + undefined, + ) + assert.equal( + getNetworkTrafficQuality(networkRequest({ botManagement: { score: 0 } })), + undefined, + ) + assert.equal( + getNetworkTrafficQuality( + networkRequest({ botManagement: { verifiedBot: true } }), + ), + 'automated', + ) + assert.equal( + getNetworkTrafficQuality( + networkRequest({ botManagement: { corporateProxy: true, score: 99 } }), + ), + 'datacenter', + ) +}) + +test('collection preserves scanner labels in query and batch events', () => { + const params = new URLSearchParams( + 'en=partner_clicked&ep.traffic_quality=automated&ep.partner_id=neon', + ) + labelAnalyticsCollection(params, 'datacenter') + assert.equal(params.get('ep.traffic_quality'), 'automated') + assert.equal(params.get('ep.partner_id'), 'neon') + assert.equal(params.get('ep.traffic_quality_rule'), 'v2') + const batch = labelAnalyticsCollection( + new URLSearchParams('en=page_view'), + 'datacenter', + 'automated', + ) + assert.equal(batch.has('ep.traffic_quality'), false) + const normal = labelAnalyticsCollection( + new URLSearchParams('en=page_view&ep.traffic_quality=unflagged'), + 'datacenter', + ) + assert.equal(normal.get('ep.traffic_quality'), 'datacenter') +}) + +test('collection removes chart state for automatic as well as explicit events', () => { + for (const path of ['/stats/npm', '/stats/npm/', '/stats/npm/react-vs-vue']) { + const params = new URLSearchParams({ + dl: `https://tanstack.com${path}?chart=${'x'.repeat(1500)}#chart`, + en: 'scroll', + }) + labelAnalyticsCollection(params, undefined) + assert.equal(params.get('dl'), `https://tanstack.com${path}`) + } + const params = new URLSearchParams({ + dl: 'https://tanstack.com/?utm_source=newsletter', + }) + labelAnalyticsCollection(params, undefined) + assert.equal(params.get('dl'), 'https://tanstack.com/?utm_source=newsletter') +}) diff --git a/tests/analytics-payload.test.ts b/tests/analytics-payload.test.ts new file mode 100644 index 000000000..1ec7b52a1 --- /dev/null +++ b/tests/analytics-payload.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import vm from 'node:vm' +import { buildSync } from 'esbuild' + +const result = buildSync({ + entryPoints: ['src/utils/analytics.ts'], + bundle: true, + write: false, + format: 'iife', + globalName: 'analytics', + platform: 'browser', + define: { + 'import.meta.env.MODE': '"production"', + 'import.meta.env.DEV': 'false', + }, +}) +const code = result.outputFiles[0].text + +test('page views and partner events use clean stats URLs without modifying other campaign URLs', () => { + const sent: unknown[][] = [] + const context = vm.createContext({ + URL, + document: { title: 'Stats' }, + window: { + location: new URL('https://tanstack.com/stats/npm?chart=large#state'), + gtag: (...args: unknown[]) => sent.push(args), + }, + }) + vm.runInContext(code, context) + vm.runInContext( + "analytics.trackPageView('/stats/npm?chart=large'); analytics.trackEvent('partner_viewed', {partner_id:'vercel',placement:'docs_rail'})", + context, + ) + for (const event of sent) { + assert.equal( + Reflect.get(Object(event[2]), 'page_location'), + 'https://tanstack.com/stats/npm', + ) + assert.equal(Reflect.get(Object(event[2]), 'page_path'), '/stats/npm') + } + vm.runInContext( + "window.location = new URL('https://tanstack.com/?utm_source=email'); analytics.trackPageView('/?utm_source=email')", + context, + ) + assert.equal( + Reflect.get(Object(sent[2][2]), 'page_location'), + 'https://tanstack.com/?utm_source=email', + ) +}) + +test('local and preview hosts cannot send events to the production provider', () => { + for (const hostname of [ + 'localhost', + 'preview.tanstack.com', + 'tanstack-com.workers.dev', + ]) { + const sent: unknown[][] = [] + const context = vm.createContext({ + URL, + document: { title: 'Preview' }, + window: { + location: new URL(`https://${hostname}/`), + gtag: (...args: unknown[]) => sent.push(args), + }, + }) + vm.runInContext(code, context) + vm.runInContext("analytics.trackPageView('/')", context) + assert.equal(sent.length, 0) + } +}) diff --git a/tests/traffic-quality.test.ts b/tests/traffic-quality.test.ts index 0cc654137..b01ce7459 100644 --- a/tests/traffic-quality.test.ts +++ b/tests/traffic-quality.test.ts @@ -189,3 +189,56 @@ test('unavailable GA getters release queued events as unknown without manufactur timeout() assert.deepEqual(emitted, ['unknown', 'unknown']) }) + +test('ten distinct partner clicks flag the session across reloads without counting repeats', () => { + const storage = new Map() + let session = 100 + const emitted: string[] = [] + function open() { + const context = vm.createContext({ + URL, + Blob, + setTimeout, + clearTimeout, + location: { href: input.url }, + document: { referrer: 'https://google.com/' }, + navigator: { userAgent: windows, 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' + ? 2 + : args[1] === 'session_id' + ? session + : 'client', + ) + if (command === 'event') emitted.push(args[1].traffic_quality) + }, + }, + }) + vm.runInContext(TRAFFIC_QUALITY_BOOTSTRAP, context) + return (id: string) => + vm.runInContext( + `window.gtag('event','partner_clicked',{partner_id:${JSON.stringify(id)}})`, + context, + ) + } + let click = open() + for (let i = 0; i < 5; i++) click(`partner-${i}`) + click = open() + for (let i = 0; i < 9; i++) click(`partner-${i}`) + assert.ok(emitted.every((quality) => quality === 'unflagged')) + click('partner-9') + assert.equal(emitted.at(-1), 'automated') + click = open() + click('partner-0') + assert.equal(emitted.at(-1), 'automated') + session++ + click('partner-0') + assert.equal(emitted.at(-1), 'unflagged') +})