← Writing Tracking

Why you need to test your tracking

Tracking breaks because it's handled like documentation. How a tracking contract, browser tests in CI and warehouse checks catch issues before the data is lost.

By Henning Merkel 6 min read

TL;DR: In most companies, tracking is handled like documentation. It’s specified in a ticket or a wiki, implemented next to the actual feature, and mostly assumed to work. That’s why it breaks often and gets noticed late. Software engineering solved the same problems with rigorous testing, and tracking can work the same way. A tracking contract defines what correct looks like, a browser test checks the dataLayer against it in CI before deployment, and the same contract monitors the data in the warehouse.

In most companies I’ve worked with, tracking is handled like documentation. It gets specified in a Jira ticket or a wiki page. A developer implements it next to the actual feature, and after that it’s mostly assumed to work. I think this is why tracking breaks as often as it does, and why it usually takes so long until someone notices. I also think most of these problems go away when we treat tracking like everything else in software development.

Here is an example I have seen in one form or another at every company. Marketing runs a campaign. The clicks arrive on the landing page with the campaign parameters in the URL, and the visitor fills out a form two pages later. Somewhere on the way, a redirect or the routing of the single-page app has dropped the parameters, so the conversion arrives in Google Analytics without a campaign attached. The campaign did work, but the data says it didn’t. The marketing manager usually has no way to prove otherwise.

One visitor, from ad click to lead
campaign tag present tag gone
Ad click
linkedin.com · sponsored post
utm_campaign=spring_launch
Landing page
/lp/spring-launch?utm_campaign=spring_launch
utm_campaign=spring_launch
Redirect / app route
302 → /demo/request
✗ query string dropped
Form
/demo/request
utm_campaign —
Conversion
generate_lead
utm_campaign —
Google Analytics · Traffic acquisition
Session campaign Sessions Key events
spring_launch linkedin / paid_social 1,240 0
(not set) (direct) / (none) 3,115 46
Illustrative. The campaign tag survives until the redirect, so Google Analytics files the lead under (direct) / (none).

The second example is an A/B test. The tracking itself is fine, but the experiment runs in a different tool. Since the teams move fast, the tracking for the test variant didn’t go through the same rigorous QA process. The events of the variant fire on desktop but have missing tracking on mobile. Since nobody notices, the test gets evaluated on numbers where the mobile traffic is missing, and the wrong winner gets picked and shipped.

Control vs variant · conversions by device
recorded real not tracked
100200300400
410
410
395
395
260
260
0
330
ControlVariant
Desktop
ControlVariant
Mobile
By recorded data
Control wins
670 vs 395
✗ the version that gets shipped
By real conversions
Variant wins
725 vs 670 +8.2%
the version that should have
Illustrative. The variant’s mobile conversions were never recorded, so the recorded winner is the wrong one.

A very rigorous team might catch either of those two examples. But for a highly iterative product team and an agile marketing team that launches new campaigns frequently, manual testing does not scale. Especially for smaller tests and campaigns, tracking issues are usually only found after weeks of collecting data, or they never surface at all. It also happens that the initial implementation was never completely correct, so the baseline that people compare against is already wrong. An audit or a monitoring alert doesn’t change this, because both only tell you about the issue after the data is already missing. And that data is oftentimes gone for good. If you are lucky, it can be repaired in the transformation layer, which costs time. Otherwise it’s lost for the whole period.

Campaign conversions per day
recorded with campaign real campaign conversions
0204060
CI test release alert ≈1,259 lost 5 weeks
wk 1wk 4wk 7wk 10
CI test: fails on the release, before any data is lost Release: breaks the campaign tracking Alert or audit: fires after five weeks Conversions recorded without a campaign
Illustrative. A test in CI fails on the release itself; the alert fires five weeks later, after the data is gone.

Why do we treat tracking like documentation?

Tracking passes through many hands before it produces a single number. The requirements usually come from a product manager. A web analyst defines how the tracking should be generated, a developer implements it, and QA or the web analyst checks it before it gets deployed. After that it still depends on a tag management system, which sends the data on to the marketing tags, the analytics tool and the warehouse the data team builds on.

Usually someone owns the tracking, often the web analyst. But end-to-end ownership is rarely defined, and nobody controls the whole pipeline. The web analyst doesn’t control the software implementation and isn’t involved in every software or analytics change. Most companies also build far more software than they have web analysts to review it. For the developer, tracking is an outside requirement next to the feature they are actually building. It costs time and is often outside their expertise. Marketing managers, BI and data analysts depend on the data, but following every tracking change is not their job.

The spec is supposed to hold all of this together, but it doesn’t. It describes the tracking at the beginning. After that, the software and the tracking change through one change request after another, while the spec stays in a Jira ticket or a wiki page.

So the spec drifts in two directions: away from what the software actually sends, and away from what analytics and marketing expect. After a while nobody can say which version is the correct one. Even a dedicated web analyst has no tool to check every change against the spec, because a text document can’t be tested.

Who touches the tracking
Product manager
requirements
Web analyst
defines the tracking
spec written here · Jira ticket or wiki page
Developer
implements it
QA / web analyst
checks it
Tag manager
sends the data on
Analytics toolMarketing tagsWarehouse
Where the spec ends up, change request by change request
fields that differ from the spec
9 8
launchCR 4CR 8
what the software sends · 9 fields differ the spec, unchanged what analytics and marketing expect · 8 fields differ
Illustrative. The spec is written once at the start; with each change request, the software and the expectations move away from it.

Software engineering has already solved this problem

Developers face the same problems in their software development lifecycle. Many people change the code, requirements move, and a spec in a ticket goes out of date. Software engineering solved this with rigorous testing. In test-driven development, you first write a test that describes what the code should do. Then you implement the code until the test passes. The test runs before every deployment, so a change that breaks the expected behaviour is caught before it reaches users.

If we treat tracking like code, it can go through the same loop. We define what the tracking should send, test the implementation against it, and only deploy when the test passes. Then we know the tracking works before it goes live. We just have to define what we test against.

One contract, two checks
Tracking contract
contracts/purchase.json
shape · values · fires
Before release · in CI
Browser test
tests/purchase.spec.js
✓runs on every change
✗fails the build when a change breaks the tracking
After release · in the warehouse
Warehouse check
checks/purchase_contract.sql
✗flags duplicated transaction IDs
✗flags IDs that break the pattern
·sees changes that skip CI: testing tool, tag manager
Illustrative. The same contract runs as a browser test before release and as a warehouse check after it.

Defining the tracking contract

To test the tracking, we need a definition of correct that a machine can read. This is the tracking contract, a data contract for the events the site sends. The developer or the web analyst doesn’t invent it alone. It comes from the business requirements and from the data the organization has already defined. Ideally it is part of the semantic layer, or a product of it, so it uses the same event names, business definitions and naming conventions that the data team and the dashboards downstream already rely on.

Take the conversion from the campaign example as a standard ecommerce purchase. Its contract states three things. The first is the shape: a purchase event with the transaction ID, the order value, the currency and the list of items. The second is the values each field may take. The transaction ID has to match the pattern ORD- plus year plus a six-digit number. The third is when the event has to fire: exactly once, after the click on the element with data-testid="checkout-confirm".

contracts/purchase.json JSON Schema
{
  "event": "purchase",
  "fires": { "after_click": "[data-testid='checkout-confirm']", "times": 1 },
  "schema": {
    "type": "object",
    "required": ["transaction_id", "value", "currency", "items"],
    "properties": {
      "transaction_id": { "type": "string", "pattern": "^ORD-\\d{4}-\\d{6}$" },
      "value": { "type": "number", "minimum": 0 },
      "currency": { "enum": ["EUR", "CHF"] },
      "items": { "type": "array", "minItems": 1 }
    }
  }
}

Testing the tracking contract

With the contract in place, the test itself is a standard browser test. Most teams already use tools like Playwright or Cypress to test their frontend. The same tool can open the checkout, click the button with data-testid="checkout-confirm" and read what the site pushes into the dataLayer at that moment. The test then compares the event against the contract. If the transaction ID has the wrong format, a field is missing or the event fires twice, the test fails.

tests/purchase.spec.js Playwright
import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
import contract from '../contracts/purchase.json' with { type: 'json' };

const validate = new Ajv().compile(contract.schema);
const purchases = (page) =>
  page.evaluate(() => window.dataLayer.filter((e) => e.event === 'purchase'));

test('purchase matches the tracking contract', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByTestId('checkout-confirm').click();

  await expect.poll(async () => (await purchases(page)).length).toBe(1);
  const [purchase] = await purchases(page);
  expect(validate(purchase.ecommerce), JSON.stringify(validate.errors)).toBe(true);
});

The test runs in CI, for example in GitHub Actions, on every change before deployment. A developer who changes the checkout gets a failing build when the change breaks the tracking. The issue gets fixed before it is live, and nobody has to find it weeks later in the data.

Monitoring with the tracking contract

The contract is also useful after deployment. It can be automatically translated into SQL, so the same rules can run as checks in BigQuery or whatever data warehouse the team uses. Data gets compared against the contract: transaction IDs that don’t match the pattern or are duplicated get flagged automatically. This catches what a test before release can’t see, for example a change in a separate testing tool or in the tag management system that never went through CI.

checks/purchase_contract.sql BigQuery
select
  event_date,
  ecommerce.transaction_id,
  count(*) as times_sent,
  logical_or(not regexp_contains(coalesce(ecommerce.transaction_id, ''), r'^ORD-\d{4}-\d{6}$')) as breaks_pattern
from `project.analytics_123456.events_*`
where event_name = 'purchase'
group by event_date, ecommerce.transaction_id
having times_sent > 1 or breaks_pattern
order by event_date desc

What testing and data contracts don’t fix

Testing improves the data quality, but it doesn’t fix the problems that are inherent to tracking. Even perfect tracking can’t measure a visitor who blocks it with an ad blocker or who denies consent. The same goes for a customer journey without user IDs, for example when a visitor starts on the phone and buys on their laptop.

End of entry

Got this exact problem on your stack? That's the kind of thing I fix on an engagement.

Book a fit call →