@analitikisler/ai-tag-debugger 0.20.0 → 0.20.1

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.1 (2026-11-03)
4
+
5
+ - Runs can report fewer findings than 0.20.0 on the same site: GA4's own events are no longer unplanned, and a reject that keeps essential storage granted is no longer a re-grant. A baseline written with 0.20.0 can then list those entries as stale; `--update-baseline` rewrites it.
6
+ - Fixed: a reject that left `functionality_storage`, `security_storage` or `personalization_storage` granted was reported as "the reject button does not stick". Only `ad_storage`, `analytics_storage`, `ad_user_data` and `ad_personalization` granted after a reject count as a re-grant now; consent platforms keep the others granted on "reject all".
7
+ - GA4's automatic events (`first_visit`, `session_start`, `user_engagement`) and enhanced measurement events (`page_view`, `scroll`, `click`, `view_search_results`, `video_start`, `video_progress`, `video_complete`, `file_download`, `form_start`, `form_submit`) are never listed as unplanned, in the findings or in Plan vs. site. When a plan lists an enhanced measurement event that never fired, the finding says which setting under Admin > Data streams > Enhanced measurement sends it. A planned `first_visit` or `session_start` counts as sent when a GA4 hit carries its flag (`_fv`, `_ss`), since GA4 never sends them by name, and a planned automatic event that wasn't seen is an info note instead of a missing event.
8
+ - A step waits until the tags have gone quiet: after the page loads and the network is idle, it waits until no tag, consent or tracking request and no dataLayer push came for 1.2 seconds, at most 3 seconds more, and once more for a page a delayed link only now started loading. A page_view gtag held for a consent update lands on the click that opened its page. Steps with recent tag activity take up to that much longer.
9
+ - A page_view that still arrives during a later wait or scroll, for a page an earlier step loaded and with none of its own yet, is put on that earlier step and marked late: the report shows "arrived 3.2 s late" on the hit and links to the request in the step it was sent in, and the MCP step result says where it went. A second page_view for the same page stays on its own step.
10
+ - MCP: `browser_action` and `start_audit` results no longer cut what the agent needs to read. Consent Mode commands are shown in full as `key=value`, `page_location` and `page_referrer` are never cut, in hits or inside dataLayer pushes, and a value that is cut ends in `…`. Calls to analytics and ad endpoints that carry no event the tool reads, such as Google's `ccm/collect` Consent Mode pings, are listed per step with their `gcs`, `gcd`, `dma` and `npa`, instead of only being counted in `finish_audit`.
11
+
3
12
  ## 0.20.0 (2026-10-25)
4
13
 
5
14
  - Node 22 or newer is required. Node 20 reached its end of life on 30 April 2026, and the OpenAI SDK the tool installs already needed 22. `aitd doctor` and `npm run setup` say so on an older Node, and CI tests on Node 22 and 24.
package/README.md CHANGED
@@ -47,7 +47,7 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
47
47
  | Duplicates | `add_to_cart` sent twice by one click |
48
48
  | 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 |
49
49
  | Personal data | An email address sent to an ad platform in plain text |
50
- | Unplanned events | Events firing that aren't in your measurement plan |
50
+ | Unplanned events | Events firing that aren't in your measurement plan. GA4's automatic events (`first_visit`, `session_start`, `user_engagement`) and enhanced measurement events (`page_view`, `scroll`, `click`, `view_search_results`, `video_*`, `file_download`, `form_start`, `form_submit`) never count, since no tag sends them. When one of them is planned and missing, the finding names the enhanced measurement setting that sends it; `first_visit` and `session_start` are read from the flags GA4 puts on its other hits. |
51
51
  | Plan coverage | Each planned event sorted into implemented, missing, missing parameters, wrong values or not checked (see [Plan vs. site](#plan-vs-site)) |
52
52
  | Campaign parameters | `utm_campaign` or `gclid` on the landing address dropped by a redirect, or missing from the page address the hits report. The visit would be credited to direct traffic |
53
53
  | Server-side tracking | One event ID used for two different events, or a Meta event ID that doesn't match the `event_id` on the GA4 hit a server container turns into a Conversions API event |
@@ -292,7 +292,7 @@ When a run uses `--against`, the report has a **Plan vs. site** section: at the
292
292
  | Missing | Planned for a journey the run walked, but never fired |
293
293
  | Missing params | Fired, but a required parameter is absent |
294
294
  | Wrong values | Fired with a value problem, from the same checks as purchase value, currency and items |
295
- | Unplanned | Fired, but isn't in the plan |
295
+ | Unplanned | Fired, but isn't in the plan (GA4's automatic and enhanced measurement events never are) |
296
296
  | Not checked | Planned, but its journey or platform wasn't part of this run |
297
297
 
298
298
  The coverage score is implemented divided by the planned events that were checkable (all except not checked), shown overall and per platform, for example `Coverage 7 of 11 (64%), 2 events not checked`. The same entries are in `plan-check.json`, one per planned event with its bucket. Exit codes don't change: `--fail-on` still looks at finding severity. Without `--against`, the report is exactly as before.
@@ -625,7 +625,7 @@ Tools the server offers:
625
625
  | `make_plan` | Drafts a tracking plan for a site (what should be tracked), the same as `aitd plan --site`. Pass `topic` (`custom-events`, `ecommerce`, `forms`, `consent`) to limit it to one kind of event. The plan is saved and its ID can be given as `against` to `finish_audit`. It uses the AI provider configured on the computer that runs the server and takes a few minutes. |
626
626
  | `what_changed` | Returns only the "since last run" diff for a site: the problems fixed and the new ones in its newest saved run (`aitd run`) compared with the run before. No browser and no AI. Pass `language` for a Turkish answer. |
627
627
  | `start_audit` | Opens a fresh browser on a URL and starts recording all tracking. Pass a ready-made journey (`purchase`, `sign_up`, …) to get its goal and expected events, and `providers` to choose the platforms to check. The assistant asks you which platforms to check first. |
628
- | `browser_action` | One click, fill, select, goto, scroll or wait. Returns the page and the tracking hits that step sent. |
628
+ | `browser_action` | One click, fill, select, goto, scroll or wait. Returns the page and the tracking hits that step sent, its dataLayer pushes (Consent Mode commands in full as `key=value`, page addresses never cut) and the calls it read no event from, such as Consent Mode pings with their `gcs` and `gcd`. A page_view that arrives during a later wait or scroll is listed on the step that loaded its page, marked late. |
629
629
  | `get_page` | Shows the current page. |
630
630
  | `finish_audit` | Checks everything against your measurement plan (JSON), a saved one given as `against` (`latest`, `first`, a plan ID, or on a local server a path), or the ready-made journey's events, optionally only the `events` you name, and writes the full HTML report with the timeline. The site comes from the audit. |
631
631
  | `run_audit` | Runs a config file end to end. With `against`, it checks the run against a saved plan and, when the config has no journeys, walks the journeys derived from that plan. `concurrency` runs that many journeys at once, each in its own browser (default 1), and `baseline` names a file of accepted findings written with `--update-baseline` (default: the config's `baseline`; see [Accept known findings](#accept-known-findings)). Local only for file paths. |
package/README.tr.md CHANGED
@@ -47,7 +47,7 @@ Manuel etiket kontrolü bunu yakalar, ama zahmetlidir; bu yüzden her sürümden
47
47
  | Tekrarlar | Tek bir tıklamayla iki kez gönderilen `add_to_cart` |
48
48
  | Çerez onayı | Ziyaretçi çerezleri kabul etmeden fire olan Meta `PageView`, eksik ya da çok geç ayarlanmış Consent Mode varsayılanı, eksik Consent Mode v2 sinyalleri (`ad_user_data`, `ad_personalization`), onaydan önce ayarlanan takip çerezleri, `decline_cookies` yolculuğu reddettikten sonra hâlâ aktif olan etiket ya da çerezler, hiçbir şey yapmayan bir reddetme düğmesi |
49
49
  | Kişisel veri | Bir reklam platformuna düz metin olarak gönderilen e-posta adresi |
50
- | Planda olmayan event'ler | Ölçümleme planınızda bulunmayan, yine de fire olan event'ler |
50
+ | Planda olmayan event'ler | Ölçümleme planınızda bulunmayan, yine de fire olan event'ler. GA4'ün otomatik event'leri (`first_visit`, `session_start`, `user_engagement`) ve geliştirilmiş ölçüm event'leri (`page_view`, `scroll`, `click`, `view_search_results`, `video_*`, `file_download`, `form_start`, `form_submit`) hiçbir etiket göndermediği için sayılmaz. Bunlardan biri planda olup gelmediğinde bulgu, onu gönderen geliştirilmiş ölçüm ayarının adını verir; `first_visit` ve `session_start`, GA4'ün diğer isteklerine koyduğu işaretlerden okunur. |
51
51
  | Plan kapsamı | Planlanan her event'in uygulandı, eksik, parametresi eksik, değeri hatalı ya da kontrol edilmedi olarak sınıflandırılması (bkz. [Plan ve site](#plan-ve-site)) |
52
52
  | Kampanya parametreleri | Açılış adresindeki `utm_campaign` ya da `gclid`'in bir yönlendirmede düşmesi, ya da isteklerin bildirdiği sayfa adresinde eksik olması. Ziyaret doğrudan trafiğe yazılır |
53
53
  | Sunucu tarafı takip | Aynı event kimliğinin iki farklı event'te kullanılması, ya da Meta event kimliğinin sunucu konteynerinin Conversions API olayına dönüştürdüğü GA4 isteğindeki `event_id` ile eşleşmemesi |
@@ -292,7 +292,7 @@ Bir çalıştırma `--against` kullandığında raporda bir **Plan ve site** bö
292
292
  | Eksik | Çalıştırmanın gezdiği bir yolculuk için planlandı, ama hiç fire olmadı |
293
293
  | Parametre eksik | Fire oldu, ama gerekli bir parametre yok |
294
294
  | Hatalı değer | Fire oldu, değer sorunu var; satın alma değeri, para birimi ve ürün kontrollerinin aynısından |
295
- | Planda yok | Fire oldu, ama planda değil |
295
+ | Planda yok | Fire oldu, ama planda değil (GA4'ün otomatik ve geliştirilmiş ölçüm event'leri hiçbir zaman bu gruba girmez) |
296
296
  | Kontrol edilmedi | Planlandı, ama yolculuğu ya da platformu bu çalıştırmanın parçası değildi |
297
297
 
298
298
  Kapsam puanı, uygulanmış olanların kontrol edilebilir planlanmış event'lere (kontrol edilmedi hariç hepsi) bölünmesiyle bulunur; genel ve platform başına gösterilir, örneğin `Kapsam 11 olaydan 7 (%64), 2 olay kontrol edilmedi`. Aynı kayıtlar `plan-check.json` dosyasında da bulunur: planlanan her event için kovasıyla birlikte bir kayıt. Çıkış kodları değişmez: `--fail-on` yine bulgu önemine bakar. `--against` olmadan rapor tam olarak eskisi gibidir.
@@ -625,7 +625,7 @@ Sunucunun sunduğu araçlar:
625
625
  | `make_plan` | Bir site için takip planı çıkarır (neyin takip edilmesi gerektiği), `aitd plan --site` ile aynıdır. Yalnızca bir tür event için sınırlamak üzere `topic` verin (`custom-events`, `ecommerce`, `forms`, `consent`). Plan kaydedilir ve kimliği `finish_audit` için `against` olarak verilebilir. Sunucuyu çalıştıran bilgisayarda ayarlı yapay zekâ sağlayıcısını kullanır ve birkaç dakika sürer. |
626
626
  | `what_changed` | Bir site için yalnızca "son çalıştırmadan bu yana" farkını döndürür: kayıtlı son çalıştırmanın (`aitd run`) bir öncekine göre düzelen ve yeni sorunları. Tarayıcı ve yapay zekâ kullanmaz. Türkçe yanıt için `language` verin. |
627
627
  | `start_audit` | Bir URL'de yeni bir tarayıcı açar ve tüm takibi kaydetmeye başlar. Hedefini ve beklenen event'lerini almak için hazır bir yolculuk (`purchase`, `sign_up`, …) verin; kontrol edilecek platformları seçmek için `providers` verin. Asistan önce hangi platformların kontrol edileceğini size sorar. |
628
- | `browser_action` | Tek bir tıklama, doldurma, seçme, gitme, kaydırma ya da bekleme. Sayfayı ve o adımın gönderdiği takip isteklerini döndürür. |
628
+ | `browser_action` | Tek bir tıklama, doldurma, seçme, gitme, kaydırma ya da bekleme. Sayfayı, o adımın gönderdiği takip isteklerini, dataLayer push'larını (Consent Mode komutları `anahtar=değer` olarak eksiksiz, sayfa adresleri hiç kesilmeden) ve event olarak okunmayan çağrıları (ör. `gcs` ve `gcd` değerleriyle Consent Mode ping'leri) döndürür. Sonraki bir bekleme ya da kaydırma sırasında gelen page_view, sayfasını yükleyen adımda gecikmeli olarak işaretlenip gösterilir. |
629
629
  | `get_page` | Geçerli sayfayı gösterir. |
630
630
  | `finish_audit` | Her şeyi ölçümleme planınıza (JSON) ya da `against` olarak verilen kayıtlı bir plana (`latest`, `first`, bir plan kimliği ya da yerel bir sunucuda bir yol) ya da hazır yolculuğun event'lerine karşı kontrol eder; isterseniz yalnızca adını verdiğiniz `events` için, ve zaman akışıyla birlikte tam HTML raporunu yazar. Site denetimden alınır. |
631
631
  | `run_audit` | Bir ayar dosyasını baştan sona çalıştırır. `against` ile çalıştırmayı kayıtlı bir plana karşı kontrol eder; ayar dosyasında yolculuk yoksa, plandan türetilen yolculukları gezer. `concurrency` o kadar yolculuğu aynı anda, her birini kendi tarayıcısında çalıştırır (varsayılan 1); `baseline`, `--update-baseline` ile yazılmış kabul edilen bulgular dosyasını adlandırır (varsayılan: ayar dosyasındaki `baseline`; bkz. [Bilinen bulguları kabul edin](#bilinen-bulguları-kabul-edin)). Dosya yolları yalnızca yerelde çalışır. |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
3
  "mcpName": "io.github.analitikisler/ai-tag-debugger",
4
- "version": "0.20.0",
4
+ "version": "0.20.1",
5
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.",
6
6
  "type": "module",
7
7
  "bin": {
package/src/browser.js CHANGED
@@ -419,6 +419,7 @@ export class AuditSession {
419
419
  this.errorPageShown = false; // whether Chromium's error page committed in the main frame since navigate() began
420
420
  this.bodyBytes = 0;
421
421
  this.bodyBudgetStep = null; // the step at which POST bodies stopped being stored
422
+ this.lastTrackingAt = 0; // when a tag, consent or tracking request or a dataLayer push last happened, for settle()
422
423
  this.startedAt = Date.now();
423
424
  }
424
425
 
@@ -448,6 +449,7 @@ export class AuditSession {
448
449
  const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
449
450
  this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
450
451
  await this.context.exposeBinding("__atdRecord", (_source, entry) => {
452
+ this.lastTrackingAt = Date.now();
451
453
  this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
452
454
  });
453
455
  await this.context.addInitScript(INIT_SCRIPT);
@@ -476,6 +478,8 @@ export class AuditSession {
476
478
  for (const hit of hits) {
477
479
  if (!this.checks(hit.platform)) continue;
478
480
  const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now() };
481
+ const late = this.lateFor(hit, entry);
482
+ if (late) Object.assign(entry, late);
479
483
  if (this.record("hits", entry)) recorded.push(entry);
480
484
  }
481
485
  if (recorded.length) hitsOf.set(request, recorded);
@@ -493,6 +497,7 @@ export class AuditSession {
493
497
  this.context.on("request", (request) => {
494
498
  const provider = providerOf(request.url());
495
499
  if (!provider) return;
500
+ this.lastTrackingAt = Date.now();
496
501
  if (!provider.always && !this.checks(provider.key)) {
497
502
  const counts = (this.skipped[this.currentStep] ??= {});
498
503
  counts[provider.label] = (counts[provider.label] ?? 0) + 1;
@@ -603,6 +608,25 @@ export class AuditSession {
603
608
  return loaded !== undefined && loaded < this.currentStep ? loaded : this.currentStep;
604
609
  }
605
610
 
611
+ /**
612
+ * A page_view that arrives during a wait or a scroll for the page an earlier step loaded, when that step
613
+ * got none: gtag can hold it for a consent update or a slow tag until after the step was reported. It goes
614
+ * to the step that loaded the page, marked late, so the report doesn't read as "this click sent nothing".
615
+ * Returns { step, late: { during, ms } } or null.
616
+ */
617
+ lateFor(hit, entry) {
618
+ if (hit.name !== "page_view" || !hit.params?.page_location || entry.step !== this.currentStep) return null;
619
+ const now = this.steps[this.currentStep];
620
+ if (!["wait", "scroll"].includes(now?.action)) return null;
621
+ const page = pageKey(hit.params.page_location);
622
+ const loaded = this.loadedIn.get(page);
623
+ if (loaded === undefined || loaded >= this.currentStep || page !== pageKey(this.page.url())) return null;
624
+ // A second page_view for the same page (a virtual one, a resend) is the step's own.
625
+ if (this.hits.some((h) => h.name === "page_view" && h.step >= loaded && h.params?.page_location && pageKey(h.params.page_location) === page)) return null;
626
+ const since = this.steps[loaded].endedAt ?? this.steps[loaded].t;
627
+ return { step: loaded, late: { during: this.currentStep, ms: Math.max(0, entry.t - since) } };
628
+ }
629
+
606
630
  /** The hit with user data for ad matching redacted, and userData: { fields, plain } when there was any. */
607
631
  /** Whether requests of this provider are recorded; with detection on, a known provider seen for the first time joins the list. */
608
632
  checks(key) {
@@ -739,9 +763,24 @@ export class AuditSession {
739
763
  }
740
764
 
741
765
  async settle() {
742
- await this.page.waitForLoadState("load", { timeout: 15000 }).catch(() => {});
743
- await this.page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
744
- await this.page.waitForTimeout(400);
766
+ for (let round = 0; round < 2; round++) {
767
+ await this.page.waitForLoadState("load", { timeout: 15000 }).catch(() => {});
768
+ await this.page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
769
+ await this.page.waitForTimeout(400);
770
+ // The network can go quiet before the tags are done: gtag holds a page_view until the consent
771
+ // platform answers (wait_for_update), and a consent platform loads its own scripts first. So the
772
+ // step ends once no tag, consent or tracking request and no dataLayer push came for quietMs.
773
+ const quiet = this.opts.quietMs ?? 1200;
774
+ const until = Date.now() + (this.opts.quietCapMs ?? 3000);
775
+ while (Date.now() < until) {
776
+ const wait = this.lastTrackingAt + quiet - Date.now();
777
+ if (wait <= 0) break;
778
+ await this.page.waitForTimeout(Math.min(wait, until - Date.now()));
779
+ }
780
+ // A click whose page starts loading only now (a link delayed for its tags) is waited for once more.
781
+ if (!this.leaving) break;
782
+ await this.page.waitForEvent("framenavigated", { predicate: (f) => f === this.page.mainFrame(), timeout: 5000 }).catch(() => {});
783
+ }
745
784
  }
746
785
 
747
786
  async snapshot() {
@@ -905,6 +944,7 @@ export class AuditSession {
905
944
  // A scripted replay of this step may fail the same way, which must not end the journey there.
906
945
  if (error) this.steps[this.currentStep].error = error;
907
946
  this.steps[this.currentStep].pagePath = pagePath(this.page.url());
947
+ this.steps[this.currentStep].endedAt = Date.now(); // when the step was reported, so a late hit can say how late
908
948
  return error;
909
949
  }
910
950
 
package/src/checks.js CHANGED
@@ -15,6 +15,9 @@ const sentBefore = (a, b) => a.step < b.step || (a.step === b.step && a.t != nul
15
15
  const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
16
16
  // Consent Mode v2 added two signals to ad_storage and analytics_storage. Google requires them for EEA/UK traffic.
17
17
  const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
18
+ // The signals a "reject all" must keep denied. functionality_storage and security_storage cover what
19
+ // the site needs to work, and personalization_storage its own features, so CMPs grant them on reject too.
20
+ const TRACKING_SIGNALS = ["ad_storage", "analytics_storage", ...V2_SIGNALS];
18
21
  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_.+)$/;
19
22
  const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
20
23
 
@@ -63,6 +66,28 @@ export function canonicalEvent(name) {
63
66
  return CANONICAL.get(n) ?? n;
64
67
  }
65
68
 
69
+ // GA4 makes these itself: first_visit and session_start from flags on its other hits, user_engagement
70
+ // when the page is hidden or left. No tag sends them, so they are never unplanned, and a run can end
71
+ // before user_engagement goes out. Keyed by canonicalEvent, like every comparison of event names here.
72
+ const GA4_AUTOMATIC = new Set(["first_visit", "session_start", "user_engagement"].map(canonicalEvent));
73
+ // Enhanced measurement events, which GA4 sends with no tag when the web stream's setting is on.
74
+ // The value names the setting that sends it.
75
+ const GA4_ENHANCED = new Map(Object.entries({
76
+ page_view: "pageViews", scroll: "scrolls", click: "outboundClicks", view_search_results: "siteSearch",
77
+ video_start: "videoEngagement", video_progress: "videoEngagement", video_complete: "videoEngagement",
78
+ file_download: "fileDownloads", form_start: "formInteractions", form_submit: "formInteractions",
79
+ }).map(([name, setting]) => [canonicalEvent(name), setting]));
80
+ const isGa4Automatic = (platform, name) => platform === "ga4" && GA4_AUTOMATIC.has(canonicalEvent(name));
81
+ /** Whether GA4 sends this event by itself, so a plan need not list it. */
82
+ const isGa4Builtin = (platform, name) => isGa4Automatic(platform, name) || (platform === "ga4" && GA4_ENHANCED.has(canonicalEvent(name)));
83
+
84
+ /** Whether a planned event was sent on a run: by name, or for GA4's first_visit and session_start, as a flag on another hit. */
85
+ function eventFired(ev, hits) {
86
+ if (hits.some((h) => hitMatchesEvent(ev, h))) return true;
87
+ const name = canonicalEvent(ev.name);
88
+ return ev.platform === "ga4" && hits.some((h) => h.platform === "ga4" && (h.autoEvents ?? []).some((a) => canonicalEvent(a) === name));
89
+ }
90
+
66
91
  /** Whether a captured hit is the planned event, by its exact name, an alias or a common variant. */
67
92
  export function hitMatchesEvent(ev, hit) {
68
93
  if (hit.platform !== ev.platform) return false;
@@ -169,17 +194,22 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
169
194
  // Expected events that never fired.
170
195
  for (const ev of plan.events) {
171
196
  if (!expectedIn(ev, run.journeyId)) continue;
172
- const fired = run.hits.some((h) => hitMatchesEvent(ev, h));
173
- if (!fired) {
174
- findings.push({
175
- ...base,
176
- severity: run.completed ? "broken" : "info",
177
- check: "missing_event",
178
- platform: ev.platform,
179
- event: ev.name,
180
- detail: run.completed ? d.missingEvent(ev.platform, ev.name) : d.missingEventEarly(ev.name),
181
- });
197
+ if (eventFired(ev, run.hits)) continue;
198
+ if (isGa4Automatic(ev.platform, ev.name)) {
199
+ findings.push({ ...base, severity: "info", check: "ga4_automatic_event", platform: ev.platform, event: ev.name, detail: d.missingAutomatic(ev.name) });
200
+ continue;
182
201
  }
202
+ // page_view is enhanced measurement too, but a missing one means the GA4 tag itself did not send.
203
+ const name = canonicalEvent(ev.name);
204
+ const setting = ev.platform === "ga4" && name !== canonicalEvent("page_view") ? GA4_ENHANCED.get(name) : undefined;
205
+ findings.push({
206
+ ...base,
207
+ severity: run.completed ? "broken" : "info",
208
+ check: "missing_event",
209
+ platform: ev.platform,
210
+ event: ev.name,
211
+ detail: !run.completed ? d.missingEventEarly(ev.name) : setting ? d.missingEnhanced(ev.name, setting) : d.missingEvent(ev.platform, ev.name),
212
+ });
183
213
  }
184
214
 
185
215
  // Required parameters missing on events that did fire (reported once per event).
@@ -410,7 +440,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
410
440
  if (rejectStep !== null) {
411
441
  const reject = run.steps.find((s) => s.index === rejectStep);
412
442
  const regranted = run.dataLayerLog.find(
413
- (e) => e.step >= rejectStep && isConsentCommand(e, "update") && Object.values(e.value[2] ?? {}).includes("granted"),
443
+ (e) => e.step >= rejectStep && isConsentCommand(e, "update") && TRACKING_SIGNALS.some((k) => e.value[2]?.[k] === "granted"),
414
444
  );
415
445
  if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
416
446
  else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
@@ -495,7 +525,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
495
525
  }
496
526
 
497
527
  // Events that are not in the plan. Without a plan every event would be listed, which says nothing.
498
- const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h)).map((h) => key(h.platform, h.name)))];
528
+ const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h) && !isGa4Builtin(h.platform, h.name)).map((h) => key(h.platform, h.name)))];
499
529
  if (hasPlan && unplanned.length) {
500
530
  findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
501
531
  }
@@ -557,10 +587,12 @@ export function planCheck(plan, runs, findings) {
557
587
  const own = findings.filter((f) => f.event && walked.some((r) => r.journeyId === f.journey && r.viewport === f.viewport) && hitMatchesEvent(ev, { platform: f.platform, name: f.event }));
558
588
  const params = own.filter((f) => f.check === "missing_params");
559
589
  const values = own.filter((f) => WRONG_VALUE_CHECKS.has(f.check));
560
- const fired = walked.some((r) => r.hits.some((h) => hitMatchesEvent(ev, h)));
590
+ const fired = walked.some((r) => eventFired(ev, r.hits));
561
591
  if (params.length) entry.bucket = "missing_params";
562
592
  else if (values.length) entry.bucket = "wrong_values";
563
- else entry.bucket = fired ? "implemented" : "missing";
593
+ else if (fired) entry.bucket = "implemented";
594
+ // GA4 makes these without a tag, so not seeing one says nothing about the site's setup.
595
+ else entry.bucket = isGa4Automatic(ev.platform, ev.name) ? "not_checked" : "missing";
564
596
  const details = [...new Set([...params, ...values].map((f) => f.detail))];
565
597
  if (details.length) entry.details = details;
566
598
  }
@@ -571,7 +603,7 @@ export function planCheck(plan, runs, findings) {
571
603
  for (const r of runs) {
572
604
  if (!findings.some((f) => f.check === "unplanned_events" && f.journey === r.journeyId && f.viewport === r.viewport)) continue;
573
605
  for (const h of r.hits) {
574
- if (events.some((e) => hitMatchesEvent(e, h)) || seen.has(key(h.platform, h.name))) continue;
606
+ if (events.some((e) => hitMatchesEvent(e, h)) || isGa4Builtin(h.platform, h.name) || seen.has(key(h.platform, h.name))) continue;
575
607
  seen.add(key(h.platform, h.name));
576
608
  entries.push({ platform: h.platform, event: h.name, bucket: "unplanned", journeys: [r.journeyId] });
577
609
  }
@@ -65,8 +65,10 @@ function pushesForHit(run, hit) {
65
65
 
66
66
  /** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
67
67
  function callsForHit(run, hit) {
68
- const calls = (run.network ?? []).filter((c) => c.step === hit.step && c.url === hit.url);
69
- const twins = run.hits.filter((h) => h.step === hit.step && h.url === hit.url);
68
+ // A late hit was sent during a later step, so its request is recorded there.
69
+ const sentIn = hit.late?.during ?? hit.step;
70
+ const calls = (run.network ?? []).filter((c) => c.step === sentIn && c.url === hit.url);
71
+ const twins = run.hits.filter((h) => (h.late?.during ?? h.step) === sentIn && h.url === hit.url);
70
72
  return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
71
73
  }
72
74
 
@@ -216,7 +218,7 @@ function runState(ctx, rows, run) {
216
218
  /* ---------- step pane: hits ---------- */
217
219
 
218
220
  /** The 1–3 parameters a practitioner scans first: missing required ones, value, items, the order id; a page title when there is nothing else. */
219
- function keyBits(ctx, t, hit, flag) {
221
+ function keyBits(ctx, t, hit, flag, run) {
220
222
  const { m } = ctx;
221
223
  const p = hit.params ?? {};
222
224
  const out = [];
@@ -228,6 +230,7 @@ function keyBits(ctx, t, hit, flag) {
228
230
  if (p.transaction_id) out.push(kv("transaction_id", p.transaction_id));
229
231
  if (hit.delivery === "server") out.push(`<span class="chip plain">${esc(m.ui.serverSideTag)}</span>`);
230
232
  if (!out.length && p.page_title) out.push(kv("", p.page_title));
233
+ if (hit.late) out.unshift(`<span class="chip plain" title="${esc(m.ui.lateHitTip((run?.steps ?? []).find((s) => s.index === hit.late.during)?.label ?? hit.late.during))}">${esc(m.ui.lateHit(hit.late.ms))}</span>`);
231
234
  if (!out.length) return `<span class="muted">${esc(m.ui.noParameters)}</span>`;
232
235
  return out.slice(0, 3).join("");
233
236
  }
@@ -261,10 +264,10 @@ function hitCard(ctx, t, rows, run, ri, step, hit, k) {
261
264
  const label = PROVIDERS[safeKey(hit.platform)] ?? platformLabel(hit.platform);
262
265
  const calls = callsForHit(run, hit);
263
266
  const reqLine = calls.length
264
- ? calls.map((c) => `<a class="mono" href="#${requestId(ri, step.index, stepCalls.indexOf(c))}">${esc(c.method ?? "")} ${esc(hostPath(c.url))} ${esc(c.status ?? "")}</a>${c.blocked ? `<span class="muted">${esc(m.html.blockedBy(label))}</span>` : ""}`).join("")
267
+ ? calls.map((c) => `<a class="mono" href="#${requestId(ri, c.step, (c.step === step.index ? stepCalls : requestsOf(run, c.step)).indexOf(c))}">${esc(c.method ?? "")} ${esc(hostPath(c.url))} ${esc(c.status ?? "")}</a>${c.blocked ? `<span class="muted">${esc(m.html.blockedBy(label))}</span>` : ""}`).join("")
265
268
  : `<span class="mono">${esc(hostPath(hit.url))}</span>`;
266
269
  const card = flag ? ids.get(flag) : null;
267
- return `<details class="card jr-hit${sev ? ` sev-${sev}` : ""}" id="${id}"${sev ? ` data-flagged="${sev}" data-print-open` : ""}><summary><span class="jr-c-p">${platChip(hit.platform)}</span><code class="jr-ev">${esc(hit.name)}</code><span class="jr-c-k">${keyBits(ctx, t, hit, flag)}</span><span class="jr-c-c">${hitConsent(t, hit.consent)}</span><span class="jr-c-s">${sev ? badge(sev, m.severity[sev]) : ""}</span><span class="jr-tm">${esc(secs(run, hit.t))}</span></summary>
270
+ return `<details class="card jr-hit${sev ? ` sev-${sev}` : ""}" id="${id}"${sev ? ` data-flagged="${sev}" data-print-open` : ""}><summary><span class="jr-c-p">${platChip(hit.platform)}</span><code class="jr-ev">${esc(hit.name)}</code><span class="jr-c-k">${keyBits(ctx, t, hit, flag, run)}</span><span class="jr-c-c">${hitConsent(t, hit.consent)}</span><span class="jr-c-s">${sev ? badge(sev, m.severity[sev]) : ""}</span><span class="jr-tm">${esc(secs(run, hit.t))}</span></summary>
268
271
  <div class="body">${flag ? `<p class="jr-flag sev-${sev}">${use(`sev-${sev}`, 14)}<span>${detailHtml(flag.detail)}</span>${card ? `<a href="#${card}">${esc(m.html.findingLink)}</a>` : ""}</p>` : ""}
269
272
  <div class="jr-hb"><div><h4 class="jr-h">${esc(m.ui.parameters)}${n(event.length + missing.length)}</h4>${batch ? `<p class="muted xs jr-batch">${esc(m.ui.inBatch(batch[0], batch[1]))}</p>` : ""}${paramTable}${ctxTable}</div>
270
273
  ${items.length ? `<div><h4 class="jr-h">${esc(m.ui.items)}${n(items.length)}</h4>${itemsTable(m, t, items)}</div>` : ""}
package/src/i18n.js CHANGED
@@ -24,6 +24,8 @@ const journeysOf = (v) => v?.journeys ?? {};
24
24
 
25
25
  const en = {
26
26
  name: "English",
27
+ // The enhanced measurement settings as GA4's web stream page names them.
28
+ ga4Settings: { pageViews: "Page views", scrolls: "Scrolls", outboundClicks: "Outbound clicks", siteSearch: "Site search", videoEngagement: "Video engagement", fileDownloads: "File downloads", formInteractions: "Form interactions" },
27
29
  severity: { broken: "Broken", risk: "Risk", warning: "Warning", info: "Info", ok: "OK" },
28
30
  ui: {
29
31
  title: (host) => `ai tag debugger report: ${host}`,
@@ -78,6 +80,8 @@ const en = {
78
80
  customItemParams: "Custom item parameters",
79
81
  noItems: "no items",
80
82
  serverSideTag: "server-side",
83
+ lateHit: (ms) => `arrived ${(ms / 1000).toFixed(1)} s late`,
84
+ lateHitTip: (step) => `Sent after this step was over, during "${step}". It belongs to this step's page.`,
81
85
  source: "Source",
82
86
  request: "Request",
83
87
  noPushes: "No dataLayer pushes in this step.",
@@ -246,6 +250,8 @@ const en = {
246
250
  journeyIncomplete: (note) => `The journey did not reach its goal${note ? `: ${note}` : "."} Events after that point could not be checked.`,
247
251
  missingEvent: (platform, name) => `${label(platform)} ${q(name)} was not sent on this journey, although the journey reached its goal. ${label(platform)} recorded nothing for it.`,
248
252
  missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so it is unverified rather than missing.`,
253
+ missingEnhanced: (name, setting) => `GA4 ${q(name)} was not sent on this journey, although the journey reached its goal. GA4 sends it by itself, with no tag, when enhanced measurement is on: in GA4, Admin > Data streams > the web stream > Enhanced measurement, switch on "${en.ga4Settings[setting]}". A tag that sends it instead works too.`,
254
+ missingAutomatic: (name) => `GA4 ${q(name)} was not seen. GA4 makes it by itself, with no tag (first_visit and session_start from flags on its other hits, user_engagement when the page is hidden or left), so it needs no setup and is not counted as missing.`,
249
255
  missingParams: (name, params) => `${q(name)} was sent without ${andList(sortParams(params).map(q), "and")}.`,
250
256
  duplicate: (name, n) => `${q(name)} was sent ${n} times by one action. Every real one is counted ${n} times.`,
251
257
  purchaseValueZero: (platform, name, v) => `${label(platform)} ${q(name)} was sent with value ${q(v)}. A purchase worth 0 or less adds no revenue, so ad platforms cannot optimise bids on it.`,
@@ -333,6 +339,7 @@ const en = {
333
339
  pii_datalayer: "The dataLayer holds unhashed personal data, so every script on the page can read it, not only the tag that needs it.",
334
340
  journey_incomplete: "The browser could not finish this journey, so every event after that point, including any conversion, is unverified.",
335
341
  unplanned_events: "Events fire that are not in the plan: either the plan is out of date or tags send things nobody asked for.",
342
+ ga4_automatic_event: "Nothing is lost: GA4 makes this event itself, and a short test visit can end before it does.",
336
343
  planned_out_of_scope: "Some planned events are on platforms this run did not check, so their status is unknown.",
337
344
  matches_plan: "These planned events fired with everything the plan requires.",
338
345
  },
@@ -369,6 +376,7 @@ const en = {
369
376
  pii_datalayer: "Push user data to the dataLayer already hashed, or restrict it to the tag that needs it and clear it afterwards.",
370
377
  journey_incomplete: "Read the note for why the browser stopped (a blocked click, a missing element, an error), fix the page or the step, and re-run the journey.",
371
378
  unplanned_events: "Add the events to the measurement plan if they are wanted, or remove the tags that send them.",
379
+ ga4_automatic_event: "No fix is needed. Keep the event in the plan for reference, or remove it, since no tag has to send it.",
372
380
  planned_out_of_scope: "Include their platforms in the next run's providers so they are checked.",
373
381
  matches_plan: "Nothing to fix.",
374
382
  },
@@ -405,6 +413,7 @@ const en = {
405
413
  pii_datalayer: "legal",
406
414
  journey_incomplete: "hygiene",
407
415
  unplanned_events: "hygiene",
416
+ ga4_automatic_event: "hygiene",
408
417
  planned_out_of_scope: "hygiene",
409
418
  matches_plan: "hygiene",
410
419
  },
@@ -720,6 +729,7 @@ const en = {
720
729
 
721
730
  const tr = {
722
731
  name: "Turkish",
732
+ ga4Settings: { pageViews: "Sayfa görüntülemeleri", scrolls: "Kaydırmalar", outboundClicks: "Giden tıklamalar", siteSearch: "Site araması", videoEngagement: "Video etkileşimi", fileDownloads: "Dosya indirmeleri", formInteractions: "Form etkileşimleri" },
723
733
  severity: { broken: "Bozuk", risk: "Risk", warning: "Uyarı", info: "Bilgi", ok: "Sorunsuz" },
724
734
  ui: {
725
735
  title: (host) => `ai tag debugger raporu: ${host}`,
@@ -774,6 +784,8 @@ const tr = {
774
784
  customItemParams: "Özel ürün parametreleri",
775
785
  noItems: "ürün yok",
776
786
  serverSideTag: "sunucu tarafı",
787
+ lateHit: (ms) => `${(ms / 1000).toFixed(1).replace(".", ",")} sn gecikmeli geldi`,
788
+ lateHitTip: (step) => `Bu adım bittikten sonra, "${step}" sırasında gönderildi. Bu adımın sayfasına ait.`,
777
789
  source: "Kaynak",
778
790
  request: "İstek",
779
791
  noPushes: "Bu adımda dataLayer gönderimi yok.",
@@ -938,6 +950,8 @@ const tr = {
938
950
  journeyIncomplete: (note) => `Yolculuk hedefine ulaşmadı${note ? `: ${note}` : "."} Bu noktadan sonraki olaylar kontrol edilemedi.`,
939
951
  missingEvent: (platform, name) => `${label(platform)} ${q(name)} bu yolculukta gönderilmedi; oysa yolculuk hedefine ulaştı. ${label(platform)} bu olay için hiçbir veri almadı.`,
940
952
  missingEventEarly: (name) => `${q(name)} görülmedi; ancak yolculuk erken durduğu için bu olay eksik değil, doğrulanmamış sayılır.`,
953
+ missingEnhanced: (name, setting) => `GA4 ${q(name)} bu yolculukta gönderilmedi; oysa yolculuk hedefine ulaştı. GA4 bu olayı geliştirilmiş ölçüm açıkken etiket gerekmeden kendisi gönderir: GA4'te Yönetici > Veri akışları > web akışı > Geliştirilmiş ölçüm altında "${tr.ga4Settings[setting]}" ayarını açın. Olayı gönderen bir etiket de aynı işi görür.`,
954
+ missingAutomatic: (name) => `GA4 ${q(name)} görülmedi. GA4 bu olayı etiket gerekmeden kendisi oluşturur (first_visit ve session_start diğer isteklerindeki işaretlerden, user_engagement sayfa gizlendiğinde ya da kapatıldığında), bu yüzden kurulum gerektirmez ve eksik sayılmaz.`,
941
955
  missingParams: (name, params) => `${q(name)} olayı ${andList(sortParams(params).map(q), "ve")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
942
956
  duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi. Her gerçek olay ${n} kez sayılıyor.`,
943
957
  purchaseValueZero: (platform, name, v) => `${label(platform)} ${q(name)} ${q(v)} değeriyle gönderildi. 0 veya daha düşük değerli bir satın alma gelire eklenmez; reklam platformları teklifleri buna göre optimize edemez.`,
@@ -1024,6 +1038,7 @@ const tr = {
1024
1038
  pii_datalayer: "dataLayer karma yapılmamış kişisel veri tutuyor; sayfadaki her betik bunu okuyabilir, yalnızca ihtiyaç duyan etiket değil.",
1025
1039
  journey_incomplete: "Tarayıcı bu yolculuğu bitiremedi; o noktadan sonraki her olay, dönüşüm dâhil, doğrulanmamış durumda.",
1026
1040
  unplanned_events: "Planda olmayan olaylar gönderiliyor: ya plan güncel değil ya da etiketler kimsenin istemediği şeyler gönderiyor.",
1041
+ ga4_automatic_event: "Kayıp yok: GA4 bu olayı kendisi oluşturur ve kısa bir test ziyareti o olay gönderilmeden bitebilir.",
1027
1042
  planned_out_of_scope: "Plandaki bazı olaylar bu çalıştırmanın kontrol etmediği platformlarda; durumları bilinmiyor.",
1028
1043
  matches_plan: "Plandaki bu olaylar, planın istediği her şeyle birlikte gönderildi.",
1029
1044
  },
@@ -1058,6 +1073,7 @@ const tr = {
1058
1073
  pii_datalayer: "Kullanıcı verisini dataLayer'a karma yapılmış olarak gönderin ya da yalnızca ihtiyaç duyan etiketle sınırlayıp ardından temizleyin.",
1059
1074
  journey_incomplete: "Tarayıcının neden durduğunu nottan okuyun (engellenen bir tıklama, bulunamayan bir öğe, bir hata), sayfayı ya da adımı düzeltin ve yolculuğu yeniden çalıştırın.",
1060
1075
  unplanned_events: "İstenen olayları ölçüm planına ekleyin ya da onları gönderen etiketleri kaldırın.",
1076
+ ga4_automatic_event: "Düzeltme gerekmez. Olayı başvuru için planda tutabilir ya da kaldırabilirsiniz; hiçbir etiketin göndermesi gerekmez.",
1061
1077
  planned_out_of_scope: "Kontrol edilmeleri için platformlarını bir sonraki çalıştırmanın sağlayıcılarına ekleyin.",
1062
1078
  matches_plan: "Düzeltilecek bir şey yok.",
1063
1079
  },
@@ -1093,6 +1109,7 @@ const tr = {
1093
1109
  pii_datalayer: "legal",
1094
1110
  journey_incomplete: "hygiene",
1095
1111
  unplanned_events: "hygiene",
1112
+ ga4_automatic_event: "hygiene",
1096
1113
  planned_out_of_scope: "hygiene",
1097
1114
  matches_plan: "hygiene",
1098
1115
  },
package/src/mcp.js CHANGED
@@ -17,7 +17,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
17
17
  import { AuditSession } from "./browser.js";
18
18
  import { navigationError, privateHostChecker } from "./guard.js";
19
19
  import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
20
- import { checkRun, groupFindings, summarize } from "./checks.js";
20
+ import { checkRun, groupFindings, isConsentCommand, summarize } from "./checks.js";
21
21
  import { LANGUAGES, messages } from "./i18n.js";
22
22
  import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
23
23
  import { validatePlan } from "./schema.js";
@@ -29,7 +29,7 @@ import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
29
29
  import { createProvider } from "./providers.js";
30
30
  import { savePlan } from "./plan.js";
31
31
  import { changesLines, sinceLastRun } from "./changes.js";
32
- import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
32
+ import { DEFAULT_PROVIDERS, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
33
33
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
34
34
 
35
35
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -64,20 +64,68 @@ const audits = new Map();
64
64
  const text = (t) => ({ content: [{ type: "text", text: t }] });
65
65
  const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
66
66
 
67
- function describeHit(h) {
68
- const params = Object.entries(h.params ?? {})
69
- .filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k))
70
- .slice(0, 12)
71
- .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`.slice(0, 120))
72
- .join(", ");
73
- return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${Object.entries(h.consent).map(([k, v]) => `${k}=${v}`).join(" ")}]` : ""}${params ? `: ${params}` : ""}`;
67
+ // Kept whole in the step result, wherever they appear: the end of a page address is where a UTM
68
+ // parameter or a gclid gets lost, so cutting it hides exactly what a campaign check needs.
69
+ const WHOLE_FIELDS = new Set(["page_location", "page_referrer"]);
70
+ const VALUE_LIMIT = 120;
71
+ const PUSH_LIMIT = 200;
72
+ const cut = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
73
+ const show = (v) => (typeof v === "string" ? v : JSON.stringify(v));
74
+
75
+ /** The page address fields anywhere in a pushed object, a few levels deep, as [name, value]. */
76
+ function wholeFieldsIn(value, depth = 0, out = []) {
77
+ if (!value || typeof value !== "object" || depth > 3) return out;
78
+ for (const [k, v] of Object.entries(value)) {
79
+ if (WHOLE_FIELDS.has(k) && typeof v === "string") out.push([k, v]);
80
+ else wholeFieldsIn(v, depth + 1, out);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function describeHit(h, steps) {
86
+ const entries = Object.entries(h.params ?? {}).filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k));
87
+ const params = [
88
+ ...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
89
+ ...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
90
+ ].join(", ");
91
+ const late = h.late ? ` (arrived ${(h.late.ms / 1000).toFixed(1)} s late, after the step that loaded its page: "${steps[h.step]?.label ?? `step ${h.step}`}")` : "";
92
+ return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${Object.entries(h.consent).map(([k, v]) => `${k}=${v}`).join(" ")}]` : ""}${params ? `: ${params}` : ""}${late}`;
93
+ }
94
+
95
+ /** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
96
+ function describePush(p) {
97
+ if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
98
+ const signals = p.value[2] && typeof p.value[2] === "object" ? Object.entries(p.value[2]).map(([k, v]) => `${k}=${show(v)}`).join(" ") : show(p.value[2]);
99
+ return `- consent ${p.value[1]}: ${signals}`;
100
+ }
101
+ const json = JSON.stringify(p.value) ?? String(p.value);
102
+ const whole = json.length > PUSH_LIMIT ? wholeFieldsIn(p.value) : [];
103
+ return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
104
+ }
105
+
106
+ /** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent parameters. */
107
+ function describeCall(c) {
108
+ let u;
109
+ try {
110
+ u = new URL(c.url);
111
+ } catch {
112
+ return `- ${c.vendor ?? c.provider} ${cut(c.url, VALUE_LIMIT)}`;
113
+ }
114
+ const signals = ["gcs", "gcd", "dma", "npa"].filter((k) => u.searchParams.has(k)).map((k) => `${k}=${u.searchParams.get(k)}`).join(" ");
115
+ return `- ${c.vendor ?? c.provider}: ${u.hostname}${u.pathname}${signals ? ` [${signals}]` : ""}`;
74
116
  }
75
117
 
76
- function stepCapture(audit, step) {
77
- const hits = audit.session.hits.filter((h) => h.step === step);
78
- const pushes = audit.session.dataLayerLog.filter((e) => e.step === step);
79
- const lines = [`Tracking hits sent by this step: ${hits.length}`, ...hits.map(describeHit)];
80
- if (pushes.length) lines.push(`dataLayer pushes: ${pushes.map((p) => JSON.stringify(p.value).slice(0, 160)).join(" | ")}`);
118
+ export function stepCapture(audit, step) {
119
+ const { session } = audit;
120
+ const hits = session.hits.filter((h) => h.step === step);
121
+ const pushes = session.dataLayerLog.filter((e) => e.step === step);
122
+ // Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
123
+ const lateHere = session.hits.filter((h) => h.late?.during === step);
124
+ const pings = session.network.filter((c) => c.step === step && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
125
+ const lines = [`Tracking hits sent by this step: ${hits.length}`, ...hits.map((h) => describeHit(h, session.steps))];
126
+ if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
127
+ if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
128
+ if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
81
129
  return lines.join("\n");
82
130
  }
83
131
 
package/src/parsers.js CHANGED
@@ -64,6 +64,8 @@ export function parseGa4(url, postData) {
64
64
  for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
65
65
  const items = [...merged].filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v);
66
66
  const user = [...merged].filter(([k]) => isUserDataKey(k));
67
+ // GA4 makes first_visit and session_start from these flags on another hit; they are never sent by name.
68
+ const autoEvents = [["_fv", "first_visit"], ["_ss", "session_start"]].filter(([flag]) => merged.has(flag)).map(([, name]) => name);
67
69
  return {
68
70
  platform: "ga4",
69
71
  delivery: ga4Delivery(u),
@@ -72,6 +74,7 @@ export function parseGa4(url, postData) {
72
74
  params: ga4ParamsFrom(merged),
73
75
  consent: ga4Consent(merged),
74
76
  ...(items.length ? { items } : {}),
77
+ ...(autoEvents.length ? { autoEvents } : {}),
75
78
  ...(user.length ? { userFields: Object.fromEntries(user) } : {}),
76
79
  url,
77
80
  };
package/src/verify.js CHANGED
@@ -14,6 +14,7 @@ const OWNER_BY_CHECK = {
14
14
  server_side: "developer",
15
15
  unplanned_events: "marketing",
16
16
  planned_out_of_scope: "marketing",
17
+ ga4_automatic_event: "marketing",
17
18
  };
18
19
 
19
20
  /** Who most likely fixes this finding: the site's developer, whoever runs Tag Manager, or marketing (the plan itself). Tag and consent setup is the default. */