Shopify Builds>Three Ships>GA4 ecommerce dataLayer instrumentation across a Rebuy cart

GA4 Ecommerce dataLayer Instrumentation Across a Rebuy Cart

A client-side event layer that hangs off the network rather than off the DOM: a fetch wrapper that recognizes a cart add, a diff against the previous cart so the event reports what changed, and item taxonomy read from a block the product page already renders.

When the cart belongs to an app, there is no theme button to bind an add-to-cart event to. So we wired the events at the network layer instead: a wrapper around fetch that recognizes a cart add, a before-and-after cart diff so the event carries the quantity that changed, and the GA4 item taxonomy recovered from a JSON block the product page already renders. This page describes the wiring, not the reporting.

Fact Strip

  • Client: Three Ships
  • Surfaces: Product detail page, product listing, cart, site-wide
  • Scope: One site-wide script; no template scoping
  • Complexity: High
  • Attribution: Deploi-authored. The event layer is ours. The storefront it runs inside is Palo Alto 5.8.0 by Presidio Creative — a paid premium theme, not our code — and the cart it observes belongs to Rebuy.
  • Status: Live, verified 2026-09-06
  • Evidence: One code artifact plus client working records covering the same tracking thread
  • Platform primitives: 2

The Problem

GA4's ecommerce events assume a page that knows what it's selling. add_to_cart, remove_from_cart and view_cart all want an item array with a brand and a category hierarchy attached — item_brand, item_category1 through item_category4 — and a Shopify cart line carries none of it. What the cart exposes is an id, a handle, a price and a quantity.

The second gap is structural. This storefront's cart is Rebuy's SmartCart, and adds arrive from everywhere: the product page, upsell slots, quick-adds in a grid, bundle tooling. There is no one button in the theme that means "a shopper added something." Binding to a selector would instrument whichever surface you happened to think of on the day you wrote the selector.

The Constraint

No server-side tagging was available, and no product data feed could be read at runtime, so anything the events needed had to be recoverable in the browser from what was already on the page.

The cart's own responses don't help much. A Shopify cart line gives back id, handle, price and quantity, and the taxonomy a merchandiser maintains — brand, category depth — isn't in it. That data had to be found somewhere else on the storefront and joined at event time.

And the cart interface is a vendor's. Rebuy owns the SmartCart's markup and its behavior, and it announces line changes through its own custom events rather than exposing a JavaScript API a theme can call. Whatever the theme wanted to know about a line change, it was going to learn by listening rather than by asking.

What We Built

custom.js installs the layer. It has three parts: an interception point, a taxonomy lookup, and a set of subscriptions.

The interception point. window.fetch is wrapped. Any request whose URL contains /cart/add is passed through to the original implementation, its response cloned, and the added lines compared against a cached /cart.js snapshot taken before the add. The difference between the two is what gets reported — Shopify's add response returns the line's resulting quantity, not the amount just added, so a shopper adding the same variant twice would otherwise produce two events describing the same line differently. Before each ecommerce push, dataLayer is cleared with {ecommerce: null}, which is the reset step GA4's own guidance asks for, and the add_to_cart event goes out after it.

The taxonomy lookup. getProductData() recovers the GA4 item fields by fetching the product's URL, parsing the response with DOMParser, and reading a #product-metafield-JSON script block that product.liquid and main-product.liquid render server-side. The merchandising taxonomy already exists there because the product page needs it; the tracking layer reads the same block rather than keeping a second copy of the catalog in JavaScript.

The subscriptions. Rebuy's own events are the cart-side inputs. rebuy:smartcart.show maps to view_cart, line-item-removed to remove_from_cart, and line-item-increase and line-item-decrease back to the add and remove events. Those are event names the cart vendor publishes, not selectors in its markup, so the subscriptions describe the cart's behavior rather than its DOM — and a restyle of the drawer doesn't move them. Each one carries the same item payload shape the add path builds, so a line looks the same to GA4 whichever cart interaction produced it.

Everything above pushes into window.dataLayer, which is the handoff point to Google Tag Manager. Nothing here talks to GA4 directly.

Why This Way

The decision that matters here is where the instrumentation attaches. Binding to buttons means enumerating every surface that can add to a cart, and on this storefront that list includes markup the theme doesn't own and can't guarantee the shape of. The network request is the one thing every add has in common regardless of which widget issued it, so that's where the layer sits.

The cart diff exists for the same reason the layer is client-side at all: the browser is the only place that knows both what the cart held a moment ago and what it holds now.

The taxonomy decision is the one with a bill attached. Reading #product-metafield-JSON off a rendered page turns that block into an interface — a piece of markup that two systems now depend on, only one of which is the product page. It has to be maintained as a contract rather than tidied as a detail, and anyone editing the product section is editing the event payload whether they know it or not. We took that in exchange for events that carry the merchandiser's own taxonomy instead of a second, drifting copy of it.

Why Not an App

The GA4 app category is large and mostly competent, and on a storefront with a theme-owned cart it would have been the right answer. The reason it isn't here is where the adds happen. Off-the-shelf trackers attach to the theme's markup and to Shopify's own front-end events, and the cart on this storefront is a vendor's application with its own DOM and its own event vocabulary. An installed tracker and the cart it needs to observe would be two apps in one page with no contract between them. Working from the theme meant we could subscribe to the cart vendor's published events directly and put the interception on the request rather than on the interface — and it kept the tag configuration in one container the client already administers, rather than split across a container and an app's settings screen.

Implementation Notes

  • window.fetch is replaced globally with the original captured as origFetch; requests that aren't cart adds are handed straight to it.
  • previousCart is a variant_id-to-quantity map, refreshed from /cart.js after each add, so the next difference is measured against what the cart actually held rather than against a running total.
  • getProductData() fetches the product's own page and parses it with DOMParser to read the #product-metafield-JSON block.
  • Both product section variants — product.liquid and main-product.liquid — emit that block, so the lookup has the same contract whichever section rendered the page.
  • The cart-side events are subscriptions to Rebuy's published event names, not to its markup, so the layer reads the cart's behavior rather than its DOM.

Edge Cases

  • The add-to-cart delta falls back to a quantity of 1 when the computed difference comes out at zero or below.
  • currency falls back to CAD when the first item in the payload doesn't carry one.
  • Each ecommerce push is preceded by dataLayer.push({ecommerce: null}), so an event firing straight after another can't inherit the item array that came before it.
  • A request that isn't a cart add is passed to the original fetch implementation unchanged.

Platform Primitives Used

  • Cart AJAX API — both the thing being observed and the source of truth for the diff: /cart/add is the interception point and /cart.js supplies the before-and-after snapshots.
  • dataLayer — the single output of the whole layer. Every event described here is a push, and everything downstream of the push is tag configuration rather than theme code.

Integrations in Play

  • Rebuy — owns the cart interface; the event layer coexists with it and subscribes to its published cart events rather than reaching into its markup.
  • Google Tag Manager — the consumer of window.dataLayer, and where the events described here become tags.

Where It Runs

One JavaScript file, loaded site-wide from the layout rather than mounted by a template. Its handlers are scoped by what's on the page instead: the fetch wrapper matters wherever an add can happen, and the Rebuy subscriptions apply wherever the cart can open — which on this storefront is every template, because the drawer is site-wide.

What This Demonstrates

Primary: Vendor widget remediation and cross-app cart sync — working with a vendor-owned cart on its own terms, through its published events and the network it uses, instead of around it. It is also an analytics and dataLayer instrumentation build, in the narrow sense: this is how the events are wired.

How We Know

One code artifact, running to roughly 430 lines, plus client working records covering the same tracking thread. What's described above is the event wiring as the code expresses it: which requests are watched, which vendor events are subscribed to, what shape the payload takes, and where it's pushed. It isn't an audit of the resulting reports. We make no claim here about how completely or how accurately any given interaction ends up represented in an analytics property — that depends on tag configuration, container rules and downstream processing that sit outside this file and outside this page.

Related Builds

The Buy-vs-Build Question

Analytics is usually a buy. What makes this one different is that the thing being measured is a third-party application, so the question stops being "which tracker" and becomes "what can observe an app you don't control." That is a theme-code answer on any storefront where the cart is somebody else's. See the checkout extension and theme-app conflict decision.

Wondering What Your Vendor-Owned Cart Is Actually Reporting?

When the cart is an app and the tracker is another app, the gap between them is where events go missing. Contact us today and we'll trace what your storefront is emitting.