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 watch
[tracking]log lines appear. - 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). The view calls thega4_data_layerhelper (app/helpers/tracking_helper.rb), which renders a small<script>with JSON built viato_jsonβ no hand-written string interpolation. -
Browser-interaction pushes for clicks and submits
(
select_item,add_to_cart,remove_from_cart, plusvirtual_page_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:
<%= ga4_data_layer("view_item",
currency: TrackingHelper::CURRENCY,
value: @product.price.to_f,
items: [ ga4_product_item(@product) ]) %>
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 watch the [tracking] select_item line 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 β
find it in tracking_helper.rb 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 virtual pageview.
The tracking controller listens for Turbo navigations and pushes a synthetic
virtual_page_view event with location, path and title.
In GTM, trigger page-view tags on that custom event β 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). -
Point the app at it β no code edits needed, the layout reads the environment:
GTM_CONTAINER_ID=GTM-XXXXXXX bin/rails server -
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.
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.at(-1) // the most recent push
Plus the app's own debug log: every push prints a
[tracking] <event> line (see
tracking_controller.js#log; disable with
data-tracking-debug-value="false" on
<body>).
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
virtual_page_views. You learn: what fires when. -
Find the clear.
Locate
ecommerce: nullinapp/helpers/tracking_helper.rband 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 the helper, 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.
Quick Reference
| Payload builders | app/helpers/tracking_helper.rb |
| Clicks + virtual pageviews | app/javascript/controllers/tracking_controller.js |
| GTM snippet wiring | app/views/layouts/application.html.erb |
| 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 |