Architecture4 min read

Engineering Owns What Happened, GTM Owns Where It Goes: A Tracking Architecture That Scales

Most analytics problems aren't tool problems, they're ownership problems. Here's the principle I now use for front-end tracking, and the thin tracking module with adapters that makes it work today with GTM and tomorrow with Segment.

Gopal Yendluri
Series: Analytics Tracking Done Properly · Part 1 of 2
  1. Engineering Owns What Happened, GTM Owns Where It Goes: A Tracking Architecture That Scales
  2. GTM vs Segment: Tag Manager or Customer Data Pipeline (and Why Many Teams Run Both)
Contents
  1. A Small Request That Revealed a Big Question
  2. The Principle: Facts vs Routing
  3. The Problem With dataLayer.push Everywhere
  4. A Thin Tracking Module With Adapters
  5. The Rules That Make It Hold
  6. The GTM Side
  7. Rolling It Out
  8. The Takeaway

A Small Request That Revealed a Big Question

A typical request from a marketing team: "Please stop firing the purchase_sub event on the order confirmation page. It's duplicating conversions in our reports."

The context: our subscription funnel pushes a family of *_sub events (details_sub, payment_sub, purchase_sub) that the funnel analysis depends on. The confirmation page also pushes a separate purchase event with flow: 'sub', which feeds GA4 and ad-platform conversions. Two events, two audiences, two payloads.

The quick fix is to delete one line of code. The right fix is to ask a more useful question: who should decide where an event goes?

The Principle: Facts vs Routing

The position we agreed with marketing is simple:

  • Engineering owns what happened. The application publishes accurate, well-structured facts about user behaviour into the data layer.
  • Google Tag Manager owns where it goes. Routing, filtering and which tools receive which events are configured in GTM, where marketing can change them without a release.

So the app keeps pushing both events. The marketing request is handled in GTM: conversion tags trigger only on purchase where flow exactly equals sub; the funnel tag triggers on purchase_sub; and blocking triggers stop purchase_sub reaching marketing tags. Nobody's reports break, and the next time marketing wants a change, it doesn't need a sprint.

The corollary: send as much useful, accurate data as possible, and filter downstream. Removing events in the app to change what a report shows couples your code to someone else's dashboard.

The Problem With dataLayer.push Everywhere

The principle only holds if the data layer is trustworthy. In most front ends I've seen, it isn't, because tracking grows organically:

  • window.dataLayer.push(...) calls scattered across dozens of components.
  • Slightly different payload shapes for the same event in different places.
  • GTM's data layer merges state between pushes, so a field from an earlier event silently leaks into a later one.
  • Nobody can answer "which events do we send, when, and with what properties?" without reading the code.
  • Moving to another tool (a CDP such as Segment, for example) means touching every component.

The fix is an old one: put an abstraction between the application and the vendor.

A Thin Tracking Module With Adapters

Components never talk to the data layer directly. They call a small module with three verbs, borrowed from the Segment spec because they're vendor-neutral and well understood:

// lib/tracking/index.ts
export type Properties = Record<string, unknown>;
 
export interface TrackingAdapter {
  track(event: string, properties: Properties): void;
  identify(userId: string, traits: Properties): void;
  page(properties: Properties): void;
}
 
const adapters: TrackingAdapter[] = [];
 
export function registerAdapter(adapter: TrackingAdapter) {
  adapters.push(adapter);
}
 
export function track(event: string, properties: Properties = {}) {
  adapters.forEach((a) => a.track(event, properties));
}
 
export function identify(userId: string, traits: Properties = {}) {
  adapters.forEach((a) => a.identify(userId, traits));
}
 
export function page(properties: Properties = {}) {
  adapters.forEach((a) => a.page(properties));
}

Supporting several adapters at once is deliberate: during a migration you can run the data layer and a new pipeline in parallel and compare the results.

The data layer adapter

All GTM- and GA4-specific structure lives in exactly one place:

// lib/tracking/adapters/dataLayer.ts
import type { TrackingAdapter, Properties } from "../index";
 
declare global {
  interface Window { dataLayer: Record<string, unknown>[] }
}
 
const ECOMMERCE_EVENTS = new Set(["purchase", "begin_checkout", "add_payment_info"]);
 
function push(payload: Record<string, unknown>) {
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push(payload);
}
 
export const dataLayerAdapter: TrackingAdapter = {
  track(event, properties) {
    const { ecommerce, ...rest } = properties as { ecommerce?: Properties } & Properties;
    if (ECOMMERCE_EVENTS.has(event)) {
      push({ ecommerce: null }); // clear the previous ecommerce object
    }
    push({ event, ...rest, ...(ecommerce ? { ecommerce } : {}) });
  },
  identify(userId, traits) {
    push({ event: "identify", user_id: userId, ...traits });
  },
  page(properties) {
    push({ event: "page_view", ...properties });
  },
};

Pushing { ecommerce: null } before each ecommerce event is Google's own recommendation, and it prevents items from a previous event being merged into the next one.

Guarding the purchase event

Confirmation pages get refreshed, bookmarked and revisited. Each visit can fire the purchase event again and inflate revenue. A small guard fixes that:

export function trackPurchaseOnce(orderId: string, fire: () => void) {
  const key = `tracked_purchase_${orderId}`;
  try {
    if (sessionStorage.getItem(key)) return;
    sessionStorage.setItem(key, "1");
  } catch {
    // storage unavailable: fall through and fire once for this page view
  }
  fire();
}

Always include transaction_id as well. GA4 uses it to de-duplicate purchases, which covers cases the session guard can't (a revisit in a new tab days later, for example).

The Rules That Make It Hold

  1. No direct dataLayer.push from components. Enforce it with a lint rule.
  2. Neutral, self-contained payloads. Each event carries its full context. Don't rely on GTM's state merging; vendor-specific shapes live only in the adapter.
  3. Revenue lives in one event. Only purchase carries value and the ecommerce object. Funnel events like purchase_sub carry funnel fields only, so GA4 can never double-count revenue.
  4. Identity is separate from events. Call identify at login and signup, instead of attaching user fields to every event.
  5. No personal data in the data layer. No plain emails, names, addresses or phone numbers; use internal IDs. Anything needed for enhanced conversions is hashed or handled server-side.
  6. Naming: keep existing event names so nothing downstream breaks. New events follow the Segment spec style: object plus past-tense action (Order Completed, Checkout Step Viewed), with the spec's ecommerce property names where they apply.
  7. A tracking plan as code. A versioned file in the repository listing each event, when it fires, and its properties and types.
# tracking-plan.yaml
events:
  purchase:
    description: Order confirmed (marketing conversions)
    fires_on: order confirmation page, once per order
    properties:
      transaction_id: { type: string, required: true }
      value: { type: number, required: true }
      currency: { type: string, required: true }
      flow: { type: string, enum: [sub, one_off], required: true }
  purchase_sub:
    description: Subscription funnel completion (funnel analysis)
    fires_on: order confirmation page, subscription flow only
    properties:
      transaction_id: { type: string, required: true }
      plan: { type: string, required: true }

Adding a property is non-breaking. Renaming or removing one is breaking and gets flagged in review, because someone's report depends on it. In development and tests, validate payloads against the plan so drift is caught before it ships.

The GTM Side

The code only works if GTM is disciplined too:

Tag Trigger
GA4 and ad conversions Custom event purchase AND flow equals sub (exact match)
Funnel analytics Custom event purchase_sub
Marketing tags generally Blocking trigger/exception on purchase_sub

Avoid broad regex triggers on event names for conversion tags. One new event called purchase_something and your conversion numbers are wrong for a week before anyone notices.

Rolling It Out

  1. Audit every existing dataLayer.push: event, page, payload.
  2. Build the module and the data layer adapter, then migrate existing pushes to it without changing event names or payloads. This is a refactor, not a redesign.
  3. Add the ecommerce: null reset and the purchase guard.
  4. Make purchase carry the full ecommerce payload and transaction_id, and strip revenue fields from purchase_sub.
  5. Write the tracking plan and add validation in development.
  6. Leave an interface ready for a future segmentAdapter.
  7. List backend-originated events (renewals, payment failures, the canonical purchase) as candidates for server-side tracking, which is more reliable than anything a browser sends.

The Takeaway

Analytics debates are usually ownership debates in disguise. Let engineering publish accurate, well-structured facts through one thin tracking module, and let GTM decide where they go. The adapter pattern costs a day or two to build, and it turns a future move to a CDP from a re-instrumentation project into a configuration change.

Next in Analytics Tracking Done Properly
GTM vs Segment: Tag Manager or Customer Data Pipeline (and Why Many Teams Run Both)
analyticsGoogle Tag ManagerGA4dataLayertracking-planTypeScript