Analytics Architecture on Magento
Magento 2 has a frontend architecture that actively works against analytics implementations. Understanding these subsystems before writing tracking code saves significant debugging time.
Layout XML system. Scripts are declared in XML files that Magento merges and renders, not added to templates directly. The primary file for global injection is default.xml in view/frontend/layout/. Magento processes default first, then route-specific handles like catalog_product_view or checkout_onepage_success. A script in the wrong handle won't appear on the page you expect.
RequireJS module system. Every frontend script must be a RequireJS module or loaded through one. Analytics libraries depending on a global dataLayer must ensure initialization before the GTM container loads.
Knockout.js on checkout. The entire checkout is a Knockout.js SPA. The URL does not change between steps. Standard DOM-based listeners do not work -- tracking code must hook into Knockout observables.
Customer sections. Cart contents, customer name, and other personalized data load via AJAX to /customer/section/load/ after render. Tracking scripts reading cart data from HTML get empty or stale cached data.
Full Page Cache. Both built-in FPC and Varnish serve identical cached HTML to every visitor. Analytics scripts scraping prices or stock data from cached HTML report incorrect values.
Installing Tracking Scripts
Via Layout XML
Add global scripts through Layout XML. In default.xml:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
src_type="url" async="true"/>
</head>
<body>
<referenceContainer name="before.body.end">
<block class="Magento\Framework\View\Element\Template"
name="analytics.gtag.init"
template="Vendor_Module::gtag-init.phtml"/>
</referenceContainer>
</body>
</page>
The PHTML template at view/frontend/templates/gtag-init.phtml:
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '<?= $block->escapeJs($block->getGtagId()) ?>');
</script>
The src_type="url" attribute tells Magento this is an external URL, not a local RequireJS module. Without it, Magento attempts RequireJS resolution and fails silently.
Via RequireJS
For code interacting with Magento's JS components, use a RequireJS module. Register in requirejs-config.js:
// view/frontend/requirejs-config.js
var config = {
map: {
'*': {
'analyticsTracker': 'Vendor_Analytics/js/tracker'
}
},
config: {
mixins: {
'Magento_Checkout/js/action/place-order': {
'Vendor_Analytics/js/place-order-mixin': true
}
}
}
};
Then the tracker module:
// view/frontend/web/js/tracker.js
define([
'jquery',
'Magento_Customer/js/customer-data',
'domReady!'
], function ($, customerData) {
'use strict';
return function (config) {
var cart = customerData.get('cart');
cart.subscribe(function (cartData) {
if (cartData && cartData.items && cartData.items.length) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'cart_updated',
'cart_total': cartData.subtotalAmount,
'cart_item_count': cartData.summary_count
});
}
});
};
});
Via Admin Configuration
Magento 2 includes built-in Google Analytics at Stores > Configuration > Sales > Google API. Before 2.4.5, this supports Universal Analytics only; 2.4.5+ adds GA4 via magento/module-google-gtag. The built-in integration handles basic pageviews and limited ecommerce events but not custom dimensions or the full GA4 schema.
Google Tag Manager on Magento
Built-in GTM Support (Adobe Commerce)
Adobe Commerce includes native GTM at Stores > Configuration > Sales > Google API > Google Tag Manager. It pushes productClick, addToCart, removeFromCart, checkout, and purchase using the older Enhanced Ecommerce (UA) schema, not GA4. For GA4, transform the data in GTM or replace the built-in integration.
Manual GTM Container Installation
For Magento Open Source, install GTM manually with both the <script> in <head> and <noscript> after <body>:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<block class="Magento\Framework\View\Element\Template"
name="gtm.head.script"
template="Vendor_Gtm::head.phtml"/>
</head>
<body>
<referenceContainer name="after.body.start">
<block class="Magento\Framework\View\Element\Template"
name="gtm.body.noscript"
template="Vendor_Gtm::noscript.phtml"
before="-"/>
</referenceContainer>
</body>
</page>
The head.phtml template:
<!-- Google Tag Manager -->
<script>
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','<?= $block->escapeJs($block->getContainerId()) ?>');
</script>
<!-- End Google Tag Manager -->
GTM Data Layer Extensions
Extensions like Mageplaza GTM, Amasty GTM, or Weltpixel GTM add full GA4-schema events (view_item_list, select_item, add_to_cart, begin_checkout, purchase). Evaluate on three criteria: GA4-schema events (not just UA Enhanced Ecommerce), correct FPC handling (customer sections, not cached HTML), and checkout extension compatibility.
Ecommerce Data Layer
Product Page Data
Push view_item on product detail pages. The ecommerce: null push prevents data bleed from previous events:
window.dataLayer = window.dataLayer || [];
dataLayer.push({ ecommerce: null });
dataLayer.push({
'event': 'view_item',
'ecommerce': {
'currency': 'USD',
'value': 49.99,
'items': [{
'item_id': 'MAG-SKU-001',
'item_name': 'Wireless Headphones',
'item_brand': 'AudioCo',
'item_category': 'Electronics',
'item_category2': 'Audio',
'item_variant': 'Black',
'price': 49.99,
'quantity': 1
}]
}
});
Checkout Tracking
Magento 2 checkout is a Knockout.js SPA. Use mixins to intercept actions at the model level. Track step progression via the step navigator:
// view/frontend/web/js/step-navigator-mixin.js
define(['mage/utils/wrapper'], function (wrapper) {
'use strict';
return function (stepNavigator) {
stepNavigator.navigateTo = wrapper.wrap(
stepNavigator.navigateTo,
function (originalFn, code, scrollToElement) {
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'checkout_step',
'checkout_step_name': code
});
return originalFn(code, scrollToElement);
}
);
return stepNavigator;
};
});
Register in requirejs-config.js:
var config = {
config: {
mixins: {
'Magento_Checkout/js/model/step-navigator': {
'Vendor_Analytics/js/step-navigator-mixin': true
}
}
}
};
Order Success Page
Build a Block class to retrieve order data from the checkout session:
<?php
namespace Vendor\Analytics\Block;
use Magento\Framework\View\Element\Template;
use Magento\Checkout\Model\Session as CheckoutSession;
class OrderSuccess extends Template
{
private CheckoutSession $checkoutSession;
public function __construct(
Template\Context $context,
CheckoutSession $checkoutSession,
array $data = []
) {
parent::__construct($context, $data);
$this->checkoutSession = $checkoutSession;
}
public function getOrderData(): ?array
{
$order = $this->checkoutSession->getLastRealOrder();
if (!$order->getId()) {
return null;
}
$items = [];
foreach ($order->getAllVisibleItems() as $item) {
$items[] = [
'item_id' => $item->getSku(),
'item_name' => $item->getName(),
'price' => (float) $item->getPrice(),
'quantity' => (int) $item->getQtyOrdered(),
'discount' => abs((float) $item->getDiscountAmount()),
];
}
return [
'transaction_id' => $order->getIncrementId(),
'value' => (float) $order->getGrandTotal(),
'currency' => $order->getOrderCurrencyCode(),
'tax' => (float) $order->getTaxAmount(),
'shipping' => (float) $order->getShippingAmount(),
'coupon' => $order->getCouponCode() ?: '',
'items' => $items,
];
}
}
The PHTML template (JSON_HEX_TAG | JSON_HEX_APOS prevents XSS via malicious product names):
<?php
$orderData = $block->getOrderData();
if ($orderData): ?>
<script>
window.dataLayer = window.dataLayer || [];
dataLayer.push({ ecommerce: null });
dataLayer.push({
'event': 'purchase',
'ecommerce': <?= json_encode($orderData, JSON_HEX_TAG | JSON_HEX_APOS) ?>
});
</script>
<?php endif; ?>
Full Page Cache and Tracking
This is the number one analytics issue on Magento 2. When FPC is enabled, the server returns identical cached HTML to every visitor. Personalized data loads separately via customer sections AJAX. Use the customer-data library:
require([
'Magento_Customer/js/customer-data'
], function (customerData) {
'use strict';
var cart = customerData.get('cart');
cart.subscribe(function (data) {
if (!data || !data.items || !data.items.length) {
return;
}
var items = data.items.map(function (item) {
return {
'item_id': item.product_sku,
'item_name': item.product_name,
'price': parseFloat(item.product_price_value),
'quantity': item.qty
};
});
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'cart_view',
'ecommerce': {
'value': parseFloat(data.subtotalAmount),
'items': items
}
});
});
});
This fires on initial load when section data is fetched and on every subsequent update, reading from the AJAX response regardless of FPC status. Non-personalized product data (SKU, name, base price) can live in cached HTML, but tier pricing and customer-group pricing must go through sections.
Configurable and Grouped Product Tracking
Configurable products have a parent SKU that differs from the selected simple variant. Fire view_item with the parent on load, then track option selections:
define(['jquery', 'domReady!'], function ($) {
'use strict';
$(document).on('change', '.swatch-option, .super-attribute-select', function () {
var config = $('.product-info-main [data-role="priceBox"]')
.data('mageConfigurable');
if (!config || !config.simpleProduct) return;
var selected = config.options[config.simpleProduct];
window.dataLayer = window.dataLayer || [];
dataLayer.push({
'event': 'select_item_variant',
'ecommerce': {
'items': [{
'item_id': selected.sku,
'item_name': selected.name,
'item_variant': selected.label,
'price': parseFloat(selected.price)
}]
}
});
});
});
For grouped products, include all child products with their quantities in add_to_cart, not the grouped parent. Always report the simple product SKU in item_id (the one on invoices) and the configurable parent's name in item_name (what the customer sees).
Common Errors
| Error | Cause | Fix |
|---|---|---|
RequireJS: Script error for: gtm-module |
RequireJS cannot resolve the module path | Verify the module name in requirejs-config.js maps to an existing file in web/js/. Check for typos in map or paths. |
Uncaught TypeError: Cannot read property 'getItems' of undefined |
Checkout quote data read before Knockout bindings initialize | Use quote.getItems.subscribe(function(items) { ... }) instead of calling quote.getItems() directly. |
analytics/analytics.xml: Element 'provider', attribute 'name': not allowed |
Malformed analytics XML in a custom or third-party module | Validate against urn:magento:module:Magento_Analytics:etc/analytics.xsd. Remove attributes not in the schema. |
Class Magento\GoogleGtag\Model\Config does not exist |
GoogleGtag module not installed (requires Magento 2.4.5+) | Run composer require magento/module-google-gtag then bin/magento setup:upgrade. |
GTM container not rendering on frontend pages |
Layout XML handle not applied or module disabled | Run bin/magento module:status Vendor_Gtm. Verify layout file is at view/frontend/layout/default.xml. Flush cache: bin/magento cache:flush layout. |
Duplicate pageview events in GA4 real-time report |
Both built-in GA and GTM GA4 tag are active | Disable built-in: Stores > Config > Sales > Google API > Enable = No. Only one pageview source should fire. |
Order data missing on success page for some transactions |
FPC caching the success page, serving stale order data | Add cacheable="false" to the layout XML for checkout_onepage_success, or move order data to a customer section. |
product.getFinalPrice() returns 0 for configurable products |
Configurable parents carry no price; pricing comes from the selected simple | Use product.getMinimalPrice() or get the price from the selected child via getTypeInstance()->getUsedProducts(). |
setup:di:compile fails after analytics extension install |
DI compilation conflict with another module | Run bin/magento setup:di:compile --verbose to identify the class. Check etc/di.xml for duplicate <preference> or <type> entries. |
dataLayer is undefined in GTM preview mode |
The dataLayer init script loads after the GTM container | Move `window.dataLayer = window.dataLayer |
Magento Cloud vs On-Premise Differences
Adobe Commerce Cloud replaces Varnish with Fastly CDN, using VCL snippets and API calls for cache invalidation. Analytics implementations relying on X-Magento-Cache-Debug headers need adjustment. Use .magento.env.yaml for per-environment analytics IDs:
stage:
deploy:
ANALYTICS_GTM_ID: "GTM-XXXXXXX"
global:
SCD_ON_DEMAND: true
On-premise installations can use server-side Measurement Protocol calls from cron jobs processing orders, refunds, or subscriptions -- valuable for backend events like partial refunds that never touch the frontend.
Performance Impact
Magento 2's Luma theme loads over 200 RequireJS modules per product page. Every analytics extension adds weight. Compare Lighthouse scores with and without analytics modules to measure impact. RequireJS bundling can help, but misconfigured bundling creates oversized bundles that block rendering.
Hyva Theme Compatibility
Sites using Hyva replace RequireJS and Knockout.js with Alpine.js and vanilla JavaScript. Most Luma-based analytics extensions break on Hyva. Check extension compatibility before purchasing.
Content Security Policy Whitelisting
Magento 2.3.5+ enforces CSP headers. Analytics domains must be whitelisted in 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">www.googletagmanager.com</value>
<value id="ga4" type="host">www.google-analytics.com</value>
</values>
</policy>
<policy id="connect-src">
<values>
<value id="ga4-connect" type="host">www.google-analytics.com</value>
<value id="ga4-region" type="host">region1.google-analytics.com</value>
</values>
</policy>
</policies>
</csp_whitelist>
Both script-src and connect-src are required. Missing connect-src is a common cause of "events fire in GTM preview but never reach GA4" -- connect-src covers the fetch/sendBeacon calls GA4 uses to transmit data.