Salesforce Commerce Cloud Analytics | OpsBlu Docs

Salesforce Commerce Cloud Analytics

Implement analytics on Salesforce Commerce Cloud (SFCC). Covers SFRA cartridge architecture, ISML template script injection, Business Manager...

Analytics Architecture on Salesforce Commerce Cloud

Salesforce Commerce Cloud (SFCC, formerly Demandware) uses a cartridge-based architecture where functionality is layered through stackable code modules. Analytics tracking integrates through four mechanisms:

  • ISML templates are SFCC's proprietary server-side templates (similar to JSP) that render HTML. Scripts are injected through template includes in the page head and footer
  • SFRA (Storefront Reference Architecture) is the standard cartridge that provides the base storefront. Custom analytics cartridges overlay SFRA templates to add tracking without modifying the base code
  • Business Manager is SFCC's admin interface where site preferences, content slots, and code versions are configured. Analytics IDs (GTM container, GA4 measurement) are typically stored as site preferences
  • dw.analytics is the built-in server-side analytics API that captures page impressions, search events, and basket data for SFCC's native analytics dashboard (Analytics > Reports in Business Manager)

SFCC pages are assembled by controllers (SFRA uses CommonJS modules) that call res.render() with an ISML template name. The template engine resolves includes, loops, and expressions server-side. Page caching is controlled per-template via <iscache> tags. Cached pages bake in all inline scripts, so dynamic data layer values on cached pages must be resolved client-side or use AJAX calls to a non-cached controller endpoint.

For headless SFCC implementations (PWA Kit / Composable Storefront), analytics is handled entirely on the client via SCAPI (Shopper Commerce API) responses and React component lifecycle hooks.


Installing Tracking Scripts

Via SFRA Cartridge Overlay

Create an analytics cartridge that overrides the SFRA htmlHead.isml partial:

<!-- cartridges/app_analytics/cartridge/templates/default/common/htmlHead.isml -->
<isset name="gtmId" value="${dw.system.Site.current.getCustomPreferenceValue('gtmContainerID')}" scope="page" />

<isif condition="${!empty(gtmId)}">
  <!-- 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','${gtmId}');</script>
</isif>

Add the noscript fallback in the body template:

<!-- cartridges/app_analytics/cartridge/templates/default/common/layout/page.isml -->
<isinclude template="common/htmlHead" />
</head>
<body>
  <isset name="gtmId" value="${dw.system.Site.current.getCustomPreferenceValue('gtmContainerID')}" scope="page" />
  <isif condition="${!empty(gtmId)}">
    <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${gtmId}"
    height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  </isif>

  <isreplace/>
</body>

Business Manager Site Preference Setup

Store the GTM container ID as a site preference so it can be changed without code deployment:

  1. In Business Manager, go to Administration > Site Development > System Object Types
  2. Select SitePreferences and add a custom attribute: gtmContainerID (type: String)
  3. Go to Merchant Tools > Site Preferences > Custom Preferences
  4. Enter your GTM container ID (e.g., GTM-XXXXXX)

The ISML templates above read this value with dw.system.Site.current.getCustomPreferenceValue('gtmContainerID').


Data Layer Setup

Server-Side via SFRA Controller

Create a controller that builds the data layer object from SFCC's server-side APIs and exposes it to the template:

// cartridges/app_analytics/cartridge/controllers/AnalyticsData.js
'use strict';

var server = require('server');
var Site = require('dw/system/Site');
var URLUtils = require('dw/web/URLUtils');

server.append('Show', function (req, res, next) {
    var viewData = res.getViewData();
    var currentSite = Site.current;
    var currentCustomer = req.currentCustomer;

    viewData.analyticsData = {
        site_id: currentSite.ID,
        locale: req.locale.id,
        currency: currentSite.defaultCurrency,
        page_url: req.httpURL.toString(),
        customer_authenticated: currentCustomer.authenticated,
        customer_registered: currentCustomer.registered
    };

    res.setViewData(viewData);
    next();
});

module.exports = server.exports();

ISML Template Data Layer Push

In your page template, render the data layer from the controller's view data:

<!-- cartridges/app_analytics/cartridge/templates/default/common/dataLayer.isml -->
<script>
  window.dataLayer = window.dataLayer || [];
  dataLayer.push({
    'site_id': '${pdict.analyticsData.site_id}',
    'locale': '${pdict.analyticsData.locale}',
    'currency': '${pdict.analyticsData.currency}',
    'page_url': '${pdict.analyticsData.page_url}',
    'customer_authenticated': ${pdict.analyticsData.customer_authenticated},
    'customer_registered': ${pdict.analyticsData.customer_registered}
  });
</script>

Product Detail Page Data Layer

On product detail pages, extend the data layer with product-specific fields from the SFCC product API:

// cartridges/app_analytics/cartridge/controllers/Product.js
'use strict';

var server = require('server');
var ProductMgr = require('dw/catalog/ProductMgr');

server.append('Show', function (req, res, next) {
    var viewData = res.getViewData();
    var product = viewData.product;

    if (product) {
        viewData.productAnalytics = {
            item_id: product.id,
            item_name: product.productName,
            item_brand: product.brand,
            item_category: product.primaryCategory
                ? product.primaryCategory.displayName : '',
            price: product.price.sales
                ? product.price.sales.value : product.price.list.value,
            currency: product.price.sales
                ? product.price.sales.currency : product.price.list.currency
        };
    }

    res.setViewData(viewData);
    next();
});

module.exports = server.exports();
<!-- cartridges/app_analytics/cartridge/templates/default/product/productDetails.isml -->
<isif condition="${pdict.productAnalytics}">
  <script>
    window.dataLayer = window.dataLayer || [];
    dataLayer.push({
      'event': 'view_item',
      'ecommerce': {
        'items': [{
          'item_id': '${pdict.productAnalytics.item_id}',
          'item_name': '<isprint value="${pdict.productAnalytics.item_name}" encoding="jshtml" />',
          'item_brand': '<isprint value="${pdict.productAnalytics.item_brand}" encoding="jshtml" />',
          'item_category': '<isprint value="${pdict.productAnalytics.item_category}" encoding="jshtml" />',
          'price': ${pdict.productAnalytics.price},
          'currency': '${pdict.productAnalytics.currency}'
        }]
      }
    });
  </script>
</isif>

Ecommerce Tracking

Purchase Event on Order Confirmation

SFRA's order confirmation controller passes the order object to the template. Extract purchase data in the confirmation ISML:

<!-- cartridges/app_analytics/cartridge/templates/default/checkout/confirmation/confirmation.isml -->
<isif condition="${pdict.order}">
  <script>
    window.dataLayer = window.dataLayer || [];
    dataLayer.push({ 'ecommerce': null }); // Clear previous ecommerce data

    var items = [];
    <isloop items="${pdict.order.items.items}" var="lineItem" status="loopState">
      items.push({
        'item_id': '${lineItem.id}',
        'item_name': '<isprint value="${lineItem.productName}" encoding="jshtml" />',
        'price': ${lineItem.priceTotal.price},
        'quantity': ${lineItem.quantity}
      });
    </isloop>

    dataLayer.push({
      'event': 'purchase',
      'ecommerce': {
        'transaction_id': '${pdict.order.orderNumber}',
        'value': ${pdict.order.totals.grandTotal.replace(/[^0-9.]/g, '')},
        'currency': '${pdict.order.currencyCode}',
        'shipping': ${pdict.order.totals.totalShippingCost.replace(/[^0-9.]/g, '')},
        'tax': ${pdict.order.totals.totalTax.replace(/[^0-9.]/g, '')},
        'items': items
      }
    });
  </script>
</isif>

Add-to-Cart via Client-Side AJAX Hook

SFRA handles add-to-cart via AJAX. Hook into the cart update response:

// cartridges/app_analytics/cartridge/client/default/js/analyticsEvents.js
'use strict';

$(document).on('product:afterAddToCart', function (e, data) {
    if (!data || !data.cart) return;

    var addedProduct = data.cart.items[data.cart.items.length - 1];

    window.dataLayer = window.dataLayer || [];
    dataLayer.push({ 'ecommerce': null });
    dataLayer.push({
        'event': 'add_to_cart',
        'ecommerce': {
            'items': [{
                'item_id': addedProduct.id,
                'item_name': addedProduct.productName,
                'price': parseFloat(addedProduct.priceTotal.price),
                'quantity': addedProduct.quantity
            }]
        }
    });
});

$(document).on('cart:afterRemoveItem', function (e, data) {
    window.dataLayer = window.dataLayer || [];
    dataLayer.push({ 'ecommerce': null });
    dataLayer.push({
        'event': 'remove_from_cart',
        'ecommerce': {
            'items': [{
                'item_id': data.removedItem.id,
                'item_name': data.removedItem.productName,
                'price': parseFloat(data.removedItem.price),
                'quantity': data.removedItem.quantity
            }]
        }
    });
});

Include this script in SFRA's client-side bundle by requiring it in the main entry file or adding it to the webpack configuration of your analytics cartridge.


Common Errors

Error Cause Fix
ISML expression outputs null Product or order field is undefined in the view data Wrap the data layer block in <isif condition="${pdict.fieldName}"> to check existence before rendering
Scripts missing after code version switch Business Manager active code version does not include the analytics cartridge Go to Administration > Code Deployment and verify the analytics cartridge is in the active code version's cartridge path
Data layer values HTML-encoded ISML auto-escapes output by default Use <isprint encoding="jshtml" /> for values inside JavaScript strings, or encoding="off" for trusted JSON output
GTM fires before data layer ready GTM snippet placed before the data layer push in the ISML template include order Ensure dataLayer.isml is included before htmlHead.isml in the page template, or use a GTM trigger that waits for a custom event
Purchase event fires on page reload Order confirmation page is not a one-time view Set a session attribute (session.custom.purchaseTracked) after the first push and check it before rendering the purchase script
Cartridge path order wrong Analytics cartridge listed after app_storefront_base in the cartridge path, so SFRA templates override the analytics overlays Move the analytics cartridge before app_storefront_base in Business Manager > Administration > Sites > Manage Sites > Cartridge Path
dw.analytics page impressions missing Built-in analytics disabled or the dw.analytics.track() call is not present in the controller Verify that dw.system.Site.current.getCustomPreferenceValue('analyticsEnabled') is true and SFRA's base Page-Show controller calls dw.analytics
Price values include currency symbols ISML ${pdict.order.totals.grandTotal} returns formatted string like $125.00 Use .replace(/[^0-9.]/g, '') in JavaScript or access the raw numeric value from the order model before formatting
Staging sandbox tracks production GA4 Same GTM container deployed to sandbox and production instances Use GTM environments with separate container snippets per SFCC instance, or use a site preference that differs per instance
Client-side events not firing Analytics JS file not included in the webpack build Add the analytics module to the cartridge's webpack.config.js entry points and run npm run build

Performance Considerations

  • ISML page caching: Use <iscache type="relative" hour="1" /> on non-personalized pages but exclude it from templates that contain dynamic data layer values (cart contents, user state). Alternatively, cache the page shell and load the data layer via an AJAX call to an uncached controller
  • Cartridge stack depth: Each cartridge overlay in the path adds a template resolution lookup. Keep the analytics cartridge as a single overlay rather than splitting across multiple cartridges
  • Client-side bundle size: SFRA's webpack build compiles all client JS into bundles. The analytics event listeners add minimal overhead (under 2KB gzipped), but avoid importing large analytics SDKs synchronously
  • dw.analytics overhead: SFCC's built-in analytics pipeline processes on every page request server-side. If you rely entirely on GA4/GTM and do not use Business Manager analytics reports, disable dw.analytics tracking to reduce server-side processing time
  • Minimize inline script size: SFCC counts inline script bytes toward the page weight budget. Keep data layer pushes under 1KB per page by only including fields you actually reference in GTM triggers and variables
  • Use OCAPI/SCAPI for headless: In PWA Kit (Composable Storefront) implementations, all analytics happens client-side. Fetch product and order data from SCAPI endpoints and push to the data layer in React useEffect hooks rather than server-rendering inline scripts