@analitikisler/ai-tag-debugger 0.7.7 → 0.7.9

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.9 (2026-10-14)
4
+
5
+ - 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`.
6
+ - A required parameter is also found under its usual other name (`products` for `items`, `revenue` for `value`, `order_id` for `transaction_id`).
7
+ - The plan coverage table in the report uses the same matching.
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - 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[...]`).
13
+ - Klaviyo is a selectable provider.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - 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).
18
+
19
+ ## 0.7.8 (2026-10-14)
20
+
21
+ - 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.
22
+ - A replay skips a malformed address in a recording instead of stopping, and reads the recording once.
23
+
3
24
  ## 0.7.7 (2026-10-14)
4
25
 
5
26
  - A replay answers a request from the recording when the address only differs by a timestamp (`_=1791977697130`, `ts`, `timestamp`, `cb`, `nocache`). Magento sites reload the basket this way after "add to basket", so the replay never got the answer that makes the page send `add_to_cart`. Exact matches still come from the recording as before.
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
  ```
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "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
5
  "type": "module",
6
6
  "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
@@ -5,6 +5,13 @@ import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
5
5
  import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
6
6
  import { startEgressProxy } from "./egress.js";
7
7
 
8
+ // Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
9
+ // one of these always blocks the journey, and a reject click is checked against them afterwards.
10
+ 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, \
11
+ #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, \
12
+ #sp_message_container, [id^="sp_message_container_"], #cookie-law-info-bar, #BorlabsCookieBox, .klaro, #termly-code-snippet-support, #cookie-information-template-wrapper, #coiOverlay, \
13
+ #cmpwrapper, #cmpbox2, .fc-consent-root, #ppms_cm_popup_overlay, [aria-label*="cookie" i][role=dialog]`;
14
+
8
15
  // Caps on what one audit records, so a looping page can't grow memory without limit.
9
16
  const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
10
17
 
@@ -201,12 +208,12 @@ function trackingBody(request) {
201
208
  * How a scripted run can find the element an action used again: the selector it was given,
202
209
  * else a CSS path to the element, else its visible text. Refs only mean something in one run.
203
210
  */
204
- // Text that looks like an email address or phone number is not saved with a click.
205
211
  /** Query parameters that only carry the time or a cache-buster, so they differ between a recording and its replay. */
206
212
  const STAMP_PARAMS = /^(_|_t|ts|timestamp|cb|cachebust|nocache)$/i;
207
213
  /** The address without its timestamp parameters. */
208
214
  function unstamped(href) {
209
- const url = new URL(href);
215
+ let url;
216
+ try { url = new URL(href); } catch { return href; }
210
217
  for (const name of [...url.searchParams.keys()]) if (STAMP_PARAMS.test(name)) url.searchParams.delete(name);
211
218
  return url.href;
212
219
  }
@@ -214,10 +221,9 @@ function unstamped(href) {
214
221
  * The GET answers in a recording whose address carries a timestamp, by address without it.
215
222
  * Entries the recording has no body for (still loading when the page was left) are not included.
216
223
  */
217
- async function restampedAnswers(file) {
224
+ function restampedAnswers(har) {
218
225
  const answers = new Map();
219
226
  answers.exact = new Set();
220
- const har = JSON.parse(await readFile(file, "utf8"));
221
227
  for (const e of har.log.entries) {
222
228
  const { request, response } = e;
223
229
  answers.exact.add(request.url);
@@ -237,6 +243,7 @@ async function restampedAnswers(file) {
237
243
  }
238
244
  return answers;
239
245
  }
246
+ // Text that looks like an email address or phone number is not saved with a click.
240
247
  const savableText = (text) => (typeof text === "string" && text && !/@|\d[\d\s().+-]{6,}\d/.test(text) ? text : undefined);
241
248
 
242
249
  async function replayTarget(a, locator) {
@@ -341,6 +348,8 @@ export class AuditSession {
341
348
  this.hits = [];
342
349
  this.steps = [];
343
350
  this.consentStep = null;
351
+ this.rejectStep = null;
352
+ this.cookieLog = []; // each cookie name and domain with the step it was first seen at (never values)
344
353
  this.errors = [];
345
354
  this.dataLayerLog = [];
346
355
  this.network = [];
@@ -454,11 +463,10 @@ export class AuditSession {
454
463
  (isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
455
464
  );
456
465
  await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
457
- // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
458
- // recording, the redirect would be followed outside any handler, and its failure would end the run.
466
+ const har = JSON.parse(await readFile(this.opts.replayHar, "utf8"));
459
467
  // A page asks again for what it asked when saved, with a new timestamp on the address. Registered
460
468
  // after the recording, so it runs first and leaves every exact match to the recording.
461
- const restamped = await restampedAnswers(this.opts.replayHar);
469
+ const restamped = restampedAnswers(har);
462
470
  if (restamped.size) {
463
471
  await this.context.route(
464
472
  (url) => restamped.has(unstamped(url.href)),
@@ -471,7 +479,9 @@ export class AuditSession {
471
479
  },
472
480
  );
473
481
  }
474
- const refusedRedirects = await this.refusedRedirects(this.opts.replayHar);
482
+ // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
483
+ // recording, the redirect would be followed outside any handler, and its failure would end the run.
484
+ const refusedRedirects = await this.refusedRedirects(har);
475
485
  if (refusedRedirects.size) {
476
486
  await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));
477
487
  }
@@ -564,15 +574,15 @@ export class AuditSession {
564
574
  }
565
575
 
566
576
  /** Addresses whose saved answer redirects somewhere the browser may not go. */
567
- async refusedRedirects(file) {
577
+ async refusedRedirects(har) {
568
578
  const refused = new Set();
569
- const har = JSON.parse(await readFile(file, "utf8"));
570
579
  for (const e of har.log.entries) {
571
580
  const status = e.response?.status;
572
581
  if (status < 300 || status >= 400) continue;
573
582
  const location = e.response.redirectURL || e.response.headers?.find((h) => /^location$/i.test(h.name))?.value;
574
583
  if (!location) continue;
575
- const target = new URL(location, e.request.url).href;
584
+ let target;
585
+ try { target = new URL(location, e.request.url).href; } catch { continue; }
576
586
  if (await this.checkUrl(target)) refused.add(e.request.url);
577
587
  }
578
588
  return refused;
@@ -716,7 +726,7 @@ export class AuditSession {
716
726
  if (!/intercepts pointer events|not stable|outside of the viewport/.test(err.message)) throw err;
717
727
  }
718
728
  const cover = await locator
719
- .evaluate((el) => {
729
+ .evaluate((el, selectors) => {
720
730
  const r = el.getBoundingClientRect();
721
731
  const x = Math.min(Math.max(r.left + r.width / 2, 0), window.innerWidth - 1);
722
732
  const y = Math.min(Math.max(r.top + r.height / 2, 0), window.innerHeight - 1);
@@ -741,13 +751,13 @@ export class AuditSession {
741
751
  const modal = n.matches("dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true]");
742
752
  // Cookie and consent banners block even when they cover only part of the screen.
743
753
  const consent =
744
- n.matches("#onetrust-banner-sdk, #onetrust-consent-sdk, #CybotCookiebotDialog, #usercentrics-root, #didomi-host, #cookiescript_injected, .cky-consent-container, .cc-window, #cmpbox, .qc-cmp2-container") ||
754
+ n.matches(selectors) ||
745
755
  (/(cookie|consent|gdpr|kvkk)/i.test(`${n.id} ${typeof n.className === "string" ? n.className : ""}`) && ["fixed", "sticky"].includes(getComputedStyle(n).position));
746
756
  if (consent) return { blocking: true, what: describe(n) };
747
757
  if (modal || b.width * b.height >= screen * 0.5) return { blocking: true, what: describe(n) };
748
758
  }
749
759
  return { blocking: false, what: describe(hit), free };
750
- })
760
+ }, CMP_BANNER_SELECTORS)
751
761
  .catch(() => null);
752
762
  if (cover?.blocking) {
753
763
  throw new Error(`The element is covered by ${cover.what}. Deal with that first (for example accept or close it), as a visitor would.`);
@@ -769,10 +779,11 @@ export class AuditSession {
769
779
  /**
770
780
  * Performs one action and records it as a step. Returns an error message
771
781
  * instead of throwing, so the agent can recover.
772
- * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
782
+ * @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean, rejects_consent?: boolean }} a
773
783
  */
774
784
  async act(a) {
775
785
  const error = await this.perform(a);
786
+ await this.trackCookies();
776
787
  // A scripted replay of this step may fail the same way, which must not end the journey there.
777
788
  if (error) this.steps[this.currentStep].error = error;
778
789
  return error;
@@ -780,7 +791,7 @@ export class AuditSession {
780
791
 
781
792
  async perform(a) {
782
793
  const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
783
- this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
794
+ this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent, rejectsConsent: !!a.rejects_consent });
784
795
  const step = this.steps[this.currentStep];
785
796
  try {
786
797
  switch (a.action) {
@@ -842,12 +853,28 @@ export class AuditSession {
842
853
  const blocked = await this.enforce();
843
854
  if (blocked) return blocked;
844
855
  if (a.accepts_consent && this.consentStep === null) this.consentStep = this.currentStep;
856
+ if (a.rejects_consent && this.rejectStep === null) {
857
+ this.rejectStep = this.currentStep;
858
+ // A rejected banner should close; one that is still showing means the button did nothing.
859
+ step.bannerOpenAfter = await this.page.evaluate((sel) => [...document.querySelectorAll(sel)].some((n) => {
860
+ const r = n.getBoundingClientRect();
861
+ return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
862
+ }), CMP_BANNER_SELECTORS).catch(() => false);
863
+ }
845
864
  return null;
846
865
  } catch (err) {
847
866
  return (await this.enforce().catch(() => null)) ?? err.message.split("\n")[0];
848
867
  }
849
868
  }
850
869
 
870
+ /** Notes which cookies appeared during the step just taken (names and domains only). */
871
+ async trackCookies() {
872
+ const seen = new Set(this.cookieLog.map((c) => `${c.domain}|${c.name}`));
873
+ for (const c of await this.cookies()) {
874
+ if (!seen.has(`${c.domain}|${c.name}`) && this.cookieLog.length < 500) this.cookieLog.push({ ...c, step: this.currentStep });
875
+ }
876
+ }
877
+
851
878
  async cookies() {
852
879
  let timer;
853
880
  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,12 +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;
184
+ // TikTok sends unnamed pings (no event name) on its own schedule, so their count varies between runs.
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;
136
190
  findings.push({
137
191
  ...base,
138
192
  severity: "warning",
@@ -140,10 +194,32 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
140
194
  platform: hits[0].platform,
141
195
  event: hits[0].name,
142
196
  step: stepLabel(hits[0].step),
143
- detail: d.duplicate(hits[0].name, hits.length),
197
+ detail: d.duplicate(hits[0].name, sends),
144
198
  });
145
199
  }
146
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
+
147
223
  // Consent.
148
224
  const granted = consentGrantedStep(run);
149
225
  const firstHitStep = run.hits.length ? Math.min(...run.hits.map((h) => h.step)) : null;
@@ -155,8 +231,63 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
155
231
  findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
156
232
  }
157
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
+
158
287
  const beforeConsent = new Map();
159
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;
160
291
  const early = granted === null || hit.step < granted;
161
292
  if (!early) continue;
162
293
  const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
@@ -205,14 +336,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
205
336
  }
206
337
 
207
338
  // Events that are not in the plan.
208
- 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)))];
209
340
  if (unplanned.length) {
210
341
  findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
211
342
  }
212
343
 
213
344
  // Planned events that fired cleanly.
214
345
  const problemKeys = new Set(findings.filter((f) => f.event).map((f) => key(f.platform, f.event)));
215
- 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));
216
347
  if (okKeys.length) {
217
348
  findings.push({ ...base, severity: "ok", check: "matches_plan", detail: d.matches(okKeys) });
218
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
@@ -174,7 +174,7 @@ export function buildServer(opts) {
174
174
  {
175
175
  title: "Act in the audit browser",
176
176
  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.",
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 (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
178
  inputSchema: {
179
179
  audit_id: z.string(),
180
180
  action: z.enum(["goto", "click", "fill", "select", "scroll", "wait"]),
@@ -184,6 +184,7 @@ export function buildServer(opts) {
184
184
  url: z.string().optional().describe("Destination for goto, absolute or relative to the current page."),
185
185
  reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
186
186
  accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
187
+ rejects_consent: z.boolean().optional().describe("True only when this click rejects or declines all cookies on the consent banner."),
187
188
  },
188
189
  },
189
190
  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>`;