@analitikisler/ai-tag-debugger 0.19.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 +17 -0
- package/README.md +4 -4
- package/README.tr.md +4 -4
- package/package.json +2 -2
- package/src/browser.js +43 -3
- package/src/checks.js +47 -15
- package/src/html/css.js +185 -166
- package/src/html/journeys.js +8 -5
- package/src/i18n.js +17 -0
- package/src/mcp.js +62 -14
- package/src/parsers.js +3 -0
- package/src/plan-html.js +4 -4
- package/src/verify.js +1 -0
- package/src/whitelabel.js +48 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
12
|
+
## 0.20.0 (2026-10-25)
|
|
13
|
+
|
|
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.
|
|
15
|
+
- The HTML report and `plan.html` take the look of Tally, the Analitik İşler design system, so they read as one family with analitikisler.com: navy grounds (`#f4f6fa` and white in light, `#071529` and `#0e1e34` in dark), one blue for links, focus and selected states (`#1161bb` / `#69b1ff`), Google Sans, Google Sans Flex and Google Sans Code named first with the system fonts behind them (the report still ships no font files), 2 px chips and badges, 4 px buttons and inputs, 6 px cards, 1 px rules and no card shadows. Labels that were set in capitals are in sentence case.
|
|
16
|
+
- Severity colours follow the design system: broken, warning and ok are its red, amber and green, info is its blue (a fixed value, so a white label never recolours it), and risk keeps a plum of its own so it stays apart from broken where only a dot or a rule shows. Owner, platform, viewport and consent colours keep their hues, retuned for the navy grounds. Every text colour passes 4.5:1 and every mark 3:1 in both themes; `docs/design/visual-system.md` lists the values and ratios.
|
|
17
|
+
- White label: both themes now check the brand colour the same way. The link colour is darkened (light) or lightened (dark) in the brand's own hue until it reads at 4.5:1 on the card, the page, the hover ground, the well and the brand's own tint, and the colour of the tab and rail rules, focus rings and pressed filter edges until it reaches 3:1 on them. A pale yellow such as `#ffe066` no longer gives light links at 4.39:1 on their tint and a focus ring at 1.3:1 on white, and a dark navy brand no longer gives a focus ring that disappears into the dark card. A colour that already reads, such as `#0a7d5a`, is unchanged. The PDF page footer uses the report's font and muted colour.
|
|
18
|
+
- Without the Google Sans family, which most readers do not have, the score sits in the middle of its ring whatever face the system lends, and a score line that wraps keeps its points and chips beside its first line instead of halfway down. `docs/design/visual-system.md` lists what macOS, Windows, Linux and Android show in its place. On Windows 11 the stacks reach Segoe UI Variable, so labels keep their medium weight. The Summary's score lines stack below 1100px instead of 900px, so an event name no longer splits across lines with the rail open on a mid-width screen. A journey's name in the Summary's Journeys card no longer breaks in the middle of a word.
|
|
19
|
+
|
|
3
20
|
## 0.19.0 (2026-10-25)
|
|
4
21
|
|
|
5
22
|
- New brand: the tool's name is written `ai tag debugger`, lowercase, wherever the reports, `plan.html`, the README, the MCP registry listing and the GitHub Actions examples show it. The reports and `plan.html` carry the new lockup with its red point, the ai mark on phones, the shared ai favicon, and the Analitik İşler master logo with its red point and three bars in the byline and footer. The logos take the page's text colour in light, dark and print, with only the point in red. The "broken" red is the brand's one red, `#C12D30` in light and `#FA6B6D` in dark. The `aitd` and `ai-tag-debugger` commands, the npm package and the config keys are unchanged.
|
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 |
|
|
@@ -150,7 +150,7 @@ Adding a native provider means implementing two functions in [`src/providers.js`
|
|
|
150
150
|
|
|
151
151
|
## Quick start
|
|
152
152
|
|
|
153
|
-
Requires Node
|
|
153
|
+
Requires Node 22+.
|
|
154
154
|
|
|
155
155
|
```bash
|
|
156
156
|
npm install -g @analitikisler/ai-tag-debugger
|
|
@@ -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 |
|
|
@@ -150,7 +150,7 @@ Yerel bir sağlayıcı eklemek için [`src/providers.js`](src/providers.js) içi
|
|
|
150
150
|
|
|
151
151
|
## Hızlı başlangıç
|
|
152
152
|
|
|
153
|
-
Node
|
|
153
|
+
Node 22+ gerekir.
|
|
154
154
|
|
|
155
155
|
```bash
|
|
156
156
|
npm install -g @analitikisler/ai-tag-debugger
|
|
@@ -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.
|
|
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.
|
|
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": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"demo": "node src/cli.js run --config examples/demo.config.json --scripted --no-ai"
|
|
24
24
|
},
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=22"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"analytics",
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
173
|
-
if (
|
|
174
|
-
findings.push({
|
|
175
|
-
|
|
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") &&
|
|
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) =>
|
|
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 =
|
|
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
|
}
|