Repository navigation
Fix starter funnel and analytics traffic classification #1437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,26 @@ | ||
| # 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. | ||
|
|
||
| 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. | ||
| 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔎 Supported by static analysis
Batched automatic events can inherit the URL classification, so this sentence is too broad. In a batched request, Replace the sentence with a narrower statement: 📝 Suggested documentation change
|
||
|
|
||
| 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 | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof getNetworkTrafficQuality>, | ||
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep automatic-only sessions
unknown.If the collection proxy labels an automatic-only session’s
session_startevent asautomated, thisCASEstoresautomated.docs/analytics/traffic-quality.mdLine 17 requires sessions containing only automatic events to remainunknown. Aggregate session quality from explicit-event labels, while retaining the proxy label on the event. Google identifiessession_startas an automatically collected event. (support.google.com)