Engineering stories · 06
Instrumenting Shopify across the theme and checkout boundary
- Web Pixels
- Amplitude
Three destination pipelines and one vendor-neutral event bus: measuring the purchase journey through a checkout that theme code cannot reach, with minimized personal data and an honest account of what is verified, what is configured, and what belongs to the store owner.
Shopify OS 2.0 · Web Pixels / Customer Events · Amplitude · Event bus (lum:analytics) · Data minimization
Every other decision on this storefront was judged against data: which pack shoppers pick, where the buy box loses them, whether a layout shift the lab cannot see is costing anything. The analytics were the instrument the rest was tuned against. This is how I instrumented the purchase journey through a checkout the theme is locked out of, kept the event streams isolated and the personal data minimal, and stayed precise about the line between engineering decisions and the privacy decisions that were not mine to make.
The boundary that shapes the architecture
Shopify's checkout is a closed, sandboxed surface. checkout.liquid is deprecated, and theme JavaScript does not run on the checkout steps. That single fact splits the funnel in two. On-site behavior (product views, pack selection, add-to-cart, cart edits) is reachable from theme code. Everything from checkout_started through checkout_completed is not.
Track only the theme and you measure up to the cart, then go dark where money changes hands, unable to tie on-site behavior to completed orders. Reaching across the boundary needs a second mechanism that runs where the theme cannot: a Shopify Web Pixel. On-site and checkout are two runtimes with two tools, and the job was to make them read as one purchase journey without pretending the boundary is not there.
The architecture: three pipelines and an internal bus
I split the instrumentation into three destination pipelines, each with a single owner and a single job, plus one internal event bus that is not a destination at all.
| Pipeline | Source | Destination | Owner |
|---|---|---|---|
| Real-user performance (RUM) | theme snippet on custom PDPs | a private Google Sheet | performance work (see deep dive 03) |
| On-site product events | theme snippets on PDPs and landings | Amplitude | this article |
| Checkout events | a Web Pixel in the Shopify admin | Amplitude | this article |
lum:analytics (not a pipeline) | every on-site event | an in-page DOM event | this article |
The isolation is deliberate. RUM writes to its own Sheet, so performance telemetry never mixes into the product-event stream or marketing's reporting. The on-site and checkout pipelines stay clear of purchase and conversion logic, so the theme's instrumentation is never a suspect when the marketing team debugs their own Purchase numbers. Three systems is more surface area than one script, traded for a blast radius of one each.
RUM is covered in deep dive 03; here it is pipeline one, isolated from the other two by design.
On-site product events, and the bus underneath them
The on-site layer is two passive, listener-only trackers that observe the buy box and never touch its purchase logic. Both gate by product or page handle, not template name, because the content team reshuffles products across templates and handles stay put, so adding a product or landing is a one-line change.
| Surface | Events (examples) | Notes |
|---|---|---|
| PDP | page_view, bundle_selected, purchase_type_selected, add_to_cart, gallery / tabs / FAQ / cross-sell / cart interactions, footer subscribe | ~18 events, handle-gated, product_id + page_title on every one |
| Advertorial landing | page_view, cta_button_clicked (with placement) | page-handle-gated |
The lum:analytics seam: an internal event bus, not a fourth pipeline
This is the one piece worth naming precisely, because it is easy to over-sell. It is not a destination and not a pipeline. It is an internal, vendor-neutral event bus inside the page. Every on-site event is dispatched as a DOM CustomEvent before any vendor SDK is called:
function track(name, props) {
var payload = Object.assign({ product_id: PRODUCT_ID, page_title: document.title }, props || {});
document.dispatchEvent(new CustomEvent('lum:analytics', { detail: { event: name, properties: payload } }));
var fn = ampFn();
if (fn) fn(name, payload); else queue.push([name, payload]);
}
Today the only consumer is the Amplitude adapter. The point is portability, not a second output: a future collector subscribes to document's lum:analytics event and inherits the whole event map without any change to the trackers. Amplitude is one consumer of the bus, not the bus itself. The SDK is app-injected and can load late, so events queue and drain once it appears, up to forty seconds before giving up.
Checkout: a Web Pixel, not theme code
Step-level checkout instrumentation cannot be theme code, so it lives in a Web Pixel under Admin, Settings, Customer events, a built-in Shopify feature, not an app. The consequence that sometimes reads as an omission is correct: there is no Web Pixel file in the theme repository, because it belongs in the admin.
Inside the pixel's sandbox there is no window.amplitude to call, so the pixel POSTs directly to Amplitude's HTTP API with fetch. It sends payment_info_submitted and checkout_completed, but not checkout_started, which the theme already emits and duplicating would inflate.
Anonymous continuity, attempted, not guaranteed. To keep checkout events tied to the same anonymous visitor as the on-site events, the pixel reads the Amplitude device id from its cookie and reuses it. This is best-effort, not a guarantee. If the cookie is absent, cleared, blocked by an ad blocker or privacy control, or partitioned across contexts, checkout events fall back to a separate anonymous identity and the join breaks. I did not validate end-to-end identity continuity or event completeness, so this is an attempt at continuity, not stitched sessions.
The consent-configuration incident, described exactly
The two pipelines started with inconsistent privacy configuration, and that inconsistency, not a bug, produced the asymmetry the analyst noticed.
The theme's Amplitude tracker sent its events unconditionally. The checkout Web Pixel was configured with Permission = Required (Analytics), so Shopify withheld the pixel's events until a visitor granted analytics consent. The visible symptom was asymmetric data: for the same sessions, on-site events arrived and checkout events did not.
I flagged this as a privacy and policy question rather than an engineering one. The theme was collecting analytics with no consent gate while the pixel waited for consent, and deciding how to reconcile the two belonged to the store owner, not to me. The configuration was then changed in the admin, setting the pixel's Permission to Not required.
I want to state the effect narrowly, because this is exactly the kind of claim that is easy to overreach on. With Not required, Shopify delivers checkout events to the pixel regardless of the consent signal, so the pixel now behaves like the on-site tracker, and checkout events began arriving. That is the observed effect. I am not claiming this configuration respects consent or satisfies any specific regulation. It aligned the two pipelines by making both collect regardless of consent. Whether collecting regardless of consent is appropriate depends on the store's privacy policy and lawful basis, which is the owner's decision and was made as an admin setting, not in code. What I changed was the event code and its shape; the consent toggle was owner-controlled.
PII minimization: a completion fact, not a value
One data-minimization decision was mine to make. The original event map asked the checkout pixel to send the customer's first-name value, which is more personal data than the measurement needs. So I reduced it to a fact: the event records that the contact field was completed, with no name attached. The on-site trackers hold the same line and carry no names or emails. The funnel can show that a shopper reached and filled the contact step without recording who they are. That is data minimization, narrower and more defensible than a compliance claim.
The remediation pass
After the trackers shipped, the analyst reported that a batch of on-site events was not firing. The cause was one disease, not many bugs: the event map had been written against generic Dawn selectors and one-shot querySelector calls at script time, and this store's DOM is heavily custom, so the generic hooks missed the real elements and the one-shot lookups ran before those elements existed.
| Element | Why it failed | Fix |
|---|---|---|
| Header (nav / logo / cart) | pointed at generic Dawn selectors absent on the custom header | repoint to the custom lhead selectors |
| Footer subscribe | Klaviyo injects the form client-side; no native <form> to bind | hook the klaviyoForms submit event |
| Reviews carousel | transform-based Swiper; a scrollLeft listener never fires | hook Swiper's slideChange, with a MutationObserver fallback |
| Gallery index | tracker runs before the gallery parses; a cached null read index 0 | re-query the track on every call |
Each shipped as an isolated PR. It is the same lesson the cart work taught elsewhere on this store: on a custom theme, a document-wide query written for the stock theme is a latent failure, and a lookup that runs before its DOM exists is a guaranteed one.
What I built, diagnosed, reviewed, and recommended
The honest boundary is itself part of the work, so I keep it sharp.
Built: the two on-site trackers, the lum:analytics bus, the checkout Web Pixel, the remediation fixes, and the first-name PII reduction.
Diagnosed, not built: the Purchase duplication. GA4 and the social pixels are marketing-owned; I did not set them up or modify them. The team was chasing intermittent duplicate Purchase events, and I confirmed the theme fires none (every Purchase-adjacent string in it is UI text). The leading hypothesis, handed over rather than proved, is two independent Purchase events without a shared event_id, most likely a third-party pixel app firing alongside the native Facebook channel, or that app plus a custom pixel, with server-side events bypassing Shopify so nothing deduplicates them. I did not confirm the exact senders. I left a diagnostic path: Meta Test Events comparing Browser versus Server and the deduplicated percentage, then the integration list, then keeping one Purchase sender.
Reviewed, not built: the cookie banner. A consent banner showed outside the region I expected. I confirmed it is Shopify's own native privacy banner (consent-tracking-api, privacy-banner, customerPrivacy), not a theme element or a third-party app. The warning was the point: do not code-hack it away, because it drives Shopify's consent mode, which gates when the marketing pixels and GA are allowed to fire. Removing it to tidy the UI could break the marketing team's collection. Reviewed and explained, not touched.
Reviewed and recommended: Klaviyo add-to-cart tracking. A junior proposed globally overriding window.fetch to catch /cart/add for an abandoned-cart flow. I advised against it: a global fetch override is the same fragile-global pattern that had caused three separate cart bugs here. I recommended the native Shopify product_added_to_cart Customer Event instead, which covers every add point with no theme code, plus one requirement: exclude the free gift, so abandoned-cart emails never advertise a $0 item.
Limitations
This instrumentation is built for directional funnel measurement, not billing-grade accuracy, and it carries real limits worth naming.
- Device-id continuity depends on the Amplitude cookie. Ad blockers, browser privacy controls, cookie clearing, and storage partitioning can break the on-site to checkout join, splitting one visitor into two anonymous identities.
- The on-site SDK is app-injected and can load late. Events queue and a flusher drains them, but anything still queued after roughly forty seconds is dropped.
- The checkout runs in an opaque sandbox I cannot inspect at runtime; the pixel relies on the standard Customer Events surface Shopify exposes.
- What the pixel collects depends on the admin consent-permission setting, which is an owner-controlled configuration the code does not enforce.
- None of this guarantees complete or exact attribution. Event completeness and identity continuity were not validated end to end.
Outcome
What is verified is the engineering, not a business metric. The on-site event map fires across PDPs and landings after the remediation pass, and the checkout pixel posts step-level events, carries no customer names, and reuses the Amplitude device id as a best-effort join. The three pipelines run independently, so the theme's instrumentation was never a suspect in the Purchase-duplication investigation.
I would not call the result "trustworthy data" in any absolute sense. It is something narrower and more honest: explicitly scoped, isolated event streams with minimized personal data, measuring the purchase journey through checkout, with the consent posture set as an owner-controlled admin configuration rather than asserted in code. The store's conversion and revenue figures belong to the brand and sit under NDA, so here I keep to the engineering.
Lessons
- Isolation is an analytics strategy, not just a code smell. Three pipelines with three failure domains kept a broken product event away from performance telemetry and the theme out of marketing's debugging, and a
lum:analyticsbus turned Amplitude into a swappable consumer for one line per event. - Say where the platform boundary is, and where your authority ends. Checkout being a sandbox is why the pixel lives in the admin. Consent configuration being a privacy decision is why I flagged it and did not assert compliance.
- Minimize the personal data, and describe the limits. A completion fact instead of a name, and a limitations section instead of a "GDPR-compliant" badge, are the more credible position.
Technical references
snippets/lum-amplitude-events.liquid: the PDP event map (~18 events), handle gating, thelum:analyticsbus, and the remediation hooks (lheadheader, KlaviyoklaviyoForms, SwiperslideChange, re-queried gallery index).snippets/lum-amplitude-events-landing.liquid: the advertorial event map (page_view,cta_button_clickedwith placement), page-handle gating.- Shopify Admin, Settings, Customer events: the checkout Web Pixel (correctly absent from the theme repo), the
Permissionsetting (owner-controlled), and the first-name field reduced to a completion fact. snippets/pdp-web-vitals.liquid: the RUM pipeline, owned by deep dive 03.
Need a Shopify engineer who can work across the full commerce path?
I work where storefront UX, product logic, platform constraints, measurement, and production delivery meet.
Start a conversation