@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 +9 -0
- package/README.md +3 -3
- package/README.tr.md +3 -3
- package/package.json +1 -1
- package/src/browser.js +43 -3
- package/src/checks.js +47 -15
- 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/verify.js +1 -0
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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/html/journeys.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
const
|
|
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,
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
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. */
|