📘 Step-by-Step Learning Guide

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

  1. 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.
  2. Read (20 min). Work through the 8 events below — each links to the exact file and element that produces it.
  3. Connect (20 min). Point the app at your GTM container (§5) and watch your clicks arrive in GTM Preview mode.
  4. Practice. Do the exercises at the bottom. They teach more than reading ever will.
1

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 builds items → ecommerce → payload inline (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, plus page_view). One Stimulus controller (app/javascript/controllers/tracking_controller.js, attached to <body>) reads product facts from data-* attributes and pushes on click / submit.
2

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.

3

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.

4

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).

5

Connect Your GTM Container

  1. Create a container at tagmanager.google.com (platform Web) and copy the ID (GTM-XXXXXXX).
  2. Hardcode the ID in app/views/layouts/application.html.erb — ersetze an beiden Stellen GTM-XXXXXXX durch deine echte ID (im <head>-Script und im <noscript>-iframe):
    GTM-XXXXXXX → GTM-ABC1234  # in application.html.erb (2 places)
  3. In GTM click Preview, enter http://localhost:3000, and shop in the connected tab.
  4. 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)$
  5. Create one tag (GA4 Event, event name {{Event}}, Send Ecommerce data from Data Layer), attach your Measurement ID and the trigger. Save and Publish.
  6. Shop again in Preview: each event appears on the left, and the Data Layer tab shows currency, value and items.
6

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.

7

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

  1. Watch the funnel. Console open, go catalog → product → add to cart → cart → checkout → place order. Check off all 8 events plus page_views via dataLayer.filter(e => e.event). You learn: what fires when.
  2. Find the clear. Locate ecommerce: null in the 5 views and in tracking_controller.js#push. You learn: the one line that prevents data bleeding.
  3. Add a coupon. Hard-code coupon: "WELCOME10" into the purchase payload in app/views/orders/show.html.erb, place an order, and confirm it in dataLayer.at(-1). You learn: the server-rendered path.
  4. Track search. In app/views/products/index.html.erb, push a view_search_results event (with search_term: @search_query) only when @search_query is present. Search for "hoodie" and confirm it fires alongside view_item_list. You learn: adding a new event end to end.
  5. Break it on purpose. Comment out the { ecommerce: null } push in one view, view a product, then open the cart. Inspect the view_cart payload — stale items bleeding in? Restore the line. You learn: why Rule 1 exists, by seeing the bug yourself.
  6. Test consent. Accept all, then open “Cookies verwalten” in the footer and switch Analytics off. Confirm a consent_update with analytics_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 viewsapp/javascript/controllers/tracking_controller.js
Consent banner + Consent Modeshared/_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 databin/rails db:seed (or footer button)
Inspect databin/rails console → Product.count, Order.last