--- name: analytics-tracking-testing description: >- Validate that analytics and marketing tracking fire CORRECTLY: GA4/GTM dataLayer events, Meta/TikTok/LinkedIn pixels, and ad-tech tags. Covers building a tracking plan as the contract, intercepting collect-endpoint beacons and dataLayer.push in Playwright, asserting event name + params + values + timing + de-duplication, Consent Mode v2 gating, CI regression gating, and news-media events (article-view, scroll-depth, paywall). Use when: "test analytics tracking," "GA4 event test," "verify the pixel fires," "dataLayer test," "tracking plan," "Meta Pixel dedup," "scroll-depth tracking test," "gate tracking in CI." Not for: whether tracking is ALLOWED to fire under consent law (GDPR/CMP) — that is compliance-testing; this skill checks the data is CORRECT. SEO meta tags / structured data — out of scope. Related: compliance-testing, playwright-automation, api-testing, qa-project-context. license: MIT metadata: author: kindlmann version: "1.0" category: specialized --- Tracking that "looks fine" in GA4 DebugView still drops events silently after a refactor, sends the wrong currency, or double-counts a purchase. Reading `window.dataLayer` or asserting the button is `toBeVisible()` proves nothing — the beacon may never leave the browser. This skill makes you intercept the real network beacon (`google-analytics.com/g/collect`, `facebook.com/tr`, `analytics.tiktok.com`, `px.ads.linkedin.com`), parse the event name and parameters out of the request, and assert them against a tracking plan that is a typed contract — then gate that contract in CI so a dropped event fails the build. ## Quick Route | Situation | Go to | |-----------|-------| | Assert one GA4 event fired with correct params | [Intercepting GA4 beacons](#intercepting-ga4-beacons) | | Treat the tracking plan as a validated contract | [The Tracking Plan Is the Contract](#the-tracking-plan-is-the-contract) | | Verify dataLayer.push shape (not the beacon) | [Asserting dataLayer.push](#asserting-datalayerpush) | | Pixel + server CAPI deduplication (event_id) | [Pixels and Server-Side Deduplication](#pixels-and-server-side-deduplication) | | Events must/mustn't fire by consent state | [Consent Mode v2 Gating](#consent-mode-v2-gating) | | Capture every pixel in one reusable fixture | `references/capture-fixture.md` | | News-media: article-view, scroll-depth, paywall | [News-Media Events](#news-media-events) | | Fail the build on a tracking regression | [Regression-Gating in CI](#regression-gating-in-ci) | | Hundreds of events, many domains, small team | [Buy vs Build](#buy-vs-build) | ## Discovery Questions First: check `.agents/qa-project-context.md` in the project root and skip anything it already answers (stack, tag manager, consent platform, target environments). - **Which tracking destinations are live?** GA4 (`/g/collect`), Meta Pixel (`facebook.com/tr`), TikTok (`analytics.tiktok.com`), LinkedIn (`px.ads.linkedin.com`), ad-tech tags — each has a different endpoint grammar, so the capture helper must know them all. - **GTM or hardcoded gtag?** GTM means the truth flows through `window.dataLayer.push` first, then GTM fires the beacon. You may assert at the push layer (input contract) AND the beacon (output contract); they are different tests. - **Is there a tracking plan?** If not, build one first — it is the contract everything else validates against. No plan means no objective pass/fail. - **Consent platform and default consent state?** Consent Mode v2 changes which beacons are even allowed to fire before consent. You need the CMP's accept/reject selectors to drive the test. - **Client + server (CAPI) dedup in play?** If purchases fire both client Pixel and server Conversions API, the `event_id` must match or you double-count. This is the correctness property, not "did fbq run." - **News-media surface?** Article pages add article-view, scroll-depth thresholds (25/50/75/100), and paywall-meter events that generic e-commerce plans miss. --- ## Core Principles 1. **Intercept the beacon, never trust the DOM or `dataLayer` alone.** A button click that updates the DOM, or a `dataLayer.push` that GTM silently drops, leaves no GA4 hit. The only proof an event was *sent* is the outbound request to the collect endpoint. Assert on the network beacon's URL params; reading `window.dataLayer` via `page.evaluate` only proves the push happened, not that anything left the browser. 2. **The tracking plan is a typed contract, not a comment.** Every expected event lives in an external schema (JSON/YAML/TS interface) with required params and their types. Tests validate captured events against it and report `missing` params and type `violations`. Hardcoding one expected value inline asserts nothing about the other twenty params and rots on the first plan change. 3. **De-duplication is the real correctness property for purchases.** Checking that `fbevents.js` loaded or counting that `fbq('track','Purchase')` ran misses double-counting entirely. The property that matters: the browser Pixel and the server CAPI send the *same* `event_id`/`eventID` so Meta collapses them into one conversion. 4. **Consent state is an input dimension, not a footnote.** The same page produces different beacons before vs after consent. Test both: before consent → no beacon (or only a cookieless consent ping); after accept → full beacon. And the default must be `denied` for all four Consent Mode v2 signals — a `granted` default is a compliance bug AND makes the gating test meaningless. 5. **Drive real user actions and wait on requests, never the clock.** Scroll depth fires from actual scrolling (`mouse.wheel`, `scrollIntoView`, `evaluate(scrollTo)`), and you wait for the beacon with `waitForRequest`, not `waitForTimeout`. A fixed sleep is flaky and hides the very timing bug you should catch. 6. **A tracking test that can't fail the build is theater.** "Check it in GA4 DebugView" and "monitor production" never block a regression. The contract must run in CI and exit non-zero when an event drops or a required param goes missing. --- ## Intercepting GA4 Beacons GA4 (gtag.js / GTM) sends every event as an HTTP request to `https://www.google-analytics.com/g/collect` (region variants like `region1.google-analytics.com/g/collect` also occur). The event identity lives in the URL query string — you do not need the response. GA4 `/g/collect` URL grammar you assert on: | Param | Meaning | Example | |-------|---------|---------| | `v=2` | Measurement Protocol version (always 2 for GA4) | `v=2` | | `tid=G-XXXXXXX` | Measurement ID | `tid=G-ABC123` | | `en=` | **Event name** | `en=add_to_cart` | | `ep.=` | Event parameter, **string** type | `ep.currency=USD` | | `epn.=` | Event parameter, **number** type | `epn.value=49.99` | | `gcs=` / `gcd=` | Consent state (see Consent Mode v2 section) | `gcs=G111` | The string/number split is load-bearing: GA4 types params automatically, so `price` arrives as `epn.value` (number) and `currency` as `ep.currency` (string). Asserting `ep.value` when it is really `epn.value` silently fails. Intercept with `page.waitForRequest` (single expected event), `page.on('request', ...)` (collect many), or `page.route` (inspect then continue — never `abort`). Parse params from `new URL(request.url()).searchParams`. Then `expect(...).toBe(...)` / `toEqual` / `toContain` on the parsed values. Minimal pattern (full version with helper in `references/ga4-interception.md`): ```ts test('add_to_cart fires with correct params', async ({ page }) => { await page.goto('/product/42'); const [request] = await Promise.all([ page.waitForRequest(r => r.url().includes('/g/collect') && r.url().includes('en=add_to_cart')), page.getByRole('button', { name: 'Add to cart' }).click(), ]); const params = new URL(request.url()).searchParams; expect(params.get('v')).toBe('2'); expect(params.get('tid')).toContain('G-'); expect(params.get('en')).toBe('add_to_cart'); expect(params.get('ep.currency')).toBe('USD'); expect(Number(params.get('epn.value'))).toBe(49.99); }); ``` Never substitute `page.evaluate(() => window.dataLayer)` as the only assertion, `toBeVisible()` on the button, or `waitForTimeout()` to "let the beacon send." See `references/ga4-interception.md` for batched-event parsing (GA4 can pack multiple events into one POST body) and region-endpoint handling. --- ## The Tracking Plan Is the Contract A tracking plan is the source of truth: for every event, its name, required params, and each param's type. Keep it as a versioned file (`tracking-plan.json` / `.yaml`, or a TS `interface`/`Zod` schema) that both the app team and the tests import. Tests read the captured event and validate it against the plan — they do not hardcode expected values inline. Validation produces a structured result, not a pass/fail boolean: list every `missing` required param and every type `mismatch`/`violation`. Example plan entry and validator: ```ts // tracking-plan.json { "add_to_cart": { "required": { "currency": "string", "value": "number", "item_id": "string" } } } function validateEvent(plan, eventName, params) { const spec = plan[eventName]; const violations = []; for (const [name, type] of Object.entries(spec.required)) { const raw = params.get(`ep.${name}`) ?? params.get(`epn.${name}`); if (raw == null) { violations.push({ param: name, problem: 'missing' }); continue; } if (type === 'number' && Number.isNaN(Number(raw))) violations.push({ param: name, problem: 'type', expected: 'number' }); } return violations; // empty array = event satisfies the contract } ``` Then `expect(validateEvent(plan, 'add_to_cart', params)).toEqual([])`. Asserting only the event name and ignoring params, or hardcoding expected values with no plan file, is the bare-agent shortcut this skill exists to replace. See `references/tracking-plan.md` for the YAML form, a Zod-typed plan, and a reusable `assertAgainstPlan` matcher. --- ## Asserting dataLayer.push When the question is specifically the GTM **input** — "is the right object pushed to `dataLayer` when the page loads?" — assert the push, not the GA4 beacon. This is the inverse of beacon interception: here the dataLayer push IS the target. Capture pushes by wrapping `window.dataLayer.push` in `addInitScript` *before navigation* so you record every push from page load, then read the recorded array via `page.evaluate`. Do **not** `page.route` to mock the dataLayer (you would replace the thing under test), and do not read the GA4 network beacon instead (that is the output, a different contract). For ecommerce, assert the nested shape, not just the event name — the `ecommerce.items` array and each item's `item_id`, `price`, `currency`: ```ts await page.addInitScript(() => { window.dataLayer = window.dataLayer || []; const orig = window.dataLayer.push.bind(window.dataLayer); window.__pushes = []; window.dataLayer.push = (...args) => { window.__pushes.push(...args); return orig(...args); }; }); await page.goto('/product/42'); const pushes = await page.evaluate(() => window.__pushes); const viewItem = pushes.find(p => p.event === 'view_item'); expect(viewItem.ecommerce.items[0]).toMatchObject({ item_id: 'SKU-42', price: 49.99, currency: 'USD' }); ``` Use `find`/`filter`/`some` to locate the event in the recorded pushes. Full helper in `references/datalayer-capture.md`. --- ## Pixels and Server-Side Deduplication Marketing pixels send their own beacons. Endpoints to intercept: | Destination | Endpoint | Event param | Dedup key | |-------------|----------|-------------|-----------| | Meta Pixel | `facebook.com/tr` (also `/tr?`) | `ev=PageView`, `ev=Purchase` | `eid` / `event_id` | | TikTok | `analytics.tiktok.com` | event in body/params | `event_id` | | LinkedIn | `px.ads.linkedin.com` | conversion id | — | For Meta, asserting that `connect.facebook.net/en_US/fbevents.js` loaded, or that `fbq('track','Purchase')` ran, is a load/count check — it does **not** prove correctness. The correctness property for a Purchase that fires both client-side (Pixel) and server-side (Conversions API / CAPI) is **deduplication**: both must carry the *same* `event_id` so Meta merges them into one conversion instead of double-counting. Test it: capture the browser `facebook.com/tr` beacon for `ev=Purchase`, read its `event_id`, and assert it equals the `event_id` your server sent to CAPI (from a mocked/captured server call or a known fixture value). Skeleton: ```ts const [pixel] = await Promise.all([ page.waitForRequest(r => r.url().includes('facebook.com/tr') && r.url().includes('ev=Purchase')), completeCheckout(page), ]); const clientEventId = new URL(pixel.url()).searchParams.get('eid'); // event_id on the wire expect(clientEventId).toBe(serverCapiEventId); // deduplicates against the server CAPI event ``` See `references/pixels-and-dedup.md` for parsing TikTok/LinkedIn payloads and capturing the server CAPI call. --- ## Consent Mode v2 Gating Consent Mode v2 is the standard (mandatory four-signal model). As of the **June 15 2026** change, Google acts only on the CMP-sent consent signal, so any two-signal answer is outdated. Test two states. The four signals — all must default to `denied`: | Signal | Governs | |--------|---------| | `ad_storage` | Advertising cookies | | `analytics_storage` | Analytics cookies | | `ad_user_data` | Sending user data to Google for ads | | `ad_personalization` | Personalized ads / remarketing | A `granted` default is a bug; omitting `ad_user_data` and `ad_personalization` (the v2 additions) is the outdated two-signal model and is wrong. **Before consent:** no full beacon should fire — or only a *cookieless consent ping*. Use `addInitScript` to seed the `gtag('consent', 'default', {...})` denied state before page scripts run, and assert no `/g/collect` request fires (or that the one that does carries a denied consent state). **After accept:** click the CMP accept button; the full beacon now fires. The consent state rides on the beacon URL: - `gcs=` — encodes `ad_storage` + `analytics_storage` only. `G100` = both denied, `G111` = both granted, `G110`/`G101` = partial. Before consent you expect `gcs=G100`. - `gcd=` — encodes all four signals (string starting `11...`); present on every hit to Google services. Assert the denied default and the `gcs=`/`gcd=` value on the pre-consent beacon, then the granted state post-accept. Full test with `addInitScript` consent seeding and CMP click in `references/consent-mode.md`. > Whether the law *permits* a beacon under a given consent state is `compliance-testing`. This skill asserts that *when* a beacon fires, its data and consent params are correct. --- ## News-Media Events News and publisher sites have a tracking surface generic e-commerce plans miss. Cover all three: - **article_view** (or `article-view`) on article load — assert the beacon fires once with article metadata (id, section, author). - **scroll-depth** at the 25 / 50 / 75 / 100 percent thresholds — one event per bucket, driven by *real scrolling*. - **paywall / meter** — a `paywall_hit` (or meter) event when the free-article meter is exhausted. Drive scroll with actual actions — `mouse.wheel`, `element.scrollIntoView`, or `page.evaluate(() => window.scrollTo(...))` / `scrollBy` — and wait on the beacon (`waitForRequest` or your captured-events list), never `waitForTimeout`. Scrolling in fixed sleeps both flakes and masks threshold-timing bugs. ```ts const buckets = [25, 50, 75, 100]; for (const pct of buckets) { const [req] = await Promise.all([ page.waitForRequest(r => r.url().includes('/g/collect') && r.url().includes('en=scroll')), page.evaluate(p => window.scrollTo(0, document.body.scrollHeight * (p / 100)), pct), ]); expect(new URL(req.url()).searchParams.get('epn.percent_scrolled')).toBe(String(pct)); } ``` Full suite (article_view metadata, the four scroll buckets de-duplicated, and the paywall-meter event) in `references/news-media.md`. --- ## Multi-Pixel Capture Fixture Don't re-implement beacon capture in every test. Build one reusable Playwright fixture (`test.extend`) that listens with `page.on('request', ...)`, matches all destination endpoints (GA4 `/g/collect`, `facebook.com/tr`, `analytics.tiktok.com`, `px.ads.linkedin.com`), parses each request's `new URL(...).searchParams` and `postData()`, and pushes a normalized `{ destination, eventName, params }` onto a `collected` array tests assert against. Critical trap: a capture helper must **observe**, so use `page.on('request')` (or `page.route` followed by `route.continue()`), **never** `page.route(...).abort()` — aborting blocks the very beacons you are trying to see. And it must capture pixels too, not GA4 only. Full fixture in `references/capture-fixture.md`. --- ## Regression-Gating in CI The gate diffs captured events against the tracking-plan baseline and **fails the build** (non-zero exit) when a previously-firing event stops firing or a required param goes missing after a release. Structure it as a Playwright project that runs the journeys, captures every beacon via the fixture, and validates each against the plan; on any `missing`/dropped event the test `expect` fails, Playwright exits non-zero, and the GitHub Actions (or any CI) job goes red. Do **not** make the gate "check GA4 DebugView manually," "only run against production traffic," or "warn but pass anyway" — none of those block a regression. The diff-against-baseline run belongs in PR CI, before merge. See `references/ci-gating.md` for the workflow YAML, the baseline-diff script, and how to surface missing-param failures in the job summary. --- ## Buy vs Build DIY Playwright interception is the right call for a bounded set of events on a few critical journeys gated in CI. It stops paying off at scale: hundreds of events across many domains, with a small team, and a need for *continuous production / drift monitoring* (catching a tag a marketer breaks in GTM at 2am, which a pre-merge CI gate never sees). | Dimension | Build (Playwright) | Buy | |-----------|-------------------|-----| | Few events, key journeys, pre-merge gate | Best fit | Overkill | | Hundreds of events, many domains | Maintenance crushes you | Buy | | Continuous 24/7 production drift monitoring | Out of scope for CI | Buy | | Small team, high coverage demand | Build cost too high | Buy | Current live paid options worth naming: - **Trackingplan** — always-on/continuous monitoring of live traffic across web, mobile, and server-side; strongest when you need real-time drift detection rather than scheduled checks. - **ObservePoint** — scheduled scans/audits of journeys against a tracking plan; mature for periodic governance. Don't recommend Segment Protocols as the *only* validation (it governs data flowing through Segment, not arbitrary client beacons), and never call Google Tag Assistant a CI gate — it is an interactive debug tool, not an automated pass/fail. Tie the decision to scale, many domains, maintenance burden, and continuous monitoring — the axes where DIY stops paying off. --- ## Anti-Patterns ### 1. Asserting `window.dataLayer` (or the DOM) instead of the beacon `page.evaluate(() => window.dataLayer)` proves a push happened, not that GA4 sent anything; `toBeVisible()` proves nothing about tracking. Intercept the `/g/collect` request and assert its `en=` and `ep.`/`epn.` params. (The one exception: when the *push itself* is the contract — see dataLayer.push — but then never reach for the GA4 beacon instead.) ### 2. `waitForTimeout` to "wait for the event to fire" A fixed sleep flakes and hides timing bugs. Wait on the request: `page.waitForRequest(r => r.url().includes('/g/collect'))`. ### 3. Asserting only the event name Name-only assertions pass while currency, value, and item_id are wrong. Validate every required param and its type against the tracking plan. ### 4. Hardcoding expected values inline with no plan file There is no source of truth, so nothing catches a renamed param across the suite. Keep the plan external and validate against it. ### 5. Load/count checks for pixels (`fbevents.js` loaded, `fbq` ran) Neither proves the conversion is correct or de-duplicated. For Purchase, assert the client and server `event_id` match. ### 6. Two-signal Consent Mode, or a `granted` default Omitting `ad_user_data` and `ad_personalization` is the pre-2026 model. Default all four to `denied` and assert `gcs=`/`gcd=`. ### 7. `page.route(...).abort()` inside a capture helper Aborting blocks the beacons you meant to observe. Use `page.on('request')` (or `route.continue()`). ### 8. A "gate" that can't fail the build GA4 DebugView, production-only monitoring, or warn-but-pass do not stop a regression. Make CI exit non-zero on a dropped event or missing required param. --- ## Verification Smallest check first — prove the suite actually catches a regression, don't just trust a green run: - **One event, one beacon:** `npx playwright test -g add_to_cart` passes, and its trace (`--trace on`) shows the captured `/g/collect` request with `en=add_to_cart`. A green test with no matching request in the trace means you asserted on the wrong thing. - **The gate can fail:** rename a required param in a branch (e.g. `value` → `amount` in the app) and re-run the CI project — the build must exit non-zero with a `missing`/violation message. If it still passes, the gate is theater. - **Consent default is denied:** with the denied seed and no accept click, `gcs=G100` (or no full beacon) on every captured hit; after accept, `gcs=G111`. A pre-consent `G111` means the default is wrongly `granted`. - **Dedup holds:** the client `eid` from `facebook.com/tr?ev=Purchase` equals the server CAPI `event_id` for the same order. Different values mean Meta will double-count. ## Done When - Each tracked event has a test that intercepts the real collect-endpoint beacon (`/g/collect`, `facebook.com/tr`, etc.) and parses `en=`/`ev=` plus params from `searchParams`/`postData` — no DOM-only or dataLayer-only assertions. - A versioned tracking plan file exists (JSON/YAML/TS) with required params and types; tests validate captured events against it and report `missing`/type violations, not just the event name. - Purchase (or any client+server event) has a deduplication assertion: client beacon `event_id` equals the server CAPI `event_id`. - A Consent Mode v2 test asserts all four signals default to `denied`, no full beacon fires before consent (or only a cookieless ping), the full beacon fires after accept, and `gcs=`/`gcd=` carry the expected consent state. - A reusable capture fixture (`test.extend`) collects GA4 + Meta + TikTok + LinkedIn beacons; no test re-implements capture and no capture helper calls `route.abort()`. - News-media surfaces (if present) cover article_view, scroll-depth at 25/50/75/100 driven by real scroll actions, and a paywall/meter event — all using `waitForRequest`, no `waitForTimeout`. - A CI job runs the suite, diffs captured events against the tracking-plan baseline, and exits non-zero on a dropped event or missing required param — verified by a deliberately-broken tag turning the build red. ## Related Skills - **compliance-testing** — Whether a beacon is *allowed* to fire under GDPR/CMP consent law, cookie-consent UI, and data-subject rights. This skill assumes the beacon is permitted and checks its data is correct; go there for the legality question. - **playwright-automation** — Page Object Model, fixtures, config, and the general E2E patterns this skill builds its interception on. - **api-testing** — Validating the server-side Conversions API / Measurement Protocol calls directly (request body, auth, response) when you need to assert the server half of deduplication. - **qa-project-context** — Stack, tag manager, consent platform, and target environments that this skill's discovery questions read first. ## Reference Files (in `references/`) - **ga4-interception.md** — Full GA4 `/g/collect` interception helper, batched-event POST-body parsing, region-endpoint handling. - **tracking-plan.md** — JSON and YAML plan forms, a Zod-typed contract, and the reusable `assertAgainstPlan` matcher. - **datalayer-capture.md** — `addInitScript` dataLayer.push wrapper and ecommerce `items[]` shape assertions. - **pixels-and-dedup.md** — Meta/TikTok/LinkedIn payload parsing and client-vs-server CAPI `event_id` dedup capture. - **consent-mode.md** — Consent Mode v2 default-denied seeding, CMP accept-click flow, and `gcs=`/`gcd=` assertions. - **news-media.md** — article_view, de-duplicated 25/50/75/100 scroll buckets, and paywall-meter event tests. - **capture-fixture.md** — The reusable multi-pixel `test.extend` capture fixture. - **ci-gating.md** — GitHub Actions workflow, baseline-diff script, and surfacing missing-param failures.