@analitikisler/ai-tag-debugger 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-10-14)
4
+
5
+ - The browser installs itself the first time an audit starts, so connecting the MCP server no longer needs a separate `playwright install` step. Progress goes to stderr.
6
+ - Listed in the official MCP Registry as `io.github.analitikisler/ai-tag-debugger` (`mcpName` in package.json, `server.json`).
7
+ - The README has one-line and one-click setup for Claude Code, Cursor and VS Code.
8
+ - The MCP server reports its real version instead of a fixed 0.6.3.
9
+
10
+ ## 0.7.9 (2026-10-14)
11
+
12
+ - A planned event is found under the name the site uses: `dl_add_to_cart` (Elevar), `addToCart` and `Add To Cart` all count as `add_to_cart`, and similar variants of the other ecommerce events too. Sites like these no longer get a false "missing add_to_cart". A plan event can list more names in `aliases`.
13
+ - A required parameter is also found under its usual other name (`products` for `items`, `revenue` for `value`, `order_id` for `transaction_id`).
14
+ - The plan coverage table in the report uses the same matching.
15
+ - Server-side tagging is recognised where the browser can see it. A GA4 hit to a server container (server-side GTM, Stape, Elevar) is marked server-side, a hit through Google tag gateway (`/g/c`) is not, and requests to Stape and Elevar hosts show in the timeline. An info finding lists, per platform, which events went server-side and which went from the browser. The report tags server-side events.
16
+ - Meta (`eid`) and TikTok (`event_id`) event IDs are read from the browser hit. The duplicate check no longer flags hits that repeat one event ID, since the platform counts those once.
17
+ - New info finding when a server-side setup is seen and a Meta or TikTok browser event has no event ID, so the platform can't match it to its server copy. Meta Conversions API and TikTok Events API requests themselves are sent server to server and can't be seen from the browser.
18
+ - New parsers for the Pinterest tag, Snap Pixel, Klaviyo onsite events and Hotjar API calls. Pinterest, Snap and Klaviyo hits show their event name, id, value fields and, when present, the hashed or plain user data they send (redacted like Meta's). Hotjar shows the site id.
19
+ - A Meta hit now shows `event_id` (`eid`), the value a Conversions API copy must repeat to be deduplicated, and its advanced matching fields (`ud[...]`).
20
+ - Klaviyo is a selectable provider.
21
+ - Consent Mode v2: the report flags a default that leaves out `ad_user_data` or `ad_personalization`, and an accept whose update does not grant them.
22
+ - New `decline_cookies` journey (also `decline`, `reject`): the AI rejects all cookies, browses on, and the report checks that nothing is sent afterwards. It flags ad pixels and Google hits that claim consent after the reject, a Consent Mode update that grants consent after it, and a banner that is still showing after the reject click. Clicks carry `rejects_consent`, like `accepts_consent` for accepting; replays keep it.
23
+ - Tracking cookies (`_ga`, `_fbp`, `_gcl_*`, `_ttp` and similar) are noted with the step that set them, by name only. Ones set before the visitor chose, or after they rejected, are flagged.
24
+ - More consent platforms are recognized as banners that block a click (Osano, Iubenda, Complianz, CookieFirst, Axeptio, Sourcepoint, TrustArc, Borlabs, Klaro, Termly and others) and as consent requests in the timeline (Didomi, Quantcast, TrustArc, Sourcepoint, Iubenda, Termly and others).
25
+
3
26
  ## 0.7.8 (2026-10-14)
4
27
 
5
28
  - Unnamed TikTok pings are left out of the duplicate check. TikTok sends them on its own timing, so their count changed between a save and its replay without the site changing.
package/README.md CHANGED
@@ -41,11 +41,11 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
41
41
  | Missing events | `purchase` never fired on the checkout journey |
42
42
  | Missing parameters | `purchase` sent without `value` and `currency` on mobile |
43
43
  | Duplicates | `add_to_cart` sent twice by one click |
44
- | Consent | Meta `PageView` fired before the visitor accepted cookies, Consent Mode default missing or set too late |
44
+ | Consent | Meta `PageView` fired before the visitor accepted cookies, Consent Mode default missing or set too late, Consent Mode v2 signals (`ad_user_data`, `ad_personalization`) missing, tracking cookies set before consent, tags or cookies still active after the `decline_cookies` journey rejects the banner, a reject button that does nothing |
45
45
  | Personal data | An email address sent to an ad platform in plain text |
46
46
  | Unplanned events | Events firing that aren't in your measurement plan |
47
47
 
48
- Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
48
+ Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain; hits to a server container are marked server-side, and Stape and Elevar requests are recognised), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
49
49
 
50
50
  ### Choose the providers to check
51
51
 
@@ -235,6 +235,8 @@ The first run's findings become the expected ones. When a change is right, for e
235
235
 
236
236
  `platform` is `ga4`, `google_ads`, `meta` or `tiktok`. `journeys` limits where an event is expected (default: every journey). Set `allow_multiple: true` for events that may legitimately fire more than once per action.
237
237
 
238
+ Event names are matched loosely, so `dl_add_to_cart` (Elevar), `addToCart` and `Add To Cart` count as `add_to_cart`, and `transaction` or `order_completed` as `purchase`. Add your own with `"aliases": ["my_event"]` on a planned event. Required parameters are also found under their usual other names: `products` for `items`, `revenue` for `value`, `order_id` for `transaction_id`.
239
+
238
240
  ## CLI
239
241
 
240
242
  ```
@@ -301,7 +303,16 @@ Add the server to the app's MCP config. For Claude Desktop that is Settings →
301
303
  }
302
304
  ```
303
305
 
304
- Install the browser once with `npx -y playwright@1.56.1 install chromium`. Gemini CLI uses the same `mcpServers` block in `~/.gemini/settings.json`. Claude Code: `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`. Restart the app, and the ai-tag-debugger tools appear. If the app can't find `npx`, use its full path (`which npx`, for example `/opt/homebrew/bin/npx` on a Mac with Homebrew). Reports are written to `~/ai-tag-debugger-reports`.
306
+ The browser (Chromium) downloads itself the first time you run an audit, about a minute, one time. To get it ahead of time: `npx -y playwright@1.56.1 install chromium`.
307
+
308
+ One-click and one-line setup:
309
+
310
+ - **Claude Code:** `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`
311
+ - **Cursor:** [Add to Cursor](https://cursor.com/en/install-mcp?name=ai-tag-debugger&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhbmFsaXRpa2lzbGVyL2FpLXRhZy1kZWJ1Z2dlciIsIm1jcCJdfQ==)
312
+ - **VS Code:** [Install in VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=ai-tag-debugger&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40analitikisler%2Fai-tag-debugger%22%2C%22mcp%22%5D%7D), or `code --add-mcp '{"name":"ai-tag-debugger","command":"npx","args":["-y","@analitikisler/ai-tag-debugger","mcp"]}'`
313
+ - **Claude Desktop, Gemini CLI and others:** the JSON block above. Gemini CLI uses the same `mcpServers` block in `~/.gemini/settings.json`.
314
+
315
+ Restart the app, and the ai-tag-debugger tools appear. If the app can't find `npx`, use its full path (`which npx`, for example `/opt/homebrew/bin/npx` on a Mac with Homebrew). Reports are written to `~/ai-tag-debugger-reports`.
305
316
 
306
317
  Running from a clone instead? Use `"command": "node"` and `"args": ["/path/to/ai-tag-debugger/src/cli.js", "mcp"]`.
307
318
 
package/README.tr.md CHANGED
@@ -41,7 +41,7 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
41
41
  | Eksik event'ler | `purchase`, ödeme senaryosunda hiç gönderilmedi |
42
42
  | Eksik parametreler | `purchase`, mobilde `value` ve `currency` olmadan gönderildi |
43
43
  | Tekrar eden event'ler | `add_to_cart` tek tıklamada iki kez gönderildi |
44
- | Çerez onayı | Meta `PageView`, ziyaretçi çerezleri kabul etmeden gönderildi; Consent Mode varsayılanı yok ya da çok geç ayarlanmış |
44
+ | Çerez onayı | Meta `PageView`, ziyaretçi çerezleri kabul etmeden gönderildi; Consent Mode varsayılanı yok ya da çok geç ayarlanmış, Consent Mode v2 sinyalleri (`ad_user_data`, `ad_personalization`) eksik, onaydan önce yazılan izleme çerezleri, `decline_cookies` yolculuğu bandı reddettikten sonra hâlâ çalışan etiketler ya da çerezler, işe yaramayan reddet düğmesi |
45
45
  | Kişisel veri | Bir e-posta adresi reklam platformuna açık metin olarak gönderildi |
46
46
  | Planda olmayan event'ler | Ölçümleme planınızda bulunmayan event'ler gönderiliyor |
47
47
 
@@ -303,7 +303,16 @@ Sunucuyu uygulamanın MCP ayarlarına ekleyin. Claude Desktop'ta bu Settings →
303
303
  }
304
304
  ```
305
305
 
306
- Tarayıcıyı bir kez `npx -y playwright@1.56.1 install chromium` ile kurun. Gemini CLI, `~/.gemini/settings.json` içinde aynı `mcpServers` bloğunu kullanır. Claude Code için: `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`. Uygulamayı yeniden başlatın, ai-tag-debugger araçları görünür. Uygulama `npx`'i bulamazsa tam yolunu kullanın (`which npx`; örneğin Homebrew kurulu bir Mac'te `/opt/homebrew/bin/npx`). Raporlar `~/ai-tag-debugger-reports` klasörüne yazılır.
306
+ Tarayıcı (Chromium), ilk denetimi çalıştırdığınızda kendiliğinden iner (bir kez, yaklaşık bir dakika). Önceden kurmak için: `npx -y playwright@1.56.1 install chromium`.
307
+
308
+ Tek tıkla ve tek satırla kurulum:
309
+
310
+ - **Claude Code:** `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`
311
+ - **Cursor:** [Cursor'a ekle](https://cursor.com/en/install-mcp?name=ai-tag-debugger&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhbmFsaXRpa2lzbGVyL2FpLXRhZy1kZWJ1Z2dlciIsIm1jcCJdfQ==)
312
+ - **VS Code:** [VS Code'a kur](https://insiders.vscode.dev/redirect/mcp/install?name=ai-tag-debugger&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40analitikisler%2Fai-tag-debugger%22%2C%22mcp%22%5D%7D) veya `code --add-mcp '{"name":"ai-tag-debugger","command":"npx","args":["-y","@analitikisler/ai-tag-debugger","mcp"]}'`
313
+ - **Claude Desktop, Gemini CLI ve diğerleri:** yukarıdaki JSON bloğu. Gemini CLI, `~/.gemini/settings.json` içinde aynı `mcpServers` bloğunu kullanır.
314
+
315
+ Uygulamayı yeniden başlatın, ai-tag-debugger araçları görünür. Uygulama `npx`'i bulamazsa tam yolunu kullanın (`which npx`; örneğin Homebrew kurulu bir Mac'te `/opt/homebrew/bin/npx`). Raporlar `~/ai-tag-debugger-reports` klasörüne yazılır.
307
316
 
308
317
  Kaynak koddan mı çalıştırıyorsunuz? `"command": "node"` ve `"args": ["/path/to/ai-tag-debugger/src/cli.js", "mcp"]` kullanın.
309
318
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.8",
3
+ "mcpName": "io.github.analitikisler/ai-tag-debugger",
4
+ "version": "0.8.0",
4
5
  "description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
5
6
  "type": "module",
6
7
  "bin": {
package/src/agent.js CHANGED
@@ -14,7 +14,7 @@ How to work:
14
14
  - Do one action per call and read the new page before the next action.
15
15
  - Elements on screen are listed first. If what you need isn't listed, scroll, or use text instead of ref to act on an element by its visible text or label (e.g. text "Rezervasyon Yap").
16
16
  - Date pickers and custom dropdowns usually need clicks rather than fill: click the field, then click the date or option.
17
- - If a cookie or consent banner appears, accept all cookies (as most visitors do) and set accepts_consent to true on that click.
17
+ - If a cookie or consent banner appears, accept all cookies (as most visitors do) and set accepts_consent to true on that click. (A journey whose goal says to reject or decline cookies does that instead and sets rejects_consent to true on that click; if the banner has no reject-all button, use the one that declines non-essential cookies, and never accept.)
18
18
  - Use obvious test data in forms: name "Test User", email "qa-test@example.com", phone "5550100", address "1 Test Street", city "Testville", postcode "00000".
19
19
  - Never enter real payment card details. If the site offers a test or demo payment option, use it. If completing the journey would require a real payment or a real account you don't have, stop and call finish with success false and explain why.
20
20
  - If something is broken (an error, a button that does nothing), try a reasonable alternative once, then finish with success false and describe what happened.
@@ -39,6 +39,7 @@ export const TOOLS = [
39
39
  url: { type: "string", description: "Destination for goto." },
40
40
  reason: { type: "string", description: "Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report." },
41
41
  accepts_consent: { type: "boolean", description: "True only when this click accepts the cookie or consent banner." },
42
+ rejects_consent: { type: "boolean", description: "True only when this click rejects or declines all cookies on the consent banner." },
42
43
  },
43
44
  required: ["action", "reason"],
44
45
  },
@@ -215,6 +216,8 @@ export function collect(session, journey, viewport) {
215
216
  hits: session.hits,
216
217
  steps: session.steps,
217
218
  consentStep: session.consentStep,
219
+ rejectStep: session.rejectStep,
220
+ cookieLog: session.cookieLog,
218
221
  dataLayerLog: session.dataLayerLog,
219
222
  network: session.network,
220
223
  providers: session.providers,
package/src/browser.js CHANGED
@@ -1,10 +1,33 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
1
3
  import { readFile, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
2
5
  import { chromium, devices } from "playwright";
3
6
  import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders } from "./parsers.js";
4
7
  import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
5
8
  import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
6
9
  import { startEgressProxy } from "./egress.js";
7
10
 
11
+ // Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
12
+ // one of these always blocks the journey, and a reject click is checked against them afterwards.
13
+ let browserInstall = null;
14
+ /** Downloads Chromium with Playwright's own installer. Output goes to stderr, because stdout carries the MCP protocol. */
15
+ export function installBrowser() {
16
+ browserInstall ??= new Promise((resolve, reject) => {
17
+ const cli = path.join(path.dirname(createRequire(import.meta.url).resolve("playwright/package.json")), "cli.js");
18
+ console.error("ai-tag-debugger: installing the Chromium browser (one time, about a minute)...");
19
+ const child = spawn(process.execPath, [cli, "install", "chromium"], { stdio: ["ignore", 2, 2] });
20
+ child.on("error", reject);
21
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Installing the browser failed (exit code ${code}). Run: npx -y playwright@1.56.1 install chromium`))));
22
+ }).finally(() => { browserInstall = null; });
23
+ return browserInstall;
24
+ }
25
+
26
+ export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk, #CybotCookiebotDialog, #usercentrics-root, #didomi-host, #cookiescript_injected, .cky-consent-container, .cc-window, #cmpbox, .qc-cmp2-container, \
27
+ #truste-consent-track, #consent_blackbar, .osano-cm-window, #iubenda-cs-banner, .iubenda-cs-container, #cmplz-cookiebanner-container, .cmplz-cookiebanner, #cookiefirst-root, .cookiefirst-root, #axeptio_overlay, \
28
+ #sp_message_container, [id^="sp_message_container_"], #cookie-law-info-bar, #BorlabsCookieBox, .klaro, #termly-code-snippet-support, #cookie-information-template-wrapper, #coiOverlay, \
29
+ #cmpwrapper, #cmpbox2, .fc-consent-root, #ppms_cm_popup_overlay, [aria-label*="cookie" i][role=dialog]`;
30
+
8
31
  // Caps on what one audit records, so a looping page can't grow memory without limit.
9
32
  const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
10
33
 
@@ -341,6 +364,8 @@ export class AuditSession {
341
364
  this.hits = [];
342
365
  this.steps = [];
343
366
  this.consentStep = null;
367
+ this.rejectStep = null;
368
+ this.cookieLog = []; // each cookie name and domain with the step it was first seen at (never values)
344
369
  this.errors = [];
345
370
  this.dataLayerLog = [];
346
371
  this.network = [];
@@ -369,7 +394,13 @@ export class AuditSession {
369
394
  // flight once they are removed at the end, go to a proxy that isn't there.
370
395
  launch.proxy = { server: "http://127.0.0.1:9", bypass: "<-loopback>" };
371
396
  }
372
- this.browser = await chromium.launch(launch);
397
+ try {
398
+ this.browser = await chromium.launch(launch);
399
+ } catch (err) {
400
+ if (!/Executable doesn't exist|playwright install/i.test(String(err?.message))) throw err;
401
+ await installBrowser();
402
+ this.browser = await chromium.launch(launch);
403
+ }
373
404
  const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
374
405
  this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
375
406
  await this.context.exposeBinding("__atdRecord", (_source, entry) => {
@@ -717,7 +748,7 @@ export class AuditSession {
717
748
  if (!/intercepts pointer events|not stable|outside of the viewport/.test(err.message)) throw err;
718
749
  }
719
750
  const cover = await locator
720
- .evaluate((el) => {
751
+ .evaluate((el, selectors) => {
721
752
  const r = el.getBoundingClientRect();
722
753
  const x = Math.min(Math.max(r.left + r.width / 2, 0), window.innerWidth - 1);
723
754
  const y = Math.min(Math.max(r.top + r.height / 2, 0), window.innerHeight - 1);
@@ -742,13 +773,13 @@ export class AuditSession {
742
773
  const modal = n.matches("dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true]");
743
774
  // Cookie and consent banners block even when they cover only part of the screen.
744
775
  const consent =
745
- n.matches("#onetrust-banner-sdk, #onetrust-consent-sdk, #CybotCookiebotDialog, #usercentrics-root, #didomi-host, #cookiescript_injected, .cky-consent-container, .cc-window, #cmpbox, .qc-cmp2-container") ||
776
+ n.matches(selectors) ||
746
777
  (/(cookie|consent|gdpr|kvkk)/i.test(`${n.id} ${typeof n.className === "string" ? n.className : ""}`) && ["fixed", "sticky"].includes(getComputedStyle(n).position));
747
778
  if (consent) return { blocking: true, what: describe(n) };
748
779
  if (modal || b.width * b.height >= screen * 0.5) return { blocking: true, what: describe(n) };
749
780
  }
750
781
  return { blocking: false, what: describe(hit), free };
751
- })
782
+ }, CMP_BANNER_SELECTORS)
752
783
  .catch(() => null);
753
784
  if (cover?.blocking) {
754
785
  throw new Error(`The element is covered by ${cover.what}. Deal with that first (for example accept or close it), as a visitor would.`);
@@ -770,10 +801,11 @@ export class AuditSession {
770
801
  /**
771
802
  * Performs one action and records it as a step. Returns an error message
772
803
  * instead of throwing, so the agent can recover.
773
- * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
804
+ * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean, rejects_consent?: boolean }} a
774
805
  */
775
806
  async act(a) {
776
807
  const error = await this.perform(a);
808
+ await this.trackCookies();
777
809
  // A scripted replay of this step may fail the same way, which must not end the journey there.
778
810
  if (error) this.steps[this.currentStep].error = error;
779
811
  return error;
@@ -781,7 +813,7 @@ export class AuditSession {
781
813
 
782
814
  async perform(a) {
783
815
  const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
784
- this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
816
+ this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent, rejectsConsent: !!a.rejects_consent });
785
817
  const step = this.steps[this.currentStep];
786
818
  try {
787
819
  switch (a.action) {
@@ -843,12 +875,28 @@ export class AuditSession {
843
875
  const blocked = await this.enforce();
844
876
  if (blocked) return blocked;
845
877
  if (a.accepts_consent && this.consentStep === null) this.consentStep = this.currentStep;
878
+ if (a.rejects_consent && this.rejectStep === null) {
879
+ this.rejectStep = this.currentStep;
880
+ // A rejected banner should close; one that is still showing means the button did nothing.
881
+ step.bannerOpenAfter = await this.page.evaluate((sel) => [...document.querySelectorAll(sel)].some((n) => {
882
+ const r = n.getBoundingClientRect();
883
+ return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
884
+ }), CMP_BANNER_SELECTORS).catch(() => false);
885
+ }
846
886
  return null;
847
887
  } catch (err) {
848
888
  return (await this.enforce().catch(() => null)) ?? err.message.split("\n")[0];
849
889
  }
850
890
  }
851
891
 
892
+ /** Notes which cookies appeared during the step just taken (names and domains only). */
893
+ async trackCookies() {
894
+ const seen = new Set(this.cookieLog.map((c) => `${c.domain}|${c.name}`));
895
+ for (const c of await this.cookies()) {
896
+ if (!seen.has(`${c.domain}|${c.name}`) && this.cookieLog.length < 500) this.cookieLog.push({ ...c, step: this.currentStep });
897
+ }
898
+ }
899
+
852
900
  async cookies() {
853
901
  let timer;
854
902
  const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve([]), 10_000)));
package/src/checks.js CHANGED
@@ -6,16 +6,63 @@ import { isOwnAddress } from "./redact.js";
6
6
 
7
7
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
8
8
  const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
9
+ // Consent Mode v2 added two signals to ad_storage and analytics_storage. Google requires them for EEA/UK traffic.
10
+ const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
11
+ const TRACKING_COOKIE = /^(_ga(_.+)?|_gid|_gat(_.+)?|_gcl_.+|_fbp|_fbc|_ttp|_tt_.+|_uet(sid|vid)|_clck|_clsk|_hj.+|_pin_unauth|_scid|IDE|test_cookie|fr|li_.+|_dc_gtm_.+)$/;
9
12
  const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
10
13
 
11
14
  /**
12
- * @typedef {{ platform: string, name: string, required_params?: string[], journeys?: string[], allow_multiple?: boolean }} PlanEvent
15
+ * @typedef {{ platform: string, name: string, aliases?: string[], required_params?: string[], journeys?: string[], allow_multiple?: boolean }} PlanEvent
13
16
  * @typedef {{ consent_mode?: boolean, events: PlanEvent[] }} Plan
14
17
  * @typedef {{ severity: "broken"|"risk"|"warning"|"info"|"ok", check: string, journey: string, viewport: string, platform?: string, event?: string, step?: string, detail: string, evidence?: string }} Finding
15
18
  */
16
19
 
17
20
  const key = (platform, name) => `${platform}:${name}`;
18
21
 
22
+ // Sites name events in their own way: Elevar's dl_add_to_cart, UA-style addToCart, Add To Cart.
23
+ // Names are compared without case, spaces, dashes, underscores and a leading "dl", then through
24
+ // SYNONYMS to one canonical name. A plan event can add its own names with "aliases".
25
+ const SYNONYMS = {
26
+ viewitem: ["productview", "productdetail", "productdetails", "detail", "viewproduct", "viewcontent"],
27
+ viewitemlist: ["productlist", "viewproductlist", "productimpressions", "viewcollection", "viewcategory"],
28
+ addtocart: ["add", "addtobasket", "addtobag", "cartadd"],
29
+ removefromcart: ["remove", "removefrombasket", "removefrombag", "cartremove"],
30
+ viewcart: ["cartview", "viewbasket", "viewbag"],
31
+ begincheckout: ["checkout", "initiatecheckout", "checkoutstart", "checkoutbegin"],
32
+ addshippinginfo: ["checkoutshipping", "shippinginfo"],
33
+ addpaymentinfo: ["checkoutpayment", "paymentinfo", "addpayment"],
34
+ purchase: ["transaction", "ordercomplete", "ordercompleted", "orderconfirmation", "checkoutcomplete", "checkoutcompleted"],
35
+ };
36
+ const CANONICAL = new Map(Object.entries(SYNONYMS).flatMap(([c, list]) => list.map((a) => [a, c])));
37
+
38
+ /** One comparable form of an event name. */
39
+ export function canonicalEvent(name) {
40
+ let n = String(name ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
41
+ if (n.startsWith("dl") && n.length > 2) n = n.slice(2);
42
+ return CANONICAL.get(n) ?? n;
43
+ }
44
+
45
+ /** Whether a captured hit is the planned event, by its exact name, an alias or a common variant. */
46
+ export function hitMatchesEvent(ev, hit) {
47
+ if (hit.platform !== ev.platform) return false;
48
+ if (hit.name === ev.name) return true;
49
+ const h = canonicalEvent(hit.name);
50
+ return h === canonicalEvent(ev.name) || (ev.aliases ?? []).some((a) => canonicalEvent(a) === h);
51
+ }
52
+
53
+ // Ecommerce parameters sites send under another name (UA-style products/revenue, order ids).
54
+ const PARAM_ALIASES = {
55
+ items: ["products", "item_list", "contents"],
56
+ value: ["revenue", "total"],
57
+ transaction_id: ["order_id", "orderid", "oid"],
58
+ currency: ["currency_code", "cu"],
59
+ };
60
+
61
+ /** Whether a hit carries a required parameter, under its own name or a common alias. */
62
+ export function hasParam(hit, name) {
63
+ return [name, ...(PARAM_ALIASES[name] ?? [])].some((n) => hit.params?.[n] !== undefined && hit.params[n] !== "");
64
+ }
65
+
19
66
  /** The page load a hit came from: GA4's page load id (_p) when it has one, else the page address. */
20
67
  function pageOf(hit) {
21
68
  try {
@@ -79,7 +126,8 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
79
126
  if (skipped.length) findings.push({ ...base, severity: "info", check: "planned_out_of_scope", detail: d.plannedOutOfScope(skipped.map((e) => key(e.platform, e.name))) });
80
127
  }
81
128
  const stepLabel = (i) => run.steps[i]?.label ?? `step ${i}`;
82
- const planByKey = new Map(plan.events.map((e) => [key(e.platform, e.name), e]));
129
+ const planEventFor = (hit) => plan.events.find((e) => hitMatchesEvent(e, hit));
130
+ const planKeyOf = (hit) => { const e = planEventFor(hit); return e ? key(e.platform, e.name) : key(hit.platform, hit.name); };
83
131
 
84
132
  if (!run.completed) {
85
133
  findings.push({ ...base, severity: "warning", check: "journey_incomplete", detail: d.journeyIncomplete(run.note) });
@@ -88,7 +136,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
88
136
  // Expected events that never fired.
89
137
  for (const ev of plan.events) {
90
138
  if (!expectedIn(ev, run.journeyId)) continue;
91
- const fired = run.hits.some((h) => h.platform === ev.platform && h.name === ev.name);
139
+ const fired = run.hits.some((h) => hitMatchesEvent(ev, h));
92
140
  if (!fired) {
93
141
  findings.push({
94
142
  ...base,
@@ -104,9 +152,9 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
104
152
  // Required parameters missing on events that did fire (reported once per event).
105
153
  const paramsReported = new Set();
106
154
  for (const hit of run.hits) {
107
- const ev = planByKey.get(key(hit.platform, hit.name));
155
+ const ev = planEventFor(hit);
108
156
  if (!ev?.required_params?.length) continue;
109
- const missing = ev.required_params.filter((p) => hit.params[p] === undefined || hit.params[p] === "");
157
+ const missing = ev.required_params.filter((p) => !hasParam(hit, p));
110
158
  const reportKey = `${key(hit.platform, hit.name)}|${missing.join(",")}`;
111
159
  if (missing.length && !paramsReported.has(reportKey)) {
112
160
  paramsReported.add(reportKey);
@@ -127,14 +175,18 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
127
175
  // new page can also carry the previous page's last hits, so the page is part of the key.
128
176
  const perStep = new Map();
129
177
  for (const hit of run.hits) {
130
- const k = `${hit.step}|${key(hit.platform, hit.name)}|${pageOf(hit)}`;
178
+ const k = `${hit.step}|${planKeyOf(hit)}|${pageOf(hit)}`;
131
179
  perStep.set(k, [...(perStep.get(k) ?? []), hit]);
132
180
  }
133
181
  for (const [, hits] of perStep) {
134
- const ev = planByKey.get(key(hits[0].platform, hits[0].name));
182
+ const ev = planEventFor(hits[0]);
135
183
  if (hits.length < 2 || ev?.allow_multiple) continue;
136
184
  // TikTok sends unnamed pings (no event name) on its own schedule, so their count varies between runs.
137
185
  if (hits[0].platform === "tiktok" && hits[0].name === "(unnamed)") continue;
186
+ // Hits that repeat an event ID are one event to the platform (it keeps the first), so only
187
+ // distinct IDs, and hits without an ID, count as separate sends.
188
+ const sends = new Set(hits.map((h, i) => (h.eventId ? `id:${h.eventId}` : `n:${i}`))).size;
189
+ if (sends < 2) continue;
138
190
  findings.push({
139
191
  ...base,
140
192
  severity: "warning",
@@ -142,10 +194,32 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
142
194
  platform: hits[0].platform,
143
195
  event: hits[0].name,
144
196
  step: stepLabel(hits[0].step),
145
- detail: d.duplicate(hits[0].name, hits.length),
197
+ detail: d.duplicate(hits[0].name, sends),
146
198
  });
147
199
  }
148
200
 
201
+ // Server-side tagging. Only what the browser can see: a GA4 hit to a server container, and requests
202
+ // to Stape or Elevar. Meta's and TikTok's server-side copies never pass through the browser.
203
+ const serverHits = run.hits.filter((h) => h.delivery === "server");
204
+ if (serverHits.length) {
205
+ const names = (list) => [...new Set(list.map((h) => h.name))];
206
+ const byPlatform = [...new Set(run.hits.map((h) => h.platform))].map((platform) => {
207
+ const own = run.hits.filter((h) => h.platform === platform);
208
+ return { platform, server: names(own.filter((h) => h.delivery === "server")), browser: names(own.filter((h) => h.delivery !== "server")) };
209
+ }).filter((p) => p.server.length);
210
+ for (const p of byPlatform) findings.push({ ...base, severity: "info", check: "server_side", platform: p.platform, detail: d.serverSide(p.platform, p.server, p.browser) });
211
+ }
212
+ const serverVendors = [...new Set((run.network ?? []).map((c) => c.provider).filter((p) => p === "stape" || p === "elevar"))];
213
+ if (serverVendors.length || serverHits.length) {
214
+ const noId = new Map();
215
+ for (const hit of run.hits) {
216
+ if ((hit.platform === "meta" || hit.platform === "tiktok") && !hit.eventId && !noId.has(key(hit.platform, hit.name))) noId.set(key(hit.platform, hit.name), hit);
217
+ }
218
+ for (const [, hit] of noId) {
219
+ findings.push({ ...base, severity: "info", check: "dedup_id_missing", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.dedupIdMissing(hit.platform, hit.name) });
220
+ }
221
+ }
222
+
149
223
  // Consent.
150
224
  const granted = consentGrantedStep(run);
151
225
  const firstHitStep = run.hits.length ? Math.min(...run.hits.map((h) => h.step)) : null;
@@ -157,8 +231,63 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
157
231
  findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
158
232
  }
159
233
  }
234
+ const defaults = run.dataLayerLog.filter((e) => isConsentCommand(e, "default"));
235
+ if (plan.consent_mode && defaults.length) {
236
+ const declared = new Set(defaults.flatMap((e) => Object.keys(e.value[2] ?? {})));
237
+ const missing = V2_SIGNALS.filter((k) => !declared.has(k));
238
+ if (missing.length) findings.push({ ...base, severity: "risk", check: "consent_v2_missing", detail: d.consentV2Missing(missing) });
239
+ }
240
+ if (granted !== null) {
241
+ const updates = run.dataLayerLog.filter((e) => isConsentCommand(e, "update"));
242
+ if (updates.length) {
243
+ const grantedKeys = new Set(updates.flatMap((e) => Object.entries(e.value[2] ?? {}).filter(([, v]) => v === "granted").map(([k]) => k)));
244
+ const missing = V2_SIGNALS.filter((k) => !grantedKeys.has(k));
245
+ if (missing.length) findings.push({ ...base, severity: "warning", check: "consent_v2_update", detail: d.consentV2Update(missing) });
246
+ }
247
+ }
248
+
249
+ // Declining: nothing may be sent or stored for ads after the visitor said no.
250
+ const rejectStep = run.rejectStep ?? null;
251
+ if (rejectStep !== null) {
252
+ const reject = run.steps.find((s) => s.index === rejectStep);
253
+ const regranted = run.dataLayerLog.find(
254
+ (e) => e.step >= rejectStep && isConsentCommand(e, "update") && Object.values(e.value[2] ?? {}).includes("granted"),
255
+ );
256
+ if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
257
+ else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
258
+ const afterReject = new Map();
259
+ for (const hit of run.hits) {
260
+ if (hit.step < rejectStep) continue;
261
+ const claimsGranted = hit.consent?.ad_storage === "granted" || hit.consent?.analytics_storage === "granted";
262
+ if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted) {
263
+ const k = key(hit.platform, hit.name);
264
+ if (!afterReject.has(k)) afterReject.set(k, hit);
265
+ }
266
+ }
267
+ for (const [, hit] of afterReject) {
268
+ findings.push({ ...base, severity: "broken", check: "after_reject", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.afterReject(hit.platform, hit.name) });
269
+ }
270
+ }
271
+
272
+ // Cookies set before the visitor chose (or after they said no).
273
+ const cookieCutoff = rejectStep ?? granted;
274
+ const earlyCookies = (run.cookieLog ?? []).filter((c) => TRACKING_COOKIE.test(c.name) && (cookieCutoff === null || c.step < cookieCutoff));
275
+ if (earlyCookies.length) {
276
+ const names = [...new Set(earlyCookies.map((c) => c.name))];
277
+ findings.push({ ...base, severity: "risk", check: "cookies_before_consent", step: stepLabel(Math.min(...earlyCookies.map((c) => c.step))), detail: granted === null && rejectStep === null ? d.cookiesNeverConsent(names) : d.cookiesBeforeConsent(names) });
278
+ }
279
+ if (rejectStep !== null) {
280
+ const lateCookies = (run.cookieLog ?? []).filter((c) => TRACKING_COOKIE.test(c.name) && c.step >= rejectStep);
281
+ if (lateCookies.length) {
282
+ const names = [...new Set(lateCookies.map((c) => c.name))];
283
+ findings.push({ ...base, severity: "broken", check: "cookies_after_reject", step: stepLabel(Math.min(...lateCookies.map((c) => c.step))), detail: d.cookiesAfterReject(names) });
284
+ }
285
+ }
286
+
160
287
  const beforeConsent = new Map();
161
288
  for (const hit of run.hits) {
289
+ // Hits from the reject click on are covered by the after_reject check.
290
+ if (rejectStep !== null && hit.step >= rejectStep) continue;
162
291
  const early = granted === null || hit.step < granted;
163
292
  if (!early) continue;
164
293
  const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
@@ -207,14 +336,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
207
336
  }
208
337
 
209
338
  // Events that are not in the plan.
210
- const unplanned = [...new Set(run.hits.filter((h) => !planByKey.has(key(h.platform, h.name))).map((h) => key(h.platform, h.name)))];
339
+ const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h)).map((h) => key(h.platform, h.name)))];
211
340
  if (unplanned.length) {
212
341
  findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
213
342
  }
214
343
 
215
344
  // Planned events that fired cleanly.
216
345
  const problemKeys = new Set(findings.filter((f) => f.event).map((f) => key(f.platform, f.event)));
217
- const okKeys = [...new Set(run.hits.map((h) => key(h.platform, h.name)))].filter((k) => planByKey.has(k) && !problemKeys.has(k));
346
+ const okKeys = [...new Set(run.hits.filter((h) => planEventFor(h)).map((h) => key(h.platform, h.name)))].filter((k) => !problemKeys.has(k));
218
347
  if (okKeys.length) {
219
348
  findings.push({ ...base, severity: "ok", check: "matches_plan", detail: d.matches(okKeys) });
220
349
  }
package/src/i18n.js CHANGED
@@ -61,6 +61,7 @@ const en = {
61
61
  customItemParams: "Custom item parameters",
62
62
  itemsCount: (n) => `${n} ${n === 1 ? "item" : "items"}`,
63
63
  noItems: "no items",
64
+ serverSideTag: "server-side",
64
65
  source: "Source",
65
66
  request: "Request",
66
67
  noRequest: "No matching request",
@@ -120,10 +121,20 @@ const en = {
120
121
  missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so this may not be a tracking bug.`,
121
122
  missingParams: (name, params) => `${q(name)} was sent without ${params.map(q).join(", ")}.`,
122
123
  duplicate: (name, n) => `${q(name)} was sent ${n} times by one action, which inflates counts and conversions.`,
124
+ serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}. Server-side copies of Meta and TikTok events never pass through the browser, so they can't be seen here.`,
125
+ dedupIdMissing: (platform, name) => `${platform} ${q(name)} was sent from the browser without an event ID, while a server-side setup was seen on this site. If the server also sends this event, ${platform} can't match the two and counts it twice.`,
123
126
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
124
127
  consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
125
128
  beforeConsentNever: (platform, name) => `${platform} ${q(name)} fired although consent was never given in this journey.`,
126
129
  beforeConsent: (platform, name) => `${platform} ${q(name)} fired before the visitor accepted cookies.`,
130
+ consentV2Missing: (keys) => `The Consent Mode default does not set ${keys.map(q).join(" and ")}. Consent Mode v2 needs them for Google ads in the EEA and UK; without them ad personalization and conversion measurement can stop.`,
131
+ consentV2Update: (keys) => `After the visitor accepted, the Consent Mode update did not grant ${keys.map(q).join(" and ")}. Ad conversions and remarketing may stay off.`,
132
+ rejectIgnored: () => "A Consent Mode update granted consent after the visitor rejected cookies, so the reject button does not stick.",
133
+ rejectButtonFailed: () => "The cookie banner was still showing after the reject button was clicked.",
134
+ afterReject: (platform, name) => `${platform} ${q(name)} was still sent after the visitor rejected cookies.`,
135
+ cookiesBeforeConsent: (names) => `Tracking cookies were set before the visitor accepted: ${names.join(", ")}.`,
136
+ cookiesNeverConsent: (names) => `Tracking cookies were set although consent was never given in this journey: ${names.join(", ")}.`,
137
+ cookiesAfterReject: (names) => `Tracking cookies were set after the visitor rejected cookies: ${names.join(", ")}.`,
127
138
  pii: (platform, name) => `An email address was sent in plain text with ${q(name)} to ${platform}.`,
128
139
  unplanned: (list) => `Events sent that are not in the measurement plan: ${list.join(", ")}.`,
129
140
  piiUnhashed: (platform, name, fields) => `${q(name)} sent user data to ${platform} without hashing it (${fields.join(", ")}). Ad platforms expect these values SHA-256 hashed.`,
@@ -192,6 +203,7 @@ const tr = {
192
203
  customItemParams: "Özel ürün parametreleri",
193
204
  itemsCount: (n) => `${n} ürün`,
194
205
  noItems: "ürün yok",
206
+ serverSideTag: "sunucu tarafı",
195
207
  source: "Kaynak",
196
208
  request: "İstek",
197
209
  noRequest: "Eşleşen istek yok",
@@ -251,10 +263,20 @@ const tr = {
251
263
  missingEventEarly: (name) => `${q(name)} görülmedi, ancak yolculuk erken durduğu için bu bir izleme hatası olmayabilir.`,
252
264
  missingParams: (name, params) => `${q(name)} olayı ${params.map(q).join(", ")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
253
265
  duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi; bu da sayıları ve dönüşümleri şişirir.`,
266
+ serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}. Meta ve TikTok olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.`,
267
+ dedupIdMissing: (platform, name) => `${platform} ${q(name)} tarayıcıdan olay kimliği olmadan gönderildi; sitede sunucu tarafı bir kurulum görüldü. Sunucu da bu olayı gönderiyorsa ${platform} ikisini eşleştiremez ve iki kez sayar.`,
254
268
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
255
269
  consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
256
270
  beforeConsentNever: (platform, name) => `${platform} ${q(name)}, bu yolculukta hiç onay verilmemesine rağmen tetiklendi.`,
257
271
  beforeConsent: (platform, name) => `${platform} ${q(name)}, ziyaretçi çerezleri kabul etmeden önce tetiklendi.`,
272
+ consentV2Missing: (keys) => `Consent Mode varsayılanı ${keys.map(q).join(" ve ")} değerlerini ayarlamıyor. Consent Mode v2, AEA ve Birleşik Krallık'taki Google reklamları için bunları ister; yoksa reklam kişiselleştirme ve dönüşüm ölçümü durabilir.`,
273
+ consentV2Update: (keys) => `Ziyaretçi kabul ettikten sonra Consent Mode güncellemesi ${keys.map(q).join(" ve ")} iznini vermedi. Reklam dönüşümleri ve yeniden pazarlama kapalı kalabilir.`,
274
+ rejectIgnored: () => "Ziyaretçi çerezleri reddettikten sonra bir Consent Mode güncellemesi izin verdi; reddet düğmesi işe yaramıyor.",
275
+ rejectButtonFailed: () => "Reddet düğmesine tıklandıktan sonra çerez bandı hâlâ görünüyordu.",
276
+ afterReject: (platform, name) => `${platform} ${q(name)}, ziyaretçi çerezleri reddettikten sonra da gönderildi.`,
277
+ cookiesBeforeConsent: (names) => `Ziyaretçi kabul etmeden önce izleme çerezleri yazıldı: ${names.join(", ")}.`,
278
+ cookiesNeverConsent: (names) => `Bu yolculukta hiç onay verilmemesine rağmen izleme çerezleri yazıldı: ${names.join(", ")}.`,
279
+ cookiesAfterReject: (names) => `Ziyaretçi çerezleri reddettikten sonra izleme çerezleri yazıldı: ${names.join(", ")}.`,
258
280
  pii: (platform, name) => `${q(name)} ile birlikte ${platform} platformuna düz metin olarak bir e-posta adresi gönderildi.`,
259
281
  unplanned: (list) => `Ölçüm planında olmayan olaylar gönderildi: ${list.join(", ")}.`,
260
282
  piiUnhashed: (platform, name, fields) => `${q(name)}, ${platform} platformuna kullanıcı verisini karma (hash) yapmadan gönderdi (${fields.join(", ")}). Reklam platformları bu değerleri SHA-256 ile karma yapılmış bekler.`,
package/src/journeys.js CHANGED
@@ -46,9 +46,13 @@ export const JOURNEY_PRESETS = {
46
46
  { platform: "meta", name: "Search" },
47
47
  ],
48
48
  },
49
+ decline_cookies: {
50
+ goal: "Reject all cookies on the cookie banner (the reject-all or decline button, never accept) and set rejects_consent to true on that click. Then browse the site like a visitor who said no: scroll the home page, open a product or article page, and open a second page. Stop there.",
51
+ events: [],
52
+ },
49
53
  };
50
54
 
51
- const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
55
+ const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
52
56
 
53
57
  /** The preset for a name like "purchase" or "signup", or null. */
54
58
  export function presetFor(name) {
package/src/mcp.js CHANGED
@@ -9,6 +9,7 @@ import { createServer } from "node:http";
9
9
  import { readFile } from "node:fs/promises";
10
10
  import { homedir } from "node:os";
11
11
  import path from "node:path";
12
+ import { readFileSync } from "node:fs";
12
13
  import { z } from "zod";
13
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -22,7 +23,7 @@ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
22
23
  import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
23
24
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
24
25
 
25
- const VERSION = "0.6.3";
26
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
26
27
  const MAX_OPEN_AUDITS = 4;
27
28
  const IDLE_MS = 15 * 60 * 1000; // an audit untouched this long is closed
28
29
  const MAX_BODY_BYTES = 1024 * 1024;
@@ -174,7 +175,7 @@ export function buildServer(opts) {
174
175
  {
175
176
  title: "Act in the audit browser",
176
177
  description:
177
- "Performs one action in the audit's browser and returns the updated page plus the tracking hits that action sent. Actions: goto (url), click (ref or text), fill (ref or text, value), select (ref or text, value), scroll (value = down or up), wait (value = milliseconds). Refs come from the latest page view, which lists on-screen elements first; if an element has no ref, scroll or pass its visible text in text. If a cookie banner appears, accept it and set accepts_consent to true on that click. Use test data in forms and never enter real payment details.",
178
+ "Performs one action in the audit's browser and returns the updated page plus the tracking hits that action sent. Actions: goto (url), click (ref or text), fill (ref or text, value), select (ref or text, value), scroll (value = down or up), wait (value = milliseconds). Refs come from the latest page view, which lists on-screen elements first; if an element has no ref, scroll or pass its visible text in text. If a cookie banner appears, accept it and set accepts_consent to true on that click (or, on a journey that is about declining cookies, reject it and set rejects_consent to true). Use test data in forms and never enter real payment details.",
178
179
  inputSchema: {
179
180
  audit_id: z.string(),
180
181
  action: z.enum(["goto", "click", "fill", "select", "scroll", "wait"]),
@@ -184,6 +185,7 @@ export function buildServer(opts) {
184
185
  url: z.string().optional().describe("Destination for goto, absolute or relative to the current page."),
185
186
  reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
186
187
  accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
188
+ rejects_consent: z.boolean().optional().describe("True only when this click rejects or declines all cookies on the consent banner."),
187
189
  },
188
190
  },
189
191
  async (input) => {
package/src/parsers.js CHANGED
@@ -36,6 +36,17 @@ const GOOGLE_COMPANION_HOST = /^(www\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$|(^|\.)d
36
36
  */
37
37
  const isGa4Path = (u) => u.pathname.endsWith("/g/collect") || (/\/g\/c$/.test(u.pathname) && /^G-[A-Z0-9]+$/.test(u.searchParams.get("tid") ?? ""));
38
38
 
39
+ const GOOGLE_PRIMARY_HOST = /(^|\.)(google-analytics\.com|analytics\.google\.com|googletagmanager\.com)$/;
40
+
41
+ /**
42
+ * Where a GA4 hit goes. "browser": Google's own hosts, or the short /g/c path of Google tag gateway
43
+ * (which only forwards to Google). "server": /g/collect on any other host, which is a server
44
+ * container (server-side GTM, Stape, Elevar) answering for the site.
45
+ */
46
+ function ga4Delivery(u) {
47
+ return GOOGLE_PRIMARY_HOST.test(u.hostname) || /\/g\/c$/.test(u.pathname) ? "browser" : "server";
48
+ }
49
+
39
50
  /** @returns {Hit[] | null} */
40
51
  export function parseGa4(url, postData) {
41
52
  const u = new URL(url);
@@ -55,6 +66,7 @@ export function parseGa4(url, postData) {
55
66
  const user = [...merged].filter(([k]) => isUserDataKey(k));
56
67
  return {
57
68
  platform: "ga4",
69
+ delivery: ga4Delivery(u),
58
70
  name: merged.get("en") || "(unnamed)",
59
71
  id: merged.get("tid") || undefined,
60
72
  params: ga4ParamsFrom(merged),
@@ -75,7 +87,11 @@ export function parseMeta(url) {
75
87
  const m = k.match(/^cd\[(.+)\]$/);
76
88
  if (m) params[m[1]] = v;
77
89
  }
78
- return [{ platform: "meta", name: u.searchParams.get("ev") || "(unnamed)", id: u.searchParams.get("id") || undefined, params, url }];
90
+ // eid is the event ID a server-side (Conversions API) copy of the event must repeat for Meta to count it once.
91
+ const eventId = u.searchParams.get("eid") || undefined;
92
+ if (eventId) params.event_id = eventId;
93
+ const user = [...u.searchParams].filter(([k]) => isUserDataKey(k));
94
+ return [{ platform: "meta", delivery: "browser", name: u.searchParams.get("ev") || "(unnamed)", id: u.searchParams.get("id") || undefined, ...(eventId ? { eventId } : {}), params, ...(user.length ? { userFields: Object.fromEntries(user) } : {}), url }];
79
95
  }
80
96
 
81
97
  /** @returns {Hit[] | null} */
@@ -105,7 +121,8 @@ export function parseTikTok(url, postData) {
105
121
  const props = body.properties || body.context?.properties || {};
106
122
  const params = Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]));
107
123
  const user = Object.entries(body.context?.user ?? {}).filter(([k, v]) => isUserDataKey(`context.user.${k}`) && v);
108
- return [{ platform: "tiktok", name: body.event || "(unnamed)", id: body.context?.pixel?.code, params, ...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`context.user.${k}`, String(v)])) } : {}), url }];
124
+ const eventId = body.event_id ? String(body.event_id) : undefined;
125
+ return [{ platform: "tiktok", delivery: "browser", name: body.event || "(unnamed)", id: body.context?.pixel?.code, ...(eventId ? { eventId } : {}), params, ...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`context.user.${k}`, String(v)])) } : {}), url }];
109
126
  }
110
127
 
111
128
  /**
@@ -128,7 +145,67 @@ export function parseX(url) {
128
145
  return [{ platform: "x", name: name || "(unnamed)", id: q.get("txn_id") || undefined, params, url }];
129
146
  }
130
147
 
131
- const PARSERS = [parseGa4, parseMeta, parseGoogleAds, parseTikTok, parseX];
148
+ const jsonOr = (text, fallback) => { try { return JSON.parse(text); } catch { return fallback; } };
149
+ const scalars = (obj) => Object.fromEntries(Object.entries(obj && typeof obj === "object" ? obj : {}).filter(([, v]) => v !== null && typeof v !== "object").map(([k, v]) => [k, String(v)]));
150
+
151
+ /** Pinterest tag: ct.pinterest.com/v3/ with event, tid, ed (event data) and pd (hashed user data). @returns {Hit[] | null} */
152
+ export function parsePinterest(url) {
153
+ const u = new URL(url);
154
+ const q = u.searchParams;
155
+ if (u.hostname !== "ct.pinterest.com" || !/^\/v3\/?$/.test(u.pathname) || !q.has("event")) return null;
156
+ const params = scalars(jsonOr(q.get("ed") ?? "{}", {}));
157
+ if (q.has("event_id")) params.event_id = q.get("event_id");
158
+ const user = Object.entries(scalars(jsonOr(q.get("pd") ?? "{}", {}))).filter(([k, v]) => isUserDataKey(k) && v);
159
+ return [{ platform: "pinterest", name: q.get("event"), id: q.get("tid") || undefined, params, ...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`pd.${k}`, v])) } : {}), url }];
160
+ }
161
+
162
+ /** Snap Pixel: tr.snapchat.com/p (query or form body) with pid, ev, and price/currency/transaction fields. @returns {Hit[] | null} */
163
+ export function parseSnapchat(url, postData) {
164
+ const u = new URL(url);
165
+ if (u.hostname !== "tr.snapchat.com" || !/^\/(p|tr)\/?$/.test(u.pathname)) return null;
166
+ const q = new URLSearchParams(u.search);
167
+ if (postData && !postData.trim().startsWith("{")) for (const [k, v] of new URLSearchParams(postData)) q.set(k, v);
168
+ else if (postData) for (const [k, v] of Object.entries(scalars(jsonOr(postData, {})))) q.set(k, v);
169
+ if (!q.has("pid") && !q.has("ev")) return null;
170
+ const params = {};
171
+ for (const [from, to] of [["pr", "value"], ["cur", "currency"], ["tid", "transaction_id"], ["ic", "quantity"], ["ci", "content_ids"], ["u", "page_location"]]) {
172
+ if (q.has(from)) params[to] = q.get(from);
173
+ }
174
+ const user = [...q].filter(([k, v]) => isUserDataKey(k) && v);
175
+ return [{ platform: "snapchat", name: q.get("ev") || "(unnamed)", id: q.get("pid") || undefined, params, ...(user.length ? { userFields: Object.fromEntries(user) } : {}), url }];
176
+ }
177
+
178
+ /**
179
+ * Klaviyo onsite tracking: a JSON:API POST to a.klaviyo.com/client/events/ with the metric
180
+ * name, event properties and the profile it belongs to. Profile fields are user data.
181
+ * @returns {Hit[] | null}
182
+ */
183
+ export function parseKlaviyo(url, postData) {
184
+ const u = new URL(url);
185
+ if (u.hostname !== "a.klaviyo.com" || !/^\/client\/events\/?$/.test(u.pathname)) return null;
186
+ const attrs = jsonOr(postData ?? "{}", {})?.data?.attributes ?? {};
187
+ const profile = scalars(attrs.profile?.data?.attributes ?? attrs.profile);
188
+ const user = Object.entries(profile).filter(([k, v]) => isUserDataKey(k) && v);
189
+ return [{
190
+ platform: "klaviyo",
191
+ name: attrs.metric?.data?.attributes?.name ?? attrs.metric?.name ?? "(unnamed)",
192
+ id: u.searchParams.get("company_id") || undefined,
193
+ params: { ...scalars(attrs.properties), ...(attrs.value !== undefined ? { value: String(attrs.value) } : {}) },
194
+ ...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`profile.${k}`, v])) } : {}),
195
+ url,
196
+ }];
197
+ }
198
+
199
+ /** Hotjar: calls to its in.hotjar.com / .hotjar.io API carry the site id in the path. Only the id is read. @returns {Hit[] | null} */
200
+ export function parseHotjar(url) {
201
+ const u = new URL(url);
202
+ if (!/(^|\.)hotjar\.(com|io)$/.test(u.hostname)) return null;
203
+ const m = u.pathname.match(/^\/api\/v\d+\/client\/sites\/(\d+)\/([a-z-]+)/i);
204
+ if (!m) return null;
205
+ return [{ platform: "hotjar", name: m[2], id: m[1], params: {}, url }];
206
+ }
207
+
208
+ const PARSERS = [parseGa4, parseMeta, parseGoogleAds, parseTikTok, parseX, parsePinterest, parseSnapchat, parseKlaviyo, parseHotjar];
132
209
 
133
210
  /** Returns normalized hits for a request, or null when it is not a tracking request. */
134
211
  export function parseTrackingRequest(url, postData) {
@@ -184,6 +261,7 @@ export const PROVIDERS = {
184
261
  microsoft_ads: "Microsoft Ads",
185
262
  pinterest: "Pinterest",
186
263
  snapchat: "Snapchat",
264
+ klaviyo: "Klaviyo",
187
265
  clarity: "Microsoft Clarity",
188
266
  hotjar: "Hotjar",
189
267
  doubleclick: "DoubleClick",
@@ -191,9 +269,12 @@ export const PROVIDERS = {
191
269
  };
192
270
  export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
193
271
  // Tag managers and consent platforms are always shown: they explain how the tags loaded.
194
- const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform" };
272
+ // Server-side tagging providers are too: their requests show where events are sent from.
273
+ const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform", stape: "Stape", elevar: "Elevar" };
195
274
 
196
275
  const HOSTS = [
276
+ [/(^|\.)stape\.(io|jp)$/, "stape"],
277
+ [/(^|\.)getelevar\.com$/, "elevar"],
197
278
  [/(^|\.)(google-analytics|analytics\.google)\.com$/, "ga4"],
198
279
  [/(^|\.)googleadservices\.com$/, "google_ads"],
199
280
  [/(^|\.)googlesyndication\.com$/, "adsense"],
@@ -206,7 +287,8 @@ const HOSTS = [
206
287
  [/(^|\.)hotjar\.(com|io)$/, "hotjar"],
207
288
  [/(^|\.)(pinterest\.com|pinimg\.com)$/, "pinterest"],
208
289
  [/(^|\.)(snapchat\.com|sc-static\.net)$/, "snapchat"],
209
- [/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.eu|cookieyes\.com)$/, "consent"],
290
+ [/(^|\.)klaviyo\.com$/, "klaviyo"],
291
+ [/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.(eu|com)|cookieyes\.com|didomi\.io|privacy-center\.org|trustarc\.com|truste\.com|consensu\.org|quantcast\.com|osano\.com|iubenda\.com|termly\.io|cookiefirst\.com|axeptio\.eu|sourcepoint\.com|sp-prod\.net|consentmanager\.net|cookiescript\.com|cookie-script\.com|cookieinformation\.com|civiccomputing\.com|piwik\.pro|complianz\.io|cdn\.privacy-mgmt\.com)$/, "consent"],
210
292
  ];
211
293
 
212
294
  /**
@@ -226,6 +308,8 @@ export function providerOf(url) {
226
308
  }
227
309
  if (/(^|\.)(doubleclick\.net|google\.[a-z.]+)$/.test(host) && /^\/(pagead|ccm)\//.test(u.pathname)) return of("google_ads");
228
310
  if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
311
+ // Before the vendor hosts: a GA4 hit to a Stape or Elevar endpoint is still a GA4 hit.
312
+ if (isGa4Path(u) && !GOOGLE_COMPANION_HOST.test(host)) return of("ga4");
229
313
  for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
230
314
  if (isGa4Path(u)) return of("ga4");
231
315
  if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
@@ -258,7 +342,8 @@ export function vendorOf(url) {
258
342
  if (p.key === "ga4") {
259
343
  const host = new URL(url).hostname;
260
344
  if (/(^|\.)(google-analytics|analytics\.google|googletagmanager)\.com$/.test(host)) return "Google Analytics";
261
- return GOOGLE_COMPANION_HOST.test(host) ? "Google Analytics (resend)" : "Google Analytics (server-side)";
345
+ if (GOOGLE_COMPANION_HOST.test(host)) return "Google Analytics (resend)";
346
+ return ga4Delivery(new URL(url)) === "server" ? "Google Analytics (server-side)" : "Google Analytics (Google tag gateway)";
262
347
  }
263
348
  return p.label;
264
349
  }
package/src/redact.js CHANGED
@@ -7,7 +7,7 @@
7
7
  const USER_KEYS = new Set([
8
8
  "em", "ph", "fn", "ln", "ge", "db", "ct", "st", "zp", "country", "external_id", "uid", "user_id", "_uid",
9
9
  "email", "phone", "phone_number", "first_name", "last_name", "street", "city", "region", "postal_code", "zip",
10
- "sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone", "email_address",
10
+ "sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone", "email_address", "u_hem", "u_hpn",
11
11
  ]);
12
12
  // In dataLayer pushes, short names like "city" or "country" are often page data, so only
13
13
  // these names, values under a user data object, and email-like values are redacted there.
package/src/replay.js CHANGED
@@ -20,7 +20,7 @@ export function replayableJourney(run) {
20
20
  ...(run.completed ? {} : { incomplete: run.note }),
21
21
  steps: (start ? steps.slice(1) : steps)
22
22
  .filter((s) => s.replay)
23
- .map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
23
+ .map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.rejectsConsent ? { rejects_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
24
24
  };
25
25
  }
26
26
 
package/src/report.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createProvider } from "./providers.js";
2
- import { groupFindings, summarize } from "./checks.js";
2
+ import { groupFindings, hitMatchesEvent, summarize } from "./checks.js";
3
3
  import { messages } from "./i18n.js";
4
4
  import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
5
5
  import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
@@ -192,7 +192,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
192
192
  return lines.join("\n");
193
193
  }
194
194
 
195
- const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x"];
195
+ const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "pinterest", "snapchat", "klaviyo", "hotjar"];
196
196
  const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
197
197
  const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
198
198
 
@@ -331,6 +331,7 @@ function eventRow(m, findings, run, step, hit) {
331
331
  if (items.length) bits.push(m.ui.itemsCount(items.length));
332
332
  if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
333
333
  if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
334
+ if (hit.delivery === "server") bits.push(m.ui.serverSideTag);
334
335
  const consent = hit.consent ? `<h5>${m.ui.consent}</h5><div class="consents">${Object.entries(hit.consent).map(([k, v]) => `<span class="consent ${v === "granted" ? "granted" : "denied"}">${escapeHtml(k)}: ${escapeHtml(v)}</span>`).join("")}</div>` : "";
335
336
  const pushLine = pushes.length ? pushes.map((e) => `<span class="mono">${escapeHtml(pushEvent(e.value) ?? "")} · ${escapeHtml(seconds(run, e.t))}</span>`).join("") : `<span class="muted">${m.ui.noMatchingPush}</span>`;
336
337
  const reqText = calls.length
@@ -618,7 +619,7 @@ function coverageCard(m, plan, runs) {
618
619
  .map((e) => {
619
620
  const cells = runs.map((r) => {
620
621
  if (!checks(r, e) || (e.journeys && !e.journeys.includes(r.journeyId))) return '<td class="c o">–</td>';
621
- return r.hits.some((h) => h.platform === e.platform && h.name === e.name) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
622
+ return r.hits.some((h) => hitMatchesEvent(e, h)) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
622
623
  });
623
624
  const unchecked = !runs.some((r) => checks(r, e));
624
625
  return `<tr><td><span class="ptag p-${safeKey(e.platform)}">${escapeHtml(platformLabel(e.platform))}</span> <code>${escapeHtml(e.name)}</code>${unchecked ? ` ${badge("muted", m.ui.notChecked)}` : ""}</td>${cells.join("")}</tr>`;