GTM & GA4 E-Commerce Tracking Tutorial
All 8 GA4 e-commerce events are already implemented in this store. Your job: read the real code, watch it fire in your browser, connect it to Google Tag Manager — then break and extend it with the exercises below.
Also available as markdown in
TRACKING_TUTORIAL.md
in the project root.
How to use this sandbox
- Watch (5 min). Open DevTools console (F12), click through the store, and inspect
dataLayer— every event lands there, or watch them arrive in GTM Preview mode. - Read (20 min). Work through the 8 events below — each links to the exact file and element that produces it.
- Connect (20 min). Point the app at your GTM container (§5) and watch your clicks arrive in GTM Preview mode.
- Practice. Do the exercises at the bottom. They teach more than reading ever will.
The Big Picture
Your click (e.g. "Add to cart")
↓
Rails view or Stimulus controller builds a payload
↓
window.dataLayer.push({ event: "add_to_cart", ecommerce: { ... } })
↓
GTM trigger fires on the custom event name
↓
GTM tag forwards the payload to GA4 (or Ads, Meta Pixel, …)
dataLayer
is a plain JS array — the only contract between your
site and GTM. Your code pushes objects in; GTM reads them out.
Two mechanisms produce events here — learn to tell them apart:
-
Server-rendered pushes for page views
(
view_item_list,view_item,view_cart,begin_checkout,purchase). Each view buildsitems→ecommerce→payloadinline (plain Ruby hashes,to_json+json_escape) and renders a small<script>that pushes it — no hand-written string interpolation. -
Browser-interaction pushes for clicks and submits
(
select_item,add_to_cart,remove_from_cart, pluspage_view). One Stimulus controller (app/javascript/controllers/tracking_controller.js, attached to<body>) reads product facts fromdata-*attributes and pushes onclick/submit.
The 8 Events — What Fires Them
| Event | Fires when… | Code lives in |
|---|---|---|
| view_item_list | Catalog loads (incl. filter / search) | products/index.html.erb |
| select_item | A product card or title is clicked | tracking_controller.js #selectItem |
| view_item | Product detail page loads | products/show.html.erb |
| add_to_cart | Any add-to-cart form is submitted | tracking_controller.js #addToCart |
| view_cart | Cart page loads with items | carts/show.html.erb |
| remove_from_cart | Trash button in the cart is clicked | tracking_controller.js #removeFromCart |
| begin_checkout | Checkout page loads | checkouts/new.html.erb |
| purchase | Order confirmation page loads | orders/show.html.erb |
Example A: server-rendered push
(view_item)
From app/views/products/show.html.erb:
<%
ecommerce = {
currency: "USD",
value: @product.price.to_f,
items: [{
item_id: @product.sku, item_name: @product.name,
item_category: @product.category,
price: @product.price.to_f, quantity: 1
}]
}
payload = json_escape({ event: "view_item", ecommerce: ecommerce }.to_json)
%>
<%= javascript_tag("window.dataLayer=...push({ecommerce:null});...push(#{payload});") %>
Example B: browser-interaction wiring
(select_item)
From app/views/products/index.html.erb —
product facts travel in plain data-* attributes on
.product-card:
<%= link_to product_path(product), class: "product-image-link",
data: { action: "click->tracking#selectItem" } do %>
Try it: open the catalog, right-click a product card → Inspect.
Find the data-product-* attributes, then click the
card and run dataLayer.filter(e => e.event === "select_item") in the console.
Two Rules That Prevent 90% of Bugs
Rule 1 — always clear ecommerce first.
GTM keeps the last ecommerce object in memory.
Push a new event without clearing and fields bleed across events.
Both mechanisms in this app push { ecommerce: null } first
(without an event key, so it fires no trigger) —
find it in each of the 5 views and in
tracking_controller.js#push.
Rule 2 — the item schema.
{
item_id: "AUDIO-ANC-001", // required — SKU
item_name: "Aura Headphones", // required — name
item_category: "Audio", // recommended
price: 199.99, // Number, not a string!
quantity: 1 // Number, not a string!
}
Strings for price/quantity
is the most common beginner mistake — GA4 silently drops the revenue.
Notice the parseFloat/parseInt
calls in the Stimulus controller: HTML attributes are always strings.
The Turbo Gotcha
Rails uses Turbo Drive: link clicks and form submits swap the
<body>
via AJAX without a full page reload. GTM's built-in Page View trigger fires only
on the first load — every later navigation is invisible to it.
How this app handles it: a custom page_view event.
The tracking controller listens for Turbo navigations and pushes
page_view with location, path and title on every page.
In GTM, trigger page-view tags on that custom event (not on All Pages, or the first
load fires twice) — or use GTM's History Change trigger
(Turbo uses the History API, so it works too).
Connect Your GTM Container
-
Create a container at tagmanager.google.com
(platform Web) and copy the ID (
GTM-XXXXXXX). -
Hardcode the ID in
app/views/layouts/application.html.erb— ersetze an beiden StellenGTM-XXXXXXXdurch deine echte ID (im<head>-Script und im<noscript>-iframe):GTM-XXXXXXX → GTM-ABC1234 # in application.html.erb (2 places) -
In GTM click Preview, enter
http://localhost:3000, and shop in the connected tab. -
Create one trigger (Custom Event, regex enabled):
^(view_item_list|select_item|view_item|add_to_cart|view_cart|remove_from_cart|begin_checkout|purchase)$ -
Create one tag (GA4 Event, event name
{{Event}}, Send Ecommerce data from Data Layer), attach your Measurement ID and the trigger. Save and Publish. - Shop again in Preview: each event appears on the left, and the Data Layer tab shows currency, value and items.
Consent Mode v2 — Cookie Banner
The banner (shared/_cookie_banner.html.erb +
consent_controller.js)
implements Google Consent Mode v2 with 3 categories:
Necessary (always on), Analytics
(analytics_storage) and Marketing
(ad_storage, ad_user_data,
ad_personalization).
Why the default lives in <head>, before GTM.
The layout sets gtag('consent', 'default', …) first, then loads
gtm.js. On repeat visits it reads the saved choice from
localStorage synchronously, so the right consent is active
before any tracking fires. Every click pushes
gtag('consent', 'update', …) plus a
consent_update dataLayer event. The footer link
“Cookies verwalten” re-opens the banner with saved checkboxes pre-selected.
In GTM, require analytics_storage for GA4 tags
(Admin → Consent Overview) and check the Preview → Consent tab.
Verify Like a Pro — Console Recipes
Most debugging needs no GTM at all. DevTools (F12) → Console, paste:
dataLayer // everything pushed so far
dataLayer.filter(e => e.event) // only real events
dataLayer.filter(e => e.event === "purchase")
dataLayer.filter(e => e.event === "consent_update").at(-1)
dataLayer.at(-1) // the most recent push
Tag Assistant labels every push “Nachricht / dataLayer push” — click it to see the
real event name. The
ecommerce: null clear shows as a message without an event name.
Exercises — Do These, In Order
-
Watch the funnel.
Console open, go catalog → product → add to cart → cart → checkout → place order.
Check off all 8 events plus
page_views viadataLayer.filter(e => e.event). You learn: what fires when. -
Find the clear.
Locate
ecommerce: nullin the 5 views and intracking_controller.js#push. You learn: the one line that prevents data bleeding. -
Add a coupon.
Hard-code
coupon: "WELCOME10"into thepurchasepayload inapp/views/orders/show.html.erb, place an order, and confirm it indataLayer.at(-1). You learn: the server-rendered path. -
Track search.
In
app/views/products/index.html.erb, push aview_search_resultsevent (withsearch_term: @search_query) only when@search_queryis present. Search for "hoodie" and confirm it fires alongsideview_item_list. You learn: adding a new event end to end. -
Break it on purpose.
Comment out the
{ ecommerce: null }push in one view, view a product, then open the cart. Inspect theview_cartpayload — stale items bleeding in? Restore the line. You learn: why Rule 1 exists, by seeing the bug yourself. -
Test consent.
Accept all, then open “Cookies verwalten” in the footer and switch Analytics off.
Confirm a
consent_updatewithanalytics_storage: "denied"lands in the dataLayer, reload, and confirm the head default already starts denied. You learn: Consent Mode default vs. update.
Quick Reference
| Server pushes (items → ecommerce → payload) | products/index, products/show, carts/show, checkouts/new, orders/show |
| Clicks + page views | app/javascript/controllers/tracking_controller.js |
| Consent banner + Consent Mode | shared/_cookie_banner.html.erb + consent_controller.js |
| GTM snippet (hardcoded) | app/views/layouts/application.html.erb → GTM-XXXXXXX ersetzen |
| Cart math (free ship ≥ $75, 8% tax) | app/models/cart.rb |
| Reset demo data | bin/rails db:seed (or footer button) |
| Inspect data | bin/rails console → Product.count, Order.last |