Magento 2 GA4 and GTM Ecommerce Tracking That Adds Up
GA4 ecommerce tracking on Magento 2 looks simple: push a few events to the data layer, let Google Tag Manager send them. The problems show up later, when revenue in GA4 does not match the orders in the admin, add to cart events are missing on half the products, or the purchase is counted twice. Most of these come from how Magento renders pages: full page cache, AJAX add to cart, a Knockout based checkout and a success page that may or may not be seen.
This article covers a data layer setup for Magento 2.4 that holds up: which events to send, where each one comes from, and the Magento specifics that break tracking.
Native integration or custom data layer
Recent Open Source releases include a Google Analytics integration based on gtag under Stores > Configuration > Sales > Google API, and Adobe Commerce adds a Google Tag Manager integration with its own data layer. Check what your exact version and edition provide before building anything. The built in options are fine for basic purchase tracking, but most teams end up needing a data layer they fully control, because marketing wants extra fields, consent handling has to fit their banner, and the theme is often not Luma.
The approach below is a small custom module that pushes GA4 ecommerce events to window.dataLayer. GTM picks them up with custom event triggers and sends them to GA4 and any other tool.
The events that matter
| GA4 event | Where in Magento | Source of data |
|---|---|---|
view_item_list | Category and search pages | Server side, in cached HTML |
view_item | Product page | Server side, in cached HTML |
add_to_cart | Product page, listings | JavaScript, after AJAX success |
remove_from_cart | Minicart, cart page | JavaScript or cart page reload |
begin_checkout | Checkout page load | Cart data from customer data |
add_shipping_info | Shipping step submit | Checkout JavaScript |
add_payment_info | Place order click | Checkout JavaScript |
purchase | Success page | Server side, from the order |
Start with view_item, add_to_cart, begin_checkout and purchase. They give a usable funnel. Add the others when those are stable.
Full page cache rules
Category and product pages are served from Varnish or the built in cache. Anything you render into them is the same for every visitor in that cache context. That is fine for product data, such as item ID, name and price for the guest group. It is wrong for anything personal, such as the customer's email or ID, or cart contents. Personal data has to come from customer data sections loaded by JavaScript, which is how the minicart works. See customer data sections for how they work.
Never mark a tracking block as cacheable="false" to get per user data into it. That disables full page cache for the whole page. The reasons are explained in Varnish full page cache.
The purchase event
The success page is not cached, so the purchase event can be built on the server from the last order in the checkout session. A view model:
<?php
declare(strict_types=1);
namespace Vendor\Ga4\ViewModel;
use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class Purchase implements ArgumentInterface
{
public function __construct(
private readonly CheckoutSession $checkoutSession
) {
}
public function getEventJson(): string
{
$order = $this->checkoutSession->getLastRealOrder();
if (!$order->getEntityId()) {
return '';
}
$items = [];
foreach ($order->getAllVisibleItems() as $item) {
$items[] = [
'item_id' => $item->getSku(),
'item_name' => $item->getName(),
'price' => round((float) $item->getPrice(), 2),
'quantity' => (int) $item->getQtyOrdered(),
'discount' => round((float) $item->getDiscountAmount() / max(1, (int) $item->getQtyOrdered()), 2),
];
}
$event = [
'event' => 'purchase',
'ecommerce' => [
'transaction_id' => $order->getIncrementId(),
'value' => round((float) $order->getGrandTotal(), 2),
'tax' => round((float) $order->getTaxAmount(), 2),
'shipping' => round((float) $order->getShippingAmount(), 2),
'currency' => $order->getOrderCurrencyCode(),
'coupon' => (string) $order->getCouponCode(),
'items' => $items,
],
];
return (string) json_encode($event, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP);
}
}
Register it on the checkout_onepage_success handle and render it with the secure renderer, so the inline script gets a CSP nonce where required:
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */
$json = $block->getData('view_model')->getEventJson();
if ($json === '') {
return;
}
$script = 'window.dataLayer = window.dataLayer || [];'
. 'window.dataLayer.push({ecommerce: null});'
. 'window.dataLayer.push(' . $json . ');';
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $script, false) ?>
Pushing ecommerce: null first clears the previous ecommerce object in GTM's data model, which avoids fields from an earlier event leaking into this one.
Why purchase numbers do not match
Expect GA4 to show fewer purchases than the admin. Ad blockers, consent refusal and customers who close the tab before the success page loads all cause gaps. Payment methods that redirect to an external page and return later are the worst case. If the customer does not come back, there is no success page and no event.
The other direction, too many purchases, happens when the success page is rendered more than once for the same order, for example through reloads or custom success page modules. Keep transaction_id stable so GA4 can filter duplicates, and if you see doubles, store a flag in the session after the first render and skip the event when it is set.
When the numbers must be close to exact, send the purchase from the server with the GA4 Measurement Protocol, triggered when the order is placed or invoiced. You need the GA client ID, which you can capture from the _ga cookie at checkout and store on the quote. That is more work, and it should replace the browser purchase event, not run alongside it.
The add_to_cart event
On Luma, add to cart from the product page and from listings is an AJAX request handled by Magento_Catalog/js/catalog-add-to-cart. After a successful response it triggers a jQuery event on the document named ajax:addToCart, with the SKU and product IDs. Listen for it:
define(['jquery'], function ($) {
'use strict';
return function (config) {
$(document).on('ajax:addToCart', function (event, data) {
var qty = 1;
if (data.form) {
qty = parseInt(data.form.find('input[name="qty"]').val(), 10) || 1;
}
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ecommerce: null});
window.dataLayer.push({
event: 'add_to_cart',
ecommerce: {
currency: config.currency,
items: [{item_id: data.sku, quantity: qty}]
}
});
});
};
});
The event data has no price. If you need value on add to cart, render a small map of product ID to price in the page, which is safe in cached HTML, and look it up by the product ID in data.productIds. Configurable products report the parent SKU here, so decide whether GA4 should see the parent or the selected child, and be consistent with view_item and purchase.
Hyvä themes do not use this jQuery event. They dispatch their own browser events for cart updates, so the listener has to be written for that theme. Check the theme's documentation for the event name.
Checkout events
The Luma checkout is a Knockout application. begin_checkout can be pushed on page load using the cart section from customer data. add_shipping_info fits as a mixin on the shipping step's setShippingInformation action, and add_payment_info as a mixin on the place order action. Mixins are explained in RequireJS mixins. Keep these mixins thin and wrap them in try and catch, because a JavaScript error in tracking code must never block an order.
Loading GTM and CSP
Add the GTM container snippet to the head through your theme or module. Magento's Content Security Policy needs to allow Google's hosts, or the browser will block scripts and requests. Add them in your module's etc/csp_whitelist.xml:
<?xml version="1.0"?>
<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd">
<policies>
<policy id="script-src">
<values>
<value id="gtm" type="host">https://www.googletagmanager.com</value>
</values>
</policy>
<policy id="connect-src">
<values>
<value id="ga-collect" type="host">https://*.google-analytics.com</value>
<value id="ga-analytics" type="host">https://*.analytics.google.com</value>
</values>
</policy>
</policies>
</csp_whitelist>
Recent releases enforce a stricter policy on the checkout payment page, so test tracking there specifically. Every tag added in GTM that loads from another host needs its own entry.
Consent
In regions with consent requirements, set Google consent mode defaults before the GTM snippet and update them when the visitor accepts. The consent banner, whether a module or a third party tool, must push the update to the data layer. Test the full flow with consent refused as well as accepted.
Testing
- Use GTM preview mode and walk through a full purchase with a test payment method.
- Check GA4 DebugView for every event and the item parameters.
- Test as a guest and as a logged in customer, and with a configurable product.
- Compare a week of GA4 purchases with admin orders to know your normal gap.
FAQ
Why does GA4 show fewer purchases than Magento?
Ad blockers, refused consent, external payment redirects and customers who close the browser before the success page all prevent the browser event. Server side Measurement Protocol closes most of the gap.
Can I put user specific data layer values in cached pages?
No. Cached HTML is shared between visitors. Load personal values with JavaScript from customer data sections instead of rendering them on the server.
How do I track AJAX add to cart in Luma?
Listen for the ajax:addToCart jQuery event on the document. Luma triggers it after a successful add to cart request, with the SKU and product IDs.
Why are my GTM tags blocked on Magento 2.4?
The Content Security Policy blocks hosts that are not allowed. Add the Google hosts to csp_whitelist.xml in a module and test the checkout pages separately.
I am available for freelance Magento and Adobe Commerce work, including analytics and tracking setups. If you need help with this, get in touch.
Related articles
AI Tools for Magento Development: What Works, What Fails
Using AI coding assistants on Magento 2 projects: where they save time, where they invent code, how to give them context and how to verify their output.
Writing Alpine.js Components in Hyvä: Patterns That Scale
How to write Alpine.js components on Hyvä: passing PHP data, view models, customer data events, form keys, component events, CSP and keeping templates clean.
Hyvä Checkout Explained: Magewire, Customization, Payments
How Hyvä Checkout works for developers: Magewire components, adding a custom field, payment module support, and when to keep the Luma checkout fallback.
Hyvä Compatibility Modules: Make Luma Extensions Work
How Hyvä compatibility modules work: the CompatModuleRegistry, rewriting extension templates with Alpine.js, hyva_ layout handles and the Luma fallback.