← Back to blog

Magento 2 GA4 and GTM Ecommerce Tracking That Adds Up

Rodrigo Biazzi

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 eventWhere in MagentoSource of data
view_item_listCategory and search pagesServer side, in cached HTML
view_itemProduct pageServer side, in cached HTML
add_to_cartProduct page, listingsJavaScript, after AJAX success
remove_from_cartMinicart, cart pageJavaScript or cart page reload
begin_checkoutCheckout page loadCart data from customer data
add_shipping_infoShipping step submitCheckout JavaScript
add_payment_infoPlace order clickCheckout JavaScript
purchaseSuccess pageServer 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

  1. Use GTM preview mode and walk through a full purchase with a test payment method.
  2. Check GA4 DebugView for every event and the item parameters.
  3. Test as a guest and as a logged in customer, and with a configurable product.
  4. 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