End-to-end digital measurement showcase: GA4 event analysis, GTM implementation design, Enhanced Ecommerce funnel, Consent Mode v2, and BigQuery batch analytics — using Google's own public GA4 dataset.
Nov 2020 – Jan 2021 · Google Merchandise Store · GA4 Property (web stream)
Auto-collected · Enhanced Measurement · Recommended ecommerce · Custom events
GA4 classifies events into four tiers — this split is typical for a mid-size ecommerce store
Page views + ecommerce events stacked · December holiday spike clearly visible
Fired automatically by GA4 SDK — no GTM tag needed
Toggle in GA4 Admin → Data Streams → Enhanced Measurement
Require GTM dataLayer push + GA4 event tag
Business-specific; tracked via GTM custom event trigger + GA4 tag
GA4 recommended events · user-level funnel · BigQuery CTE analysis
Computed via BigQuery: each user counted once per step (regardless of how many times they triggered the event). Drop-off % shown between steps — checkout abandonment is the biggest opportunity.
Users who reached each stage at least once
Percentage of prior-step users who proceeded to the next step
first_visit event · traffic_source struct · source / medium / campaign
Based on traffic_source.medium grouping
Purchase revenue attributed to first_visit source
Purchases ÷ unique users per channel
Enhanced Ecommerce · UNNEST(items) · category × brand breakdown
Holiday season (Dec 2020) drove 56% revenue uplift vs November baseline
Apparel dominates — consistent with Google Merchandise Store's core catalogue
Desktop leads — expected for B2C Google merch purchases
Chrome dominates — reflects audience aligned with Google ecosystem
US is primary market; international tail worth segmenting for targeted campaigns
Container structure · dataLayer specification · trigger → variable → tag pattern
CMS / SPA pushes dataLayer events on user interaction
dataLayer.push()Triggers listen for dataLayer events; Variables extract params
GTM-XXXXXXXOne tag per recommended event; Config tag fires on all pages
GA4 Config + EventsEvents stream to GA4 → BigQuery export → daily sharded tables
G-XXXXXXXXXXevents_YYYYMMDD sharded tables · UNNEST for analysis
ga4_obfuscated_*// Clear previous ecommerce object (CRITICAL — prevents data bleeding) window.dataLayer.push({ ecommerce: null }); // Push purchase event — fires on order confirmation page window.dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'TXN-98765', // dedup key in GA4 currency: 'USD', value: 59.98, tax: 4.80, shipping: 5.00, coupon: 'SUMMER10', items: [{ item_id: 'SKU-12345', item_name: 'Google Unisex Eco Tee Black', item_brand: 'Google', item_category: 'Apparel', price: 29.99, quantity: 2 }] } });
// Clear ecommerce — always before any ecommerce push dataLayer.push({ ecommerce: null }); // view_item — fires when product detail page loads dataLayer.push({ event: 'view_item', ecommerce: { currency: 'USD', value: 29.99, items: [{ item_id: 'SKU-12345', item_name: 'Google Unisex Eco Tee Black', item_brand: 'Google', item_category: 'Apparel', // maps to category in BigQuery items[] item_category2: 'Mens', item_variant: 'Black / L', price: 29.99, quantity: 1, index: 0 // position in list (for list attribution) }] } });
// ─── Consent Mode v2 ──────────────────────────────────────────────────── // MUST execute BEFORE the GTM snippet loads. Sets the default deny state. // GA4 will fire cookieless pings for denied users and use behavioural // modelling to fill the measurement gap (requires 1,000 daily events). window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('consent', 'default', { analytics_storage: 'denied', // GA4 measurement ad_storage: 'denied', // Google Ads cookies ad_user_data: 'denied', // Enhanced Conversions user data ad_personalization: 'denied', // Remarketing wait_for_update: 500 // ms to wait for CMP callback }); // GTM container loads here... // When user ACCEPTS in CMP callback: gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted' });
-- GA4 Ecommerce Funnel — BigQuery SQL -- Uses QueryPriority.BATCH (see batch_extractor.py) for zero slot cost -- _TABLE_SUFFIX wildcard restricts scan to the 92-day analysis window WITH user_steps AS ( SELECT user_pseudo_id, MAX(CASE WHEN event_name = 'view_item' THEN 1 END) AS s1, MAX(CASE WHEN event_name = 'add_to_cart' THEN 1 END) AS s2, MAX(CASE WHEN event_name = 'begin_checkout' THEN 1 END) AS s3, MAX(CASE WHEN event_name = 'purchase' THEN 1 END) AS s4 FROM `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*` WHERE _TABLE_SUFFIX BETWEEN '20201101' AND '20210131' GROUP BY user_pseudo_id ) SELECT step, label, COUNTIF(flag=1) AS users FROM ( SELECT 1 step,'view_item' label, s1 flag FROM user_steps UNION ALL SELECT 2,'add_to_cart', s2 FROM user_steps UNION ALL SELECT 3,'begin_checkout', s3 FROM user_steps UNION ALL SELECT 4,'purchase', s4 FROM user_steps ) GROUP BY step, label ORDER BY step;
privacy_info struct in BigQuery · analytics_storage · ads_storage · modelling gap
Privacy-compliant measurement without sacrificing conversion visibility
Consent banner displayed; default state = 'denied' for all signal types
GA4 sends cookieless ping · no cookies set · user_pseudo_id is transient
gtag consent update → 'granted' · GA4 fires full measurement tags
Behavioural modelling fills the denied gap in reports (requires 1K daily events)
privacy_info.analytics_storage column allows auditing consent-gap in SQL
GA4 → BQ export · events_* sharding · UNNEST patterns · QueryPriority.BATCH savings
Cost and slot comparison for GA4 export analysis
events_YYYYMMDD shard growth · Dec spike = holiday traffic
bigquery/batch_extractor.py
· QueryPriority.BATCH · 6-h SLA · use_query_cache=True · safety cap: 5 GB/query
from google.cloud import bigquery from google.cloud.bigquery import QueryJobConfig, QueryPriority def batch_job_config(destination_table=None): """ BATCH priority = free shared slots (BigQuery slot pool). No reservation needed; 6-h SLA; ~50% cost saving vs INTERACTIVE on large GA4 export tables. We schedule overnight via Cloud Scheduler. """ cfg = QueryJobConfig( priority = QueryPriority.BATCH, # ← key setting use_query_cache = True, # reuse cached results maximum_bytes_billed = 5_000_000_000, # 5 GB safety cap ) if destination_table: cfg.destination = f"{PROJECT}.{DATASET}.{destination_table}" cfg.write_disposition = WriteDisposition.WRITE_TRUNCATE return cfg # Run query — blocks until BATCH job completes (SLA ≤ 6 hours) job = client.query(FUNNEL_SQL, job_config=batch_job_config('ga4_funnel')) result = job.result() # waits for completion df = result.to_dataframe()
GA4 exports create one shard per day (events_20201115). Filtering with WHERE _TABLE_SUFFIX BETWEEN '20201101' AND '20210131' limits the scan to exactly 92 shards instead of the entire table history — critical for cost control.
GA4 stores all event parameters as a repeated RECORD (ARRAY of STRUCT). Every parameter access requires UNNEST(event_params) or a subquery: (SELECT value.string_value FROM UNNEST(event_params) WHERE key='page_location')
Enhanced Ecommerce product data is in the items[] ARRAY. UNNEST one item per row then SUM(i.price × i.quantity) for revenue. Always filter WHERE event_name = 'purchase' first to avoid counting cart items as revenue.
GA4 doesn't have a native session-level table. Reconstruct sessions as: CONCAT(user_pseudo_id, '-', ga_session_id) and deduplicate with COUNT(DISTINCT ...). session_start event is the anchor for session-level attributes.
user_ltv struct · user_first_touch_timestamp · acquisition cohort by month
Nov 2020 cohort has highest LTV after 3 months — earliest acquisition, most time to purchase
Dec cohort shows lower per-user LTV — holiday browsers convert at lower rates post-December
Why this dashboard is deployed on Netlify — and what the config achieves
This dashboard is a 100% static HTML file — no server runtime, no backend, no database.
Netlify's 300+ edge node CDN delivers the page in <50ms TTFB from Cape Town to California.
Why Netlify over GitHub Pages? Custom security headers (CSP, HSTS),
branch deploy previews, instant rollbacks, and the netlify.toml
configuration file allows version-controlling the deployment behaviour alongside the code.
What netlify.toml does: Redirects root → dashboard, sets
X-Frame-Options / X-Content-Type-Options / Content-Security-Policy headers,
and caches HTML for 1 hour and JS/CSS assets for 1 year (immutable).
# netlify.toml — version-controlled deployment config [build] publish = "." # serve from project root — no build step command = "" # static site; no npm/webpack needed [[redirects]] from = "/" to = "/ga4_dashboard.html" status = 200 # 200 = rewrite (URL stays at /) [[headers]] for = "/*" [headers.values] X-Frame-Options = "SAMEORIGIN" X-Content-Type-Options = "nosniff" Content-Security-Policy = "default-src 'self'; script-src 'unsafe-inline' https://cdn.jsdelivr.net" [[headers]] for = "/*.html" [headers.values] Cache-Control = "public, max-age=3600, must-revalidate"
Actionable findings from the GA4 BigQuery analysis — the "so what" layer
76% of users who view a product never add it to cart. Only 24% of view_item users reach add_to_cart — the single biggest funnel gap. GTM can track "Add to Cart" button hover + click separately to identify hesitation signals.
Mobile users represent 28% of traffic but only 12% of revenue — conversion rate is 3× lower than desktop. Device category split in BigQuery shows mobile checkout drop-off at the payment step specifically.
Google CPC traffic converts at 4.1% — nearly 2× the organic rate. Enhanced Conversions in Google Ads via GTM (hashed email on purchase event) will improve bidding signal quality by reducing the modelled conversion gap from Consent Mode denials.
21% of events have analytics_storage = 'No'. GA4 models this gap but modelled conversions have ~80% accuracy. Implementing server-side tagging (sGTM) with first-party cookies would reduce dependency on modelling for returning users.
December revenue was 56% above November — but Avg Order Value dropped 4% (discount codes). The coupon field in the purchase dataLayer push allows BigQuery analysis of promo impact: WHERE ecommerce.coupon IS NOT NULL.
22% of users are non-US, but international revenue share is only 14%. UK (5% users, 3.8% revenue) and Canada (8% users, 7.2% revenue) show strong intent signals in GA4's geo dimension — lower conversion likely due to shipping friction.
Interactive ecommerce tracking showcase — Bash/TFG product catalog · Real event firing · Live dataLayer console
Live demo featuring 9,275 real Bash/TFG products. Browse the catalog, add items to cart, complete checkout — all events fire to the dataLayer in real-time. Shows complete GA4 ecommerce event taxonomy in action.
9,275 products from Bash/TFG ecommerce catalog. Browse by category, apply filters, view item details — all trigger proper GA4 ecommerce events.
Every interaction (view_item, add_to_cart, begin_checkout, purchase) pushes a structured event to window.dataLayer. Watch the JSON in real-time in the console.
Demonstrates all stages: catalog browsing → product selection → cart → checkout steps → purchase confirmation → refund requests.
Add your GTM ID to gtm-demo/config.js, deploy to Netlify, and use GTM Preview Mode to debug tags in real-time.
Three ways to interact with real GA4 ecommerce tracking
gtm-demo/index.html in a browser. Events fire to console.GTM-XXXXXXX in config.js with your real GTM ID. Deploy, test, and monitor tag performance.