@analitikisler/ai-tag-debugger 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +24 -3
- package/README.tr.md +23 -2
- package/package.json +4 -1
- package/src/agent.js +2 -1
- package/src/browser.js +94 -13
- package/src/checks.js +17 -0
- package/src/containers.js +163 -0
- package/src/html/scope.js +15 -1
- package/src/i18n.js +78 -0
- package/src/mcp.js +66 -6
- package/src/report.js +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
- Runs can report findings 0.21.0 did not on the same site (`container_duplicate`, `container_environment`, `datalayer_split`, `container_send_to_only`). A baseline written with 0.21.0 can then list new entries; `--update-baseline` rewrites it.
|
|
6
|
+
- New **Google containers** card in the Inventory section and a table in `report.md`: every GTM container and Google tag the pages loaded, with how it got there (a script tag in the HTML, the inline snippet, another script such as a consent platform, a Custom HTML tag or a Google tag in another container, a Zone or template, a `gtag('config')` command, an event's `send_to`, or a destination of the Google tag), the dataLayer it reads, whether it was served by Google or from the site's own domain, and the GTM environment when one is named. The source is read from the page as each script is added, not guessed from network requests. `capture.json` keeps them under `containerLoads`.
|
|
7
|
+
- MCP: the new `get_containers` tool lists the Google containers an open audit's pages loaded so far, with their sources. Step results and `get_step` list the containers each step loaded, `finish_audit` and `run_audit` end with them, the JSON timeline carries them per step, and `get_report` returns them with `part: "containers"`.
|
|
8
|
+
- Pushes to a dataLayer of another name that a container reads (`l=myDataLayer`) are recorded, marked with that name.
|
|
9
|
+
- New checks: `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination only an event's `send_to` loaded.
|
|
10
|
+
- Development: the guide for coding agents is `AGENTS.md`, the name agents of any vendor read, instead of `CLAUDE.md`, and CONTRIBUTING.md points to it.
|
|
11
|
+
- Development: ESLint's `minimatch` is held at 11 through `overrides` in package.json, for the globparts denial-of-service advisory (GHSA-g3hv-75wg-m249) that no 3.x or 10.x release fixes; ESLint itself still ships an older one. The tool as installed from npm was never affected: `minimatch` is only a development dependency. `npm audit` reports none.
|
|
12
|
+
|
|
3
13
|
## 0.21.0 (2026-11-03)
|
|
4
14
|
|
|
5
15
|
- Runs can report findings 0.20.1 did not on the same site (hits with `ad_user_data` or `ad_personalization` granted before consent or after a reject, `gclid_not_stored`, `client_id_changed`, `consent_default_not_applied`, `page_blocked`, `page_crashed`, and tags sent with cookies when the visitor made no choice), and no longer reports `consent_default_missing` where a consent platform sets the default inside GTM. A baseline written with 0.20.1 can then list stale or new entries; `--update-baseline` rewrites it.
|
package/README.md
CHANGED
|
@@ -309,6 +309,26 @@ When the plan is not the site's first, the header compares it with the version s
|
|
|
309
309
|
|
|
310
310
|
Every report also has a **tag inventory**: each vendor the site sent requests to, marked first party when its requests went to the site's own domain, third party when they went to the vendor, or both. It shows how many pages the vendor was seen on and the cookies tied to it. Cookies that no vendor owns are listed apart, as first or third party. Only cookie names are recorded, never their values, with two exceptions that are derived rather than copied: the GA4 client id that `_ga` holds (GA4 hits carry it anyway), and whether `_gcl_aw` and `_fbc` hold the click ids the landing page was opened with. Each step also notes which cookies it added, changed or removed, by name, in its **Cookies** tab under **Journeys**. It is the **Inventory** section of `report.html` and a table in `report.md`.
|
|
311
311
|
|
|
312
|
+
### Google containers
|
|
313
|
+
|
|
314
|
+
Under the inventory, **Google containers** lists every GTM container (`gtm.js`) and Google tag (`gtag/js`) the pages loaded, and how each one got there. The browser notes every container script as it is added and, when a script added it, which scripts were running at that moment, so the source is read from the page itself rather than guessed from network requests:
|
|
315
|
+
|
|
316
|
+
| Source | Meaning |
|
|
317
|
+
|---|---|
|
|
318
|
+
| A script tag in the page's HTML | The `gtag.js` snippet, or `gtm.js` referenced directly |
|
|
319
|
+
| Inline code in the page | The GTM snippet, or a hand-written loader |
|
|
320
|
+
| Another script on the page | A consent platform, the site's bundle or another tag manager; its address is given |
|
|
321
|
+
| A Custom HTML tag in GTM-… | A Custom HTML tag in that container inserted it |
|
|
322
|
+
| A Google tag in GTM-… | A Google tag or template in that container, through GTM's `load_google_tags` |
|
|
323
|
+
| GTM-… itself | One GTM container loaded another: a Zone or a tag template |
|
|
324
|
+
| A `gtag('config')` command | The Google tag loaded the destination because the page configured it |
|
|
325
|
+
| An event's `send_to` with no config | The Google tag loaded the destination only because an event was sent to it |
|
|
326
|
+
| A destination of the Google tag | The Google tag loaded it with no command in the page, such as a linked destination |
|
|
327
|
+
|
|
328
|
+
Each row also gives the dataLayer the container reads (`l=`), whether it came from Google or from the site's own domain (the Google tag gateway or a server container), the GTM environment when the snippet names one, and the pages it loaded on. A container reading a dataLayer of another name has its pushes recorded too, marked with that name. `report.md` has the same table. Over MCP, step results and `get_step` list the containers each step loaded, `get_containers` lists them all while the audit is open, `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "containers"` and the JSON timeline carries them per step. `capture.json` keeps the loads under `containerLoads`. The environment token (`gtm_auth`) and the page's query string are never kept.
|
|
329
|
+
|
|
330
|
+
Four checks read it: `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment (`gtm_preview`, `gtm_auth`) loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination that only an event's `send_to` loaded, with no `gtag('config')` for it.
|
|
331
|
+
|
|
312
332
|
### Folder layout and plan IDs
|
|
313
333
|
|
|
314
334
|
Plans and runs are kept in `aitd/` in the folder you run the tool from:
|
|
@@ -604,12 +624,12 @@ Output:
|
|
|
604
624
|
- **Journeys**: one journey and viewport at a time. The steps on the left, named after the pages ("Open Home", "Add to cart") and grouped by page, with the hits each step produced; the selected step on the right with **Hits**, **Data Layer** and **Requests** tabs. Each hit shows its event, key parameters and consent state, and opens into the decoded parameters, item cards and the raw request. A batched GA4 request shows each of its events. It opens on the first step with a problem.
|
|
605
625
|
- **Consent**: one line per platform with what it received before a choice, after accept and after reject, the Consent Mode default and updates with their timing, and the [consent scenarios](#consent-scenarios) table.
|
|
606
626
|
- **Coverage**: whether each planned event was sent on each journey, with the parameters it still needs, conversion integrity, and, with `--against`, the [Plan vs. site](#plan-vs-site) buckets.
|
|
607
|
-
- **Inventory**: the [tag inventory](#tag-inventory), one line per platform, opening into its hosts, pages and cookies.
|
|
627
|
+
- **Inventory**: the [tag inventory](#tag-inventory), one line per platform, opening into its hosts, pages and cookies, and the [Google containers](#google-containers) with how each reached the page.
|
|
608
628
|
- **Run**: the facts of the run (site, version, AI provider and model, plan, journeys, viewports, platforms, timing, files) and, when the site has earlier runs, the **Trend**.
|
|
609
629
|
|
|
610
630
|
Every card starts collapsed except the Summary; each section has Expand all and Collapse all. The page carries one small script, allowed by its hash in the page's Content Security Policy, for the rail, the filters, the tabs and the deep links; without JavaScript the sections stack into one document, which is also the print layout. Keyboard: `[` folds the rail, `1`–`7` jump to a section. The logos are embedded, so nothing is loaded from the internet. `--no-branding` leaves out the Analitik İşler logo and credit line.
|
|
611
631
|
- `timeline.csv`: the same timeline as one row per hit, dataLayer push and network call, ready for Excel or Google Sheets.
|
|
612
|
-
- `report.md`: the report in Markdown for a pull request comment, a Slack message or a mail, around 100 lines and under 8 KB for a four-journey run. Its title is the verdict (`# shop.example: purchase tracking is broken on mobile`), then one meta line, the status with the data quality score, and the counts. Then, in this order: **Changed since last run** and **Trend** (when the site has earlier runs), **Plan vs. site** (with `--against`), **Priorities** (the to-do list per owner: Developer, Tag Manager, Marketing), **Findings** (up to five full blocks with Where, Impact, Cause, Evidence, Also fine on, Owner, Fix, How to verify and a link into `report.html`; the rest one line each under *Also*), **Consent** (the Consent Mode timing sentence and the scenario table with a reason in every cell), **What passed**, **Journeys** (one row per run, then only the steps that carry a problem), **Not checked** (what the run could not judge, with the config key that enables it)
|
|
632
|
+
- `report.md`: the report in Markdown for a pull request comment, a Slack message or a mail, around 100 lines and under 8 KB for a four-journey run. Its title is the verdict (`# shop.example: purchase tracking is broken on mobile`), then one meta line, the status with the data quality score, and the counts. Then, in this order: **Changed since last run** and **Trend** (when the site has earlier runs), **Plan vs. site** (with `--against`), **Priorities** (the to-do list per owner: Developer, Tag Manager, Marketing), **Findings** (up to five full blocks with Where, Impact, Cause, Evidence, Also fine on, Owner, Fix, How to verify and a link into `report.html`; the rest one line each under *Also*), **Consent** (the Consent Mode timing sentence and the scenario table with a reason in every cell), **What passed**, **Journeys** (one row per run, then only the steps that carry a problem), **Not checked** (what the run could not judge, with the config key that enables it), the **Tag inventory** and **Google containers**. The per-hit timeline is not in the file; `timeline.csv` has it.
|
|
613
633
|
- `capture.json`: everything captured, for your own analysis, including the tool `version`, the `ai` provider and the computed `metrics` (verdict, score, plan coverage, funnel, platform parity, consent compliance, conversion integrity, timing, priorities).
|
|
614
634
|
- `plan-check.json`: with `--against`, one entry per planned event with its bucket.
|
|
615
635
|
- `junit.xml`: the findings as JUnit test results, one suite per journey and viewport, for CI systems to show as test failures (see above).
|
|
@@ -637,7 +657,8 @@ Tools the server offers:
|
|
|
637
657
|
| `get_step` | Shows what an earlier `step` captured again, as `browser_action` showed it, including a page_view that came late and was added to it. |
|
|
638
658
|
| `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. The result ends with a compact JSON timeline, one entry per step: the hits with their consent, consent commands, other tracking calls, the Google tag's consent when it changed, and the cookies and ids that changed. |
|
|
639
659
|
| `consent_matrix` | Opens a site's start page like an ad click once per consent choice (no choice and on to the next page, accept all, reject all, analytics only), each in a fresh browser, and returns which tags sent after each choice and the [consent matrix](#consent-scenarios). `campaign` takes the campaign parameters to check (as in `start_audit`), `concurrency` how many choices run at once (default 2, at most 4). About half a minute. |
|
|
640
|
-
| `
|
|
660
|
+
| `get_containers` | Lists every GTM container and Google tag the open audit's pages loaded so far, with how each reached the page (see [Google containers](#google-containers)), its dataLayer, host, GTM environment and the step it loaded at, as text and JSON. |
|
|
661
|
+
| `get_report` | Reads a report this server wrote, by the name `finish_audit` or `run_audit` gave: `part` `markdown` (default, `report.md`), `summary` (`summary.json`), `timeline` (the JSON timeline of every journey, with the containers each step loaded) or `containers` (every Google container with how it reached the page, as JSON). Only this server's own reports, over HTTP too. |
|
|
641
662
|
| `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. |
|
|
642
663
|
|
|
643
664
|
`against` takes the same values as the CLI's `--against`. Over HTTP it takes only `latest`, `first` or a plan ID, never a path. Plans are the ones saved by `aitd plan --site <url>` or by `make_plan`. Ask for a plan when you want to know what to track, and for an audit when you want to know what the site sends.
|
package/README.tr.md
CHANGED
|
@@ -309,6 +309,26 @@ Kontrol edilen plan sitenin ilk planı değilse, rapor başlığı onu bir önce
|
|
|
309
309
|
|
|
310
310
|
Her raporda ayrıca bir **etiket envanteri** bulunur: sitenin istek gönderdiği her sağlayıcı; istekleri sitenin kendi alan adına gittiyse birinci taraf, sağlayıcıya gittiyse üçüncü taraf, ikisine de gittiyse ikisi olarak işaretlenir. Sağlayıcının görüldüğü sayfa sayısını ve ona bağlı çerezleri gösterir. Hiçbir sağlayıcıya ait olmayan çerezler ayrıca birinci ya da üçüncü taraf olarak listelenir. Yalnızca çerez adları kaydedilir, değerleri asla; kopyalanmayıp türetilen iki istisna dışında: `_ga`'nın tuttuğu GA4 istemci kimliği (GA4 istekleri onu zaten taşır) ve `_gcl_aw` ile `_fbc`'nin açılış sayfasının açıldığı tıklama kimliklerini tutup tutmadığı. Her adım ayrıca eklediği, değiştirdiği ya da sildiği çerezleri adlarıyla, **Yolculuklar** altındaki **Çerezler** sekmesinde not eder. Envanter `report.html` içindeki **Envanter** bölümünde, `report.md` içinde ise bir tablo olarak yer alır.
|
|
311
311
|
|
|
312
|
+
### Google container'ları
|
|
313
|
+
|
|
314
|
+
Envanterin altında **Google container'ları**, sayfaların yüklediği her GTM container'ını (`gtm.js`) ve Google etiketini (`gtag/js`) ve her birinin sayfaya nasıl geldiğini listeler. Tarayıcı her container betiğini eklendiği anda not eder ve onu bir betik eklediyse o anda hangi betiklerin çalıştığını kaydeder; böylece kaynak ağ isteklerinden tahmin edilmez, sayfanın kendisinden okunur:
|
|
315
|
+
|
|
316
|
+
| Kaynak | Anlamı |
|
|
317
|
+
|---|---|
|
|
318
|
+
| Sayfanın HTML'indeki bir script etiketi | `gtag.js` snippet'i ya da doğrudan eklenmiş `gtm.js` |
|
|
319
|
+
| Sayfadaki satır içi kod | GTM snippet'i ya da elle yazılmış bir yükleyici |
|
|
320
|
+
| Sayfadaki başka bir betik | Bir onay platformu, sitenin paketi ya da başka bir etiket yöneticisi; adresi verilir |
|
|
321
|
+
| GTM-… içindeki bir Özel HTML etiketi | O container'daki bir Özel HTML etiketi ekledi |
|
|
322
|
+
| GTM-… içindeki bir Google etiketi | O container'daki bir Google etiketi ya da şablonu, GTM'in `load_google_tags` API'siyle |
|
|
323
|
+
| GTM-… kendisi | Bir GTM container'ı başka birini yükledi: bir Zone ya da etiket şablonu |
|
|
324
|
+
| Bir `gtag('config')` komutu | Sayfa hedefi yapılandırdığı için Google etiketi onu yükledi |
|
|
325
|
+
| config olmadan bir olayın `send_to` değeri | Google etiketi hedefi yalnızca ona bir olay gönderildiği için yükledi |
|
|
326
|
+
| Google etiketinin bir hedefi | Google etiketi onu sayfada bir komut olmadan yükledi, örneğin bağlı bir hedef |
|
|
327
|
+
|
|
328
|
+
Her satır ayrıca container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yoksa sitenin kendi alan adından mı (Google etiket ağ geçidi ya da bir sunucu container'ı) geldiğini, snippet bir GTM ortamı adlandırıyorsa onu ve yüklendiği sayfaları verir. Başka adlı bir dataLayer okuyan container'ın push'ları da o adla işaretlenerek kaydedilir. `report.md` aynı tabloyu içerir. MCP'de adım sonuçları ve `get_step` her adımın yüklediği container'ları listeler, `get_containers` denetim açıkken hepsini listeler, `finish_audit` ve `run_audit` sonuçları onlarla biter, `get_report` onları `part: "containers"` ile döndürür ve JSON zaman akışı onları adım adım taşır. `capture.json` yüklemeleri `containerLoads` altında saklar. Ortam belirteci (`gtm_auth`) ve sayfanın sorgu dizesi asla saklanmaz.
|
|
329
|
+
|
|
330
|
+
Dört kontrol bunu okur: aynı sayfaya birden fazla kez yüklenen bir container için `container_duplicate` (uyarı), Live yerine yüklenen bir GTM ortamı (`gtm_preview`, `gtm_auth`) için `container_environment` (uyarı), aynı sayfada farklı dataLayer'ları okuyan container'lar için `datalayer_split` (bilgi) ve kendisi için `gtag('config')` olmadan yalnızca bir olayın `send_to` değeriyle yüklenen bir hedef için `container_send_to_only` (bilgi).
|
|
331
|
+
|
|
312
332
|
### Klasör yapısı ve plan kimlikleri
|
|
313
333
|
|
|
314
334
|
Planlar ve çalıştırmalar, aracı çalıştırdığınız klasördeki `aitd/` içinde tutulur:
|
|
@@ -604,7 +624,7 @@ jq -r '.verdict.status + " " + (.score | tostring)' aitd/example.com/runs/*/summ
|
|
|
604
624
|
- **Yolculuklar**: her seferinde bir yolculuk ve ekran. Solda sayfalara göre adlandırılmış ("Ana sayfayı aç", "Sepete ekle") ve sayfaya göre gruplanmış adımlar, her adımın ürettiği isteklerle; sağda seçili adım, **İstekler**, **Data Layer** ve **Ağ** sekmeleriyle. Her istek event'ini, temel parametrelerini ve çerez onayı durumunu gösterir; çözülmüş parametrelere, ürün kartlarına ve ham isteğe açılır. Toplu bir GA4 isteği içindeki her event'i gösterir. Sorunlu ilk adımda açılır.
|
|
605
625
|
- **Onay**: her platform için bir satır: seçimden önce, kabulden sonra ve retten sonra ne aldığı; Consent Mode varsayılanı ve güncellemeleri zamanlamalarıyla; ve [onay senaryoları](#onay-senaryoları) tablosu.
|
|
606
626
|
- **Kapsam**: planlanan her event'in her yolculukta gönderilip gönderilmediği, hâlâ ihtiyaç duyduğu parametrelerle; dönüşüm bütünlüğü; ve `--against` ile [Plan ve site](#plan-ve-site) kovaları.
|
|
607
|
-
- **Envanter**: [etiket envanteri](#etiket-envanteri), platform başına bir satır; sunucularına, sayfalarına ve çerezlerine açılır.
|
|
627
|
+
- **Envanter**: [etiket envanteri](#etiket-envanteri), platform başına bir satır; sunucularına, sayfalarına ve çerezlerine açılır; ayrıca her birinin sayfaya nasıl geldiğiyle [Google container'ları](#google-containerları).
|
|
608
628
|
- **Çalıştırma**: çalıştırmanın künyesi (site, sürüm, yapay zekâ sağlayıcısı ve modeli, plan, yolculuklar, ekranlar, platformlar, süreler, dosyalar) ve sitenin önceki çalıştırmaları varsa **Eğilim**.
|
|
609
629
|
|
|
610
630
|
Özet dışında her kart kapalı başlar; her bölümde Tümünü aç ve Tümünü kapat düğmeleri vardır. Sayfada şerit, filtreler, sekmeler ve derin bağlantılar için, sayfanın Content Security Policy'sinde özetiyle izin verilmiş küçük bir betik bulunur; JavaScript olmadan bölümler tek bir belge olarak alt alta dizilir, yazdırma düzeni de budur. Klavye: `[` şeridi katlar, `1`–`7` bir bölüme atlar. Logolar gömülüdür, bu yüzden internetten hiçbir şey yüklenmez. `--no-branding`, Analitik İşler logosunu ve kredi satırını çıkarır.
|
|
@@ -637,7 +657,8 @@ Sunucunun sunduğu araçlar:
|
|
|
637
657
|
| `get_step` | Önceki bir adımın (`step`) yakaladıklarını `browser_action`'ın gösterdiği gibi yeniden gösterir; geç gelip o adıma eklenen page_view da dahil. |
|
|
638
658
|
| `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. Sonuç, adım başına bir kayıt içeren kısa bir JSON zaman akışıyla biter: onay durumlarıyla istekler, onay komutları, diğer takip çağrıları, değiştiğinde Google etiketinin onay durumu, değişen çerezler ve kimlikler. |
|
|
639
659
|
| `consent_matrix` | Bir sitenin başlangıç sayfasını her onay seçimi için (seçim yapmadan sonraki sayfa, tümünü kabul, tümünü reddet, yalnızca analitik) bir reklam tıklaması gibi, her seferinde yeni bir tarayıcıda açar; her seçimden sonra hangi etiketlerin gönderdiğini ve [onay matrisini](#onay-senaryoları) döndürür. `campaign` kontrol edilecek kampanya parametrelerini (`start_audit`'teki gibi), `concurrency` aynı anda kaç seçimin çalışacağını alır (varsayılan 2, en fazla 4). Yaklaşık yarım dakika sürer. |
|
|
640
|
-
| `
|
|
660
|
+
| `get_containers` | Açık denetimin sayfalarının şimdiye kadar yüklediği her GTM container'ını ve Google etiketini, her birinin sayfaya nasıl geldiğiyle ([Google container'ları](#google-containerları)), dataLayer'ı, sunucusu, GTM ortamı ve yüklendiği adımla birlikte metin ve JSON olarak listeler. |
|
|
661
|
+
| `get_report` | Bu sunucunun yazdığı bir raporu, `finish_audit` ya da `run_audit`'in verdiği adla okur: `part` `markdown` (varsayılan, `report.md`), `summary` (`summary.json`), `timeline` (her yolculuğun JSON zaman akışı; her adımın yüklediği container'larla) ya da `containers` (sayfaya nasıl geldiğiyle her Google container'ı, JSON olarak). Yalnızca bu sunucunun kendi raporları, HTTP üzerinden de. |
|
|
641
662
|
| `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. |
|
|
642
663
|
|
|
643
664
|
`against`, CLI'deki `--against` ile aynı değerleri alır. HTTP üzerinden yalnızca `latest`, `first` ya da bir plan kimliği kabul edilir, asla bir yol değil. Planlar, `aitd plan --site <url>` ya da `make_plan` ile kaydedilmiş olanlardır. Neyin takip edileceğini öğrenmek için plan, sitenin ne gönderdiğini öğrenmek için denetim isteyin.
|
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.22.0",
|
|
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": {
|
|
@@ -67,5 +67,8 @@
|
|
|
67
67
|
"@eslint/js": "^9.39.5",
|
|
68
68
|
"eslint": "^9.39.5",
|
|
69
69
|
"globals": "^17.13.0"
|
|
70
|
+
},
|
|
71
|
+
"overrides": {
|
|
72
|
+
"minimatch": "^11.0.4"
|
|
70
73
|
}
|
|
71
74
|
}
|
package/src/agent.js
CHANGED
|
@@ -237,7 +237,7 @@ export const journeyError = (err) => `The journey stopped with an error: ${err?.
|
|
|
237
237
|
export function failedRun(journey, viewport, err, providers) {
|
|
238
238
|
return {
|
|
239
239
|
journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
|
|
240
|
-
cookieLog: [], cookieChanges: [], dataLayerLog: [], network: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
|
|
240
|
+
cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
@@ -256,6 +256,7 @@ export function collect(session, journey, viewport) {
|
|
|
256
256
|
// Hits let through to the real accounts (block_hits false, or a live MCP audit): the reports say so.
|
|
257
257
|
...(session.opts.blockHits === false ? { live: true } : {}),
|
|
258
258
|
dataLayerLog: session.dataLayerLog,
|
|
259
|
+
containerLoads: session.containerLoads,
|
|
259
260
|
network: session.network,
|
|
260
261
|
providers: session.providers,
|
|
261
262
|
skipped: session.skipped,
|
package/src/browser.js
CHANGED
|
@@ -10,6 +10,7 @@ import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from
|
|
|
10
10
|
import { startEgressProxy } from "./egress.js";
|
|
11
11
|
import { campaignParamsIn } from "./campaign.js";
|
|
12
12
|
import { detectCmpInPage } from "./cmp.js";
|
|
13
|
+
import { callersOf, containerOf } from "./containers.js";
|
|
13
14
|
|
|
14
15
|
// Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
|
|
15
16
|
// one of these always blocks the journey, and a reject click is checked against them afterwards.
|
|
@@ -34,7 +35,7 @@ export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk
|
|
|
34
35
|
// Efilli draws its banner inside the open shadow root of <efilli-layout-dynamic>, so the checks below also look inside open shadow roots.
|
|
35
36
|
|
|
36
37
|
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
37
|
-
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
|
|
38
|
+
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500 };
|
|
38
39
|
|
|
39
40
|
// Secrets the browser process has no use for. It gets the rest of the environment
|
|
40
41
|
// (PATH, HOME, DISPLAY...) so Chromium still starts normally.
|
|
@@ -60,8 +61,57 @@ const hostOf = (url) => {
|
|
|
60
61
|
}
|
|
61
62
|
};
|
|
62
63
|
|
|
64
|
+
// Runs inside INIT_SCRIPT: records each Google container script (gtm.js, gtag/js) the page adds, through
|
|
65
|
+
// window.__atdLoad, with how it got there. One set by a script carries that moment's call stack, whose script
|
|
66
|
+
// addresses tell who asked for it (see src/containers.js); one the HTML parser inserted never passes through
|
|
67
|
+
// the setters, so a MutationObserver reports it. A container reading another dataLayer gets it watched.
|
|
68
|
+
const CONTAINER_HOOK = `
|
|
69
|
+
const docUrl = location.href;
|
|
70
|
+
const doc = Math.random().toString(36).slice(2, 10);
|
|
71
|
+
const isContainer = (src) => /\\/(gtm\\.js|gtag\\/js)\\?/.test(String(src)) && /[?&]id=/.test(String(src));
|
|
72
|
+
const seen = new WeakSet();
|
|
73
|
+
const report = (el, src, how) => {
|
|
74
|
+
try {
|
|
75
|
+
const abs = new URL(String(src), document.baseURI).href;
|
|
76
|
+
if (!isContainer(abs) || !window.__atdLoad) return;
|
|
77
|
+
const layer = new URL(abs).searchParams.get("l") || "dataLayer";
|
|
78
|
+
watch(layer);
|
|
79
|
+
let frames = [];
|
|
80
|
+
if (how === "script") {
|
|
81
|
+
const limit = Error.stackTraceLimit;
|
|
82
|
+
Error.stackTraceLimit = 50;
|
|
83
|
+
const stack = String(new Error().stack || "");
|
|
84
|
+
Error.stackTraceLimit = limit;
|
|
85
|
+
// The first two frames are report() and the setter, both from this script; injected inline code
|
|
86
|
+
// (a Custom HTML tag) has no address and shows as <anonymous>.
|
|
87
|
+
frames = stack.split("\\n").filter((line) => /^\\s+at /.test(line)).slice(2)
|
|
88
|
+
.map((line) => (line.match(/(https?:\\/\\/[^\\s()]+?):\\d+:\\d+/) || [])[1] || (/<anonymous>/.test(line) ? "inline" : null))
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
window.__atdLoad({ src: abs, how, frames, documentUrl: docUrl, page: location.href, doc });
|
|
92
|
+
} catch {}
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
const desc = Object.getOwnPropertyDescriptor(HTMLScriptElement.prototype, "src");
|
|
96
|
+
Object.defineProperty(HTMLScriptElement.prototype, "src", {
|
|
97
|
+
...desc,
|
|
98
|
+
set(v) { if (!seen.has(this)) { seen.add(this); report(this, v, "script"); } return desc.set.call(this, v); },
|
|
99
|
+
});
|
|
100
|
+
const setAttribute = Element.prototype.setAttribute;
|
|
101
|
+
Element.prototype.setAttribute = function (name, v) {
|
|
102
|
+
if (this instanceof HTMLScriptElement && String(name).toLowerCase() === "src" && !seen.has(this)) { seen.add(this); report(this, v, "script"); }
|
|
103
|
+
return setAttribute.call(this, name, v);
|
|
104
|
+
};
|
|
105
|
+
new MutationObserver((list) => {
|
|
106
|
+
for (const m of list) for (const n of m.addedNodes) {
|
|
107
|
+
if (n instanceof HTMLScriptElement && n.src && !seen.has(n)) { seen.add(n); report(n, n.src, "parser"); }
|
|
108
|
+
}
|
|
109
|
+
}).observe(document, { childList: true, subtree: true });
|
|
110
|
+
} catch {}
|
|
111
|
+
`;
|
|
112
|
+
|
|
63
113
|
const INIT_SCRIPT = `(() => {
|
|
64
|
-
const record = (entry) => {
|
|
114
|
+
const record = (entry, layer = "dataLayer") => {
|
|
65
115
|
let value = entry;
|
|
66
116
|
try {
|
|
67
117
|
// gtag() pushes an arguments object; store it as a plain array.
|
|
@@ -95,22 +145,32 @@ const INIT_SCRIPT = `(() => {
|
|
|
95
145
|
};
|
|
96
146
|
value = JSON.parse(JSON.stringify(copy(value, [])));
|
|
97
147
|
} catch { value = String(entry); }
|
|
98
|
-
if (window.__atdRecord) window.__atdRecord({ url: location.href, value });
|
|
148
|
+
if (window.__atdRecord) window.__atdRecord({ url: location.href, value, ...(layer === "dataLayer" ? {} : { layer }) });
|
|
99
149
|
};
|
|
100
|
-
const wrap = (dl) => {
|
|
101
|
-
if (!dl || dl.__atdWrapped) return dl;
|
|
102
|
-
dl.forEach(record);
|
|
150
|
+
const wrap = (dl, layer) => {
|
|
151
|
+
if (!dl || dl.__atdWrapped || typeof dl.push !== "function") return dl;
|
|
152
|
+
dl.forEach((e) => record(e, layer));
|
|
103
153
|
const push = dl.push.bind(dl);
|
|
104
|
-
dl.push = (...items) => { items.forEach(record); return push(...items); };
|
|
154
|
+
dl.push = (...items) => { items.forEach((e) => record(e, layer)); return push(...items); };
|
|
105
155
|
dl.__atdWrapped = true;
|
|
106
156
|
return dl;
|
|
107
157
|
};
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
158
|
+
const watched = new Set();
|
|
159
|
+
// A container can read a dataLayer of another name (gtm.js?l=myDataLayer); it is watched once one is seen.
|
|
160
|
+
const watch = (layer) => {
|
|
161
|
+
if (watched.has(layer) || !/^[A-Za-z_$][\\w$]*$/.test(layer)) return;
|
|
162
|
+
watched.add(layer);
|
|
163
|
+
try {
|
|
164
|
+
let current = layer === "dataLayer" ? wrap(window[layer] || [], layer) : wrap(window[layer], layer);
|
|
165
|
+
Object.defineProperty(window, layer, {
|
|
166
|
+
configurable: true,
|
|
167
|
+
get: () => current,
|
|
168
|
+
set: (v) => { current = wrap(v, layer); },
|
|
169
|
+
});
|
|
170
|
+
} catch { /* a property the page made unconfigurable */ }
|
|
171
|
+
};
|
|
172
|
+
watch("dataLayer");
|
|
173
|
+
${CONTAINER_HOOK}
|
|
114
174
|
})();`;
|
|
115
175
|
|
|
116
176
|
// Labels the interactive elements of one frame with numeric refs, starting at
|
|
@@ -486,6 +546,7 @@ export class AuditSession {
|
|
|
486
546
|
this.refusedScripts = new Map(); // step -> how many of the page's scripts were refused for coming back as HTML
|
|
487
547
|
this.errors = [];
|
|
488
548
|
this.dataLayerLog = [];
|
|
549
|
+
this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
|
|
489
550
|
this.network = [];
|
|
490
551
|
this.providers = resolveProviders(this.opts.providers);
|
|
491
552
|
this.landing = null; // the first page: { step, requested, chain, final }
|
|
@@ -530,6 +591,7 @@ export class AuditSession {
|
|
|
530
591
|
this.lastTrackingAt = Date.now();
|
|
531
592
|
this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
|
|
532
593
|
});
|
|
594
|
+
await this.context.exposeBinding("__atdLoad", (_source, entry) => this.recordLoad(entry));
|
|
533
595
|
await this.context.addInitScript(INIT_SCRIPT);
|
|
534
596
|
this.page = await this.context.newPage();
|
|
535
597
|
this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
|
|
@@ -756,6 +818,25 @@ export class AuditSession {
|
|
|
756
818
|
return { postData: this.opts.keepBodies ? body : redactBody(body).text };
|
|
757
819
|
}
|
|
758
820
|
|
|
821
|
+
/**
|
|
822
|
+
* A container script the page added: its ID, dataLayer and host, whether the HTML parser inserted it or a
|
|
823
|
+
* script did and, for a script, the callers on the stack. Only addresses without their query are kept, so
|
|
824
|
+
* the environment token (gtm_auth) and user data in a page's query string never reach the capture.
|
|
825
|
+
*/
|
|
826
|
+
recordLoad(entry) {
|
|
827
|
+
const container = containerOf(entry?.src);
|
|
828
|
+
if (!container) return;
|
|
829
|
+
this.record("containerLoads", {
|
|
830
|
+
...container,
|
|
831
|
+
how: entry.how === "parser" ? "parser" : "script",
|
|
832
|
+
...(entry.how === "parser" ? {} : { callers: callersOf(entry.frames, entry.documentUrl) }),
|
|
833
|
+
page: pagePath(entry.page),
|
|
834
|
+
doc: String(entry.doc ?? "").slice(0, 16),
|
|
835
|
+
step: this.currentStep,
|
|
836
|
+
t: Date.now(),
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
759
840
|
/** Adds an entry to one of the logs, up to its limit. Returns false once the limit is reached. */
|
|
760
841
|
record(log, entry) {
|
|
761
842
|
if (this[log].length >= LOG_LIMITS[log]) {
|
package/src/checks.js
CHANGED
|
@@ -5,6 +5,7 @@ import { messages } from "./i18n.js";
|
|
|
5
5
|
import { isOwnAddress } from "./redact.js";
|
|
6
6
|
import { campaignParamsIn } from "./campaign.js";
|
|
7
7
|
import { CONSENT_SIGNALS } from "./parsers.js";
|
|
8
|
+
import { duplicateLoads, loadSources, splitLayers } from "./containers.js";
|
|
8
9
|
|
|
9
10
|
const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
|
|
10
11
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
@@ -366,6 +367,22 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
366
367
|
}
|
|
367
368
|
}
|
|
368
369
|
|
|
370
|
+
// Google containers and how each got onto the page (src/containers.js). Runs saved by older versions have none.
|
|
371
|
+
const ui = messages(lang).ui.containers;
|
|
372
|
+
const source = (l) => ui.kinds[l.kind](l.by);
|
|
373
|
+
for (const dup of duplicateLoads(run).filter((x, i, all) => all.findIndex((y) => y.id === x.id) === i)) {
|
|
374
|
+
findings.push({ ...base, severity: "warning", check: "container_duplicate", step: stepLabel(dup.loads[1].step), detail: d.containerDuplicate(dup.id, dup.loads.map(source), stepLabel(dup.loads[1].step)), evidence: dup.loads.map((l) => `${l.id} ${l.kind}${l.by ? ` ${l.by}` : ""} ${l.page ?? ""}`).join("\n") });
|
|
375
|
+
}
|
|
376
|
+
const loads = loadSources(run);
|
|
377
|
+
for (const l of loads.filter((x, i, all) => x.environment && all.findIndex((y) => y.environment && y.id === x.id) === i)) {
|
|
378
|
+
findings.push({ ...base, severity: "warning", check: "container_environment", step: stepLabel(l.step), detail: d.containerEnvironment(l.id, l.environment) });
|
|
379
|
+
}
|
|
380
|
+
const split = splitLayers(run)[0];
|
|
381
|
+
if (split) findings.push({ ...base, severity: "info", check: "datalayer_split", step: stepLabel(split.step), detail: d.dataLayerSplit(split.layers.map((x) => `${x.id} → ${x.layer}`), stepLabel(split.step)) });
|
|
382
|
+
for (const l of loads.filter((x, i, all) => x.kind === "send_to" && !all.some((y) => y.id === x.id && y.kind !== "send_to") && all.findIndex((y) => y.kind === "send_to" && y.id === x.id) === i)) {
|
|
383
|
+
findings.push({ ...base, severity: "info", check: "container_send_to_only", platform: l.id.startsWith("G-") ? "ga4" : "google_ads", step: stepLabel(l.step), detail: d.sendToOnly(l.id, l.by) });
|
|
384
|
+
}
|
|
385
|
+
|
|
369
386
|
// One event ID on two different events: the platform keeps the first and drops the other as its
|
|
370
387
|
// duplicate, so a real event is lost (usually an ID set once per page instead of once per event).
|
|
371
388
|
const byId = new Map();
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// How each Google container got onto the page: GTM (gtm.js) and Google tag (gtag/js) scripts, with what
|
|
2
|
+
// inserted them. The browser records every container script as it is added (see CONTAINER_HOOK in browser.js):
|
|
3
|
+
// parser-inserted from the HTML, or set by a script, with the scripts on the call stack at that moment. This
|
|
4
|
+
// module turns that into a load source: the snippet, a script tag in the HTML, another script on the page,
|
|
5
|
+
// a tag or Zone in another container, or a gtag config or send_to command the Google tag acted on.
|
|
6
|
+
|
|
7
|
+
// Container and destination IDs gtm.js and gtag/js are loaded with.
|
|
8
|
+
const CONTAINER_ID = /^(GTM|G|GT|AW|DC|MC|HA)-[A-Z0-9]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The container a script URL loads, or null: its ID, gtm or gtag, the dataLayer it reads (`l`), the host it
|
|
12
|
+
* came from (Google's, or the site's own through the Google tag gateway or a server container) and the GTM
|
|
13
|
+
* environment when one is named (`gtm_preview`). gtm_auth, the environment's token, is never kept.
|
|
14
|
+
*/
|
|
15
|
+
export function containerOf(url) {
|
|
16
|
+
let u;
|
|
17
|
+
try { u = new URL(url); } catch { return null; }
|
|
18
|
+
if (!/^https?:$/.test(u.protocol)) return null;
|
|
19
|
+
const id = (u.searchParams.get("id") ?? "").toUpperCase();
|
|
20
|
+
if (!CONTAINER_ID.test(id)) return null;
|
|
21
|
+
const script = /\/gtm\.js$/.test(u.pathname) ? "gtm" : /\/gtag\/js$/.test(u.pathname) ? "gtag" : null;
|
|
22
|
+
if (!script) return null;
|
|
23
|
+
const environment = u.searchParams.get("gtm_preview") || (u.searchParams.has("gtm_auth") ? "unnamed" : null);
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
script,
|
|
27
|
+
layer: u.searchParams.get("l") || "dataLayer",
|
|
28
|
+
host: u.hostname,
|
|
29
|
+
served: /(^|\.)googletagmanager\.com$/.test(u.hostname) ? "google" : "site",
|
|
30
|
+
...(environment ? { environment } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** origin + path, so a caller's query string (which may carry user data) is never kept. */
|
|
35
|
+
export function scriptPath(url) {
|
|
36
|
+
try {
|
|
37
|
+
const u = new URL(url);
|
|
38
|
+
return /^https?:$/.test(u.protocol) ? u.origin + u.pathname : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The call stack the browser captured, as callers from the innermost out: { inline: true } for script in the
|
|
46
|
+
* page itself (the page's address, or "inline" for code injected without one) (the snippet, or a Custom HTML tag GTM injected), { container: id } for a container script and
|
|
47
|
+
* { script: url } for any other script. Repeats in a row are folded.
|
|
48
|
+
*/
|
|
49
|
+
export function callersOf(frames, documentUrl) {
|
|
50
|
+
const doc = String(documentUrl ?? "").split("#")[0];
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const f of frames ?? []) {
|
|
53
|
+
const url = String(f).split("#")[0];
|
|
54
|
+
const c = containerOf(url);
|
|
55
|
+
const caller = url === doc || url === "inline" ? { inline: true } : c ? { container: c.id } : scriptPath(url) ? { script: scriptPath(url) } : null;
|
|
56
|
+
if (!caller) continue;
|
|
57
|
+
const last = out.at(-1);
|
|
58
|
+
if (last && JSON.stringify(last) === JSON.stringify(caller)) continue;
|
|
59
|
+
out.push(caller);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pathOf = (href) => scriptPath(href);
|
|
65
|
+
|
|
66
|
+
/** Whether a gtag command in the dataLayer names this ID: ["config", id] or ["event", name, { send_to }]. */
|
|
67
|
+
function commandFor(load, pushes) {
|
|
68
|
+
const earlier = pushes.filter((e) => Array.isArray(e.value) && (e.t ?? 0) <= (load.t ?? Infinity) && (!load.page || pathOf(e.url) === load.page));
|
|
69
|
+
if (earlier.some((e) => e.value[0] === "config" && String(e.value[1]).toUpperCase() === load.id)) return "config";
|
|
70
|
+
const sendsTo = (e) => {
|
|
71
|
+
const to = e.value[0] === "event" ? e.value[2]?.send_to : undefined;
|
|
72
|
+
const list = Array.isArray(to) ? to : String(to ?? "").split(",");
|
|
73
|
+
return list.some((s) => String(s).trim().toUpperCase().split("/")[0] === load.id);
|
|
74
|
+
};
|
|
75
|
+
return earlier.some(sendsTo) ? "send_to" : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where one recorded load came from, as { kind, by? }:
|
|
80
|
+
* - html: a script tag in the page's HTML (the gtag.js snippet, or gtm.js referenced directly)
|
|
81
|
+
* - inline: code in the page itself (the GTM snippet, or a hand-written loader)
|
|
82
|
+
* - script: another script on the page; by is its address (a consent platform, the site's bundle)
|
|
83
|
+
* - custom_html: a Custom HTML tag in the GTM container `by`
|
|
84
|
+
* - google_tag: a Google tag or template in the GTM container `by`, through GTM's load_google_tags
|
|
85
|
+
* - nested: GTM container `by` loaded another GTM container itself, as a Zone or a tag template does
|
|
86
|
+
* - config / send_to: a gtag('config') or an event's send_to naming the ID, acted on by `by`
|
|
87
|
+
* - destination: the Google tag `by` loaded it as one of its destinations, with no command in the page
|
|
88
|
+
*/
|
|
89
|
+
export function sourceOf(load, pushes = []) {
|
|
90
|
+
if (load.how === "parser") return { kind: "html" };
|
|
91
|
+
const callers = load.callers ?? [];
|
|
92
|
+
const at = callers.findIndex((c) => c.container && c.container !== load.id);
|
|
93
|
+
if (at >= 0) {
|
|
94
|
+
const by = callers[at].container;
|
|
95
|
+
const command = commandFor(load, pushes);
|
|
96
|
+
if (command) return { kind: command, by };
|
|
97
|
+
if (by.startsWith("GTM-")) {
|
|
98
|
+
// Code in the page between the call and the container is a Custom HTML tag the container injected.
|
|
99
|
+
if (callers.slice(0, at).some((c) => c.inline)) return { kind: "custom_html", by };
|
|
100
|
+
return { kind: load.id.startsWith("GTM-") ? "nested" : "google_tag", by };
|
|
101
|
+
}
|
|
102
|
+
return { kind: "destination", by };
|
|
103
|
+
}
|
|
104
|
+
const other = callers.find((c) => c.script);
|
|
105
|
+
if (other) return { kind: "script", by: other.script };
|
|
106
|
+
return { kind: "inline" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A run's container loads with their sources, in the order they happened. Runs saved by older versions have none. */
|
|
110
|
+
export function loadSources(run) {
|
|
111
|
+
const pushes = run.dataLayerLog ?? [];
|
|
112
|
+
return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
|
|
117
|
+
* environment?, kind, by?, pages, journeys }.
|
|
118
|
+
*/
|
|
119
|
+
export function containerRows(runs) {
|
|
120
|
+
const rows = new Map();
|
|
121
|
+
for (const run of runs) {
|
|
122
|
+
for (const l of loadSources(run)) {
|
|
123
|
+
const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? ""].join("|");
|
|
124
|
+
const row = rows.get(k) ?? {
|
|
125
|
+
id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
|
|
126
|
+
...(l.environment ? { environment: l.environment } : {}), kind: l.kind, ...(l.by ? { by: l.by } : {}),
|
|
127
|
+
pages: new Set(), journeys: new Set(),
|
|
128
|
+
};
|
|
129
|
+
if (l.page) row.pages.add(l.page);
|
|
130
|
+
row.journeys.add(`${run.journeyId} (${run.viewport})`);
|
|
131
|
+
rows.set(k, row);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...rows.values()].map((r) => ({ ...r, pages: [...r.pages].sort(), journeys: [...r.journeys] }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The containers loaded more than once into one page, as [{ id, loads }] with the loads in order. */
|
|
138
|
+
export function duplicateLoads(run) {
|
|
139
|
+
const byDoc = new Map();
|
|
140
|
+
for (const l of loadSources(run)) {
|
|
141
|
+
const k = `${l.doc ?? l.page}|${l.id}`;
|
|
142
|
+
byDoc.set(k, [...(byDoc.get(k) ?? []), l]);
|
|
143
|
+
}
|
|
144
|
+
return [...byDoc.values()].filter((list) => list.length > 1).map((loads) => ({ id: loads[0].id, loads }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Pages where containers read different dataLayers, as [{ page, step, layers: [{ id, layer }] }]. */
|
|
148
|
+
export function splitLayers(run) {
|
|
149
|
+
const byDoc = new Map();
|
|
150
|
+
for (const l of loadSources(run)) {
|
|
151
|
+
// A destination a Google tag loaded reads that tag's dataLayer, whatever its own URL says.
|
|
152
|
+
if (["config", "send_to", "destination"].includes(l.kind)) continue;
|
|
153
|
+
const k = l.doc ?? l.page;
|
|
154
|
+
byDoc.set(k, [...(byDoc.get(k) ?? []), l]);
|
|
155
|
+
}
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const loads of byDoc.values()) {
|
|
158
|
+
if (new Set(loads.map((l) => l.layer)).size < 2) continue;
|
|
159
|
+
const seen = new Map(loads.map((l) => [l.id, { id: l.id, layer: l.layer }]));
|
|
160
|
+
out.push({ page: loads[0].page, step: loads[0].step, layers: [...seen.values()] });
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
package/src/html/scope.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// handling (which runs check which platform, the funnel cells, the inventory's parties and cookies, the skipped
|
|
4
4
|
// vendors, the trend) is the 0.17 renderer's, so the numbers match the Markdown report and the CLI.
|
|
5
5
|
import { buildInventory } from "../inventory.js";
|
|
6
|
+
import { containerRows } from "../containers.js";
|
|
6
7
|
import { providerOf } from "../parsers.js";
|
|
7
8
|
import { siteOf } from "../redact.js";
|
|
8
9
|
import { VERSION, BUCKET_ORDER, coverageLines, pcSort, planChangeItems, routeList } from "../report-shared.js";
|
|
@@ -193,12 +194,24 @@ function inventoryLines(ctx) {
|
|
|
193
194
|
return { lines, otherCookies: inv.otherCookies };
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/** The Google containers card: one line per container and the way it reached the page. Null when none was recorded. */
|
|
198
|
+
function containersCard(ctx) {
|
|
199
|
+
const { m, runs } = ctx;
|
|
200
|
+
const rows = containerRows(runs);
|
|
201
|
+
if (!rows.length) return null;
|
|
202
|
+
const c = m.ui.containers;
|
|
203
|
+
const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`)}${plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`))}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
|
|
204
|
+
const ids = uniq(rows.map((r) => r.id));
|
|
205
|
+
return `<li><details class="card inv" id="inv-containers"><summary><span class="t">${esc(m.ui.containersTitle)}</span>${plain(n(ids.length))}<span class="inv-m">${esc(ids.slice(0, 3).join(", "))}${ids.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.containersIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
196
208
|
function inventorySection(ctx) {
|
|
197
209
|
const { m, runs } = ctx;
|
|
198
210
|
const t = m.html.scope;
|
|
199
211
|
const { lines, otherCookies } = inventoryLines(ctx);
|
|
200
212
|
const skipped = skippedTotals(runs);
|
|
201
|
-
|
|
213
|
+
const containers = containersCard(ctx);
|
|
214
|
+
if (!lines.length && !skipped.length && !containers) return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<p class="muted">${esc(m.ui.noInventory)}</p>`);
|
|
202
215
|
const counts = (x) => `<span class="inv-m">${t.requests(x.requests, bold(x.requests))}</span><span class="inv-m">${t.pages(x.pages.size, bold(x.pages.size))}</span><span class="inv-m${x.cookies.length ? "" : " zero"}">${t.cookies(x.cookies.length, bold(x.cookies.length))}</span>`;
|
|
203
216
|
const out = lines.map((x) => {
|
|
204
217
|
const id = `inv-${safeKey(x.p)}`;
|
|
@@ -214,6 +227,7 @@ function inventorySection(ctx) {
|
|
|
214
227
|
<div role="tabpanel" id="${tid("pages")}" data-label="${esc(m.ui.pagesCol)}">${pages ? `<ul class="inv-list">${pages}</ul>` : none}</div>
|
|
215
228
|
<div role="tabpanel" id="${tid("cookies")}" data-label="${esc(m.ui.cookiesCol)}">${x.cookies.length ? `<ul class="inv-list">${x.cookies.map((ck) => `<li>${code(ck)}</li>`).join("")}</ul>` : none}</div></div></div></details></li>`;
|
|
216
229
|
});
|
|
230
|
+
if (containers) out.unshift(containers);
|
|
217
231
|
const skippedN = skipped.reduce((a, [, k]) => a + k, 0);
|
|
218
232
|
if (skipped.length) {
|
|
219
233
|
out.push(`<li><details class="card inv" id="inv-out-of-scope"><summary><span class="t">${esc(m.ui.outOfScopeStatus)}</span>${plain(t.requests(skippedN, bold(skippedN)))}<span class="inv-m">${esc(skipped.slice(0, 3).map(([v]) => v).join(", "))}${skipped.length > 3 ? " …" : ""}</span></summary><div class="body"><ul class="inv-list">${skipped.map(([v, k]) => `<li><span>${esc(v)}</span>${badge("muted", m.ui.outOfScopeStatus)}<span class="inv-n">${t.requests(k, bold(k))}</span></li>`).join("")}</ul></div></details></li>`);
|
package/src/i18n.js
CHANGED
|
@@ -202,6 +202,29 @@ const en = {
|
|
|
202
202
|
party: { first: "First party", third: "Third party", both: "Both" },
|
|
203
203
|
otherCookies: "Other cookies",
|
|
204
204
|
noInventory: "No vendor requests were recorded.",
|
|
205
|
+
containersTitle: "Google containers",
|
|
206
|
+
containersIntro: "How each GTM container and Google tag got onto the page: what inserted its script, the dataLayer it reads and where it was served from.",
|
|
207
|
+
noContainers: "No GTM container or Google tag script was loaded.",
|
|
208
|
+
containerCol: "Container",
|
|
209
|
+
sourceCol: "Loaded by",
|
|
210
|
+
layerCol: "dataLayer",
|
|
211
|
+
servedCol: "Served from",
|
|
212
|
+
containers: {
|
|
213
|
+
script: { gtm: "GTM container", gtag: "Google tag" },
|
|
214
|
+
served: { google: "Google", site: "The site's own domain" },
|
|
215
|
+
environment: (env) => (env === "unnamed" ? "GTM environment" : `GTM environment ${env}`),
|
|
216
|
+
kinds: {
|
|
217
|
+
html: () => "A script tag in the page's HTML",
|
|
218
|
+
inline: () => "Inline code in the page (the snippet)",
|
|
219
|
+
script: (by) => `Another script on the page: ${by}`,
|
|
220
|
+
custom_html: (by) => `A Custom HTML tag in ${by}`,
|
|
221
|
+
google_tag: (by) => `A Google tag in ${by} (load_google_tags)`,
|
|
222
|
+
nested: (by) => `${by} itself: a Zone or a tag template`,
|
|
223
|
+
config: (by) => `A gtag('config') command, loaded by ${by}`,
|
|
224
|
+
send_to: (by) => `An event's send_to with no config, loaded by ${by}`,
|
|
225
|
+
destination: (by) => `A destination of the Google tag ${by}`,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
205
228
|
provider: "Provider",
|
|
206
229
|
checked: "Checked",
|
|
207
230
|
outOfScopeStatus: "Out of scope",
|
|
@@ -323,6 +346,10 @@ const en = {
|
|
|
323
346
|
campaignOk: (names) => `${names.join(", ")} survived the landing page's redirects and reached the hits.`,
|
|
324
347
|
gclidNotStored: () => `The landing address carried a gclid and ad storage was granted, but the _gcl_aw cookie never held it. Google Ads cannot tie a conversion later in the visit, or on a later visit, to the ad click.`,
|
|
325
348
|
clientIdChanged: (from, at) => `The GA4 client id in the _ga cookie changed between "${from}" and "${at}". GA4 counts the visit before and after as two different users.`,
|
|
349
|
+
containerDuplicate: (id, sources, step) => `${id} was loaded ${sources.length} times into one page at "${step}": ${sources.join("; ")}. Each copy can run the container's tags again, so hits can go out twice; at best one install is dead weight.`,
|
|
350
|
+
containerEnvironment: (id, env) => `${id} is loaded from a GTM environment (${env === "unnamed" ? "gtm_auth" : `gtm_preview=${env}`}) instead of the Live version, so visitors run whatever was last published to that environment, often a staging or development version.`,
|
|
351
|
+
dataLayerSplit: (list, step) => `Containers on one page at "${step}" read different dataLayers: ${list.join(", ")}. A push to one is never seen by the containers reading another, so events and consent commands may reach only some of them.`,
|
|
352
|
+
sendToOnly: (id, by) => `${id} was loaded only because an event named it in send_to; no gtag('config', '${id}') ran. ${by} sends to it with default settings, so whatever a config command would set for it does not apply.`,
|
|
326
353
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
327
354
|
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
328
355
|
consentDefaultNotApplied: () => "The page pushed a Consent Mode default, but the Google tag never applied it, so its tags ran without one. Tag Assistant shows Consent Mode as not configured.",
|
|
@@ -378,6 +405,10 @@ const en = {
|
|
|
378
405
|
page_crashed: "Visitors who get the same error see a broken page, and the tags on it may not run.",
|
|
379
406
|
gclid_not_stored: "Conversions are not linked to the ad click that brought the visitor, so Google Ads under-reports them and bids on less data.",
|
|
380
407
|
client_id_changed: "One visitor becomes two users in GA4: the visit is split, the journey before the change loses its conversions, and attribution starts over.",
|
|
408
|
+
container_duplicate: "A container loaded twice can fire its tags twice, inflating page views and conversions; at best one install is redundant.",
|
|
409
|
+
container_environment: "Visitors run a non-Live version of the container, so tags still under test, or an old setup, collect production data.",
|
|
410
|
+
datalayer_split: "Events and consent commands pushed to one dataLayer never reach the containers reading another, so some tags miss them.",
|
|
411
|
+
container_send_to_only: "The destination runs without its own config, so the settings a config command would give it are missing.",
|
|
381
412
|
campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
|
|
382
413
|
event_id_reused: "Two different events share one event ID, so the platform keeps the first and silently drops the other: a real event is lost.",
|
|
383
414
|
event_id_mismatch: "The browser and server copies of this event carry different IDs, so Meta cannot pair them and counts the conversion twice.",
|
|
@@ -420,6 +451,10 @@ const en = {
|
|
|
420
451
|
page_crashed: "Open the page in an ordinary browser. If it works there, the site's bot protection refused its scripts to the audit's network; if not, a deployment serves pages that ask for script files it no longer has.",
|
|
421
452
|
gclid_not_stored: "Fire the Conversion Linker tag (or the Google tag with the Ads ID) on every page, on consent granted as well as on page load, and keep the gclid on the landing address until it has run.",
|
|
422
453
|
client_id_changed: "Look for what rewrites or deletes _ga at that step: a consent platform clearing cookies, a second GA4 setup with another cookie_domain or cookie_prefix, or a cross-domain link without the _gl parameter.",
|
|
454
|
+
container_duplicate: "Keep one install: remove the extra snippet, or the tag in the other container that loads it again.",
|
|
455
|
+
container_environment: "Use the Live snippet in production: take gtm_auth and gtm_preview out of the GTM snippet, or publish what the environment holds to Live.",
|
|
456
|
+
datalayer_split: "Point every container at the same dataLayer, or push events and consent commands to each dataLayer the containers read.",
|
|
457
|
+
container_send_to_only: "Add a gtag('config', ID) for the destination before the events that send to it, or a Google tag for it in GTM.",
|
|
423
458
|
campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
|
|
424
459
|
event_id_reused: "Generate a new event ID for every event, not once per page load.",
|
|
425
460
|
event_id_mismatch: "Use one event ID for the pixel event and the server copy: pass the same value to both tags.",
|
|
@@ -462,6 +497,10 @@ const en = {
|
|
|
462
497
|
page_crashed: "hygiene",
|
|
463
498
|
gclid_not_stored: "money",
|
|
464
499
|
client_id_changed: "money",
|
|
500
|
+
container_duplicate: "money",
|
|
501
|
+
container_environment: "hygiene",
|
|
502
|
+
datalayer_split: "hygiene",
|
|
503
|
+
container_send_to_only: "hygiene",
|
|
465
504
|
campaign_params: "money",
|
|
466
505
|
event_id_reused: "money",
|
|
467
506
|
event_id_mismatch: "money",
|
|
@@ -980,6 +1019,29 @@ const tr = {
|
|
|
980
1019
|
party: { first: "Birinci taraf", third: "Üçüncü taraf", both: "İkisi de" },
|
|
981
1020
|
otherCookies: "Diğer çerezler",
|
|
982
1021
|
noInventory: "Sağlayıcıya giden istek kaydedilmedi.",
|
|
1022
|
+
containersTitle: "Google container'ları",
|
|
1023
|
+
containersIntro: "Her GTM container'ının ve Google etiketinin sayfaya nasıl geldiği: betiğini neyin eklediği, okuduğu dataLayer ve nereden sunulduğu.",
|
|
1024
|
+
noContainers: "Hiçbir GTM container'ı veya Google etiketi betiği yüklenmedi.",
|
|
1025
|
+
containerCol: "Container",
|
|
1026
|
+
sourceCol: "Yükleyen",
|
|
1027
|
+
layerCol: "dataLayer",
|
|
1028
|
+
servedCol: "Sunulduğu yer",
|
|
1029
|
+
containers: {
|
|
1030
|
+
script: { gtm: "GTM container'ı", gtag: "Google etiketi" },
|
|
1031
|
+
served: { google: "Google", site: "Sitenin kendi alan adı" },
|
|
1032
|
+
environment: (env) => (env === "unnamed" ? "GTM ortamı" : `GTM ortamı ${env}`),
|
|
1033
|
+
kinds: {
|
|
1034
|
+
html: () => "Sayfanın HTML'indeki bir script etiketi",
|
|
1035
|
+
inline: () => "Sayfadaki satır içi kod (snippet)",
|
|
1036
|
+
script: (by) => `Sayfadaki başka bir betik: ${by}`,
|
|
1037
|
+
custom_html: (by) => `${by} içindeki bir Özel HTML etiketi`,
|
|
1038
|
+
google_tag: (by) => `${by} içindeki bir Google etiketi (load_google_tags)`,
|
|
1039
|
+
nested: (by) => `${by} kendisi: bir Zone veya etiket şablonu`,
|
|
1040
|
+
config: (by) => `Bir gtag('config') komutu, ${by} yükledi`,
|
|
1041
|
+
send_to: (by) => `config olmadan bir olayın send_to değeri, ${by} yükledi`,
|
|
1042
|
+
destination: (by) => `${by} Google etiketinin bir hedefi`,
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
983
1045
|
provider: "Sağlayıcı",
|
|
984
1046
|
checked: "Kontrol edildi",
|
|
985
1047
|
outOfScopeStatus: "Kapsam dışı",
|
|
@@ -1097,6 +1159,10 @@ const tr = {
|
|
|
1097
1159
|
campaignOk: (names) => `${names.join(", ")} açılış sayfasının yönlendirmelerinden geçti ve isteklere ulaştı.`,
|
|
1098
1160
|
gclidNotStored: () => `Açılış adresi bir gclid taşıyordu ve reklam depolamasına izin verildi, ancak _gcl_aw çerezi onu hiç tutmadı. Google Ads, ziyaretin ilerleyen adımlarındaki ya da sonraki bir ziyaretteki dönüşümü reklam tıklamasına bağlayamaz.`,
|
|
1099
1161
|
clientIdChanged: (from, at) => `_ga çerezindeki GA4 istemci kimliği "${from}" ile "${at}" arasında değişti. GA4 değişiklikten önceki ve sonraki ziyareti iki ayrı kullanıcı sayar.`,
|
|
1162
|
+
containerDuplicate: (id, sources, step) => `${id}, "${step}" adımında aynı sayfaya ${sources.length} kez yüklendi: ${sources.join("; ")}. Her kopya container'ın etiketlerini yeniden çalıştırabilir, bu yüzden istekler iki kez gidebilir; en iyi ihtimalle kurulumlardan biri boşuna yükleniyor.`,
|
|
1163
|
+
containerEnvironment: (id, env) => `${id}, Live sürüm yerine bir GTM ortamından (${env === "unnamed" ? "gtm_auth" : `gtm_preview=${env}`}) yükleniyor; ziyaretçiler o ortama en son ne yayımlandıysa onu, çoğu zaman bir test veya geliştirme sürümünü çalıştırıyor.`,
|
|
1164
|
+
dataLayerSplit: (list, step) => `"${step}" adımında aynı sayfadaki container'lar farklı dataLayer'ları okuyor: ${list.join(", ")}. Birine yapılan push diğerini okuyan container'lar tarafından hiç görülmez; olaylar ve onay komutları yalnızca bazılarına ulaşabilir.`,
|
|
1165
|
+
sendToOnly: (id, by) => `${id} yalnızca bir olay send_to ile onu adlandırdığı için yüklendi; gtag('config', '${id}') hiç çalışmadı. ${by} ona varsayılan ayarlarla gönderiyor, bu yüzden bir config komutunun onun için ayarlayacağı hiçbir şey uygulanmıyor.`,
|
|
1100
1166
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
1101
1167
|
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
|
1102
1168
|
consentDefaultNotApplied: () => "Sayfa bir Consent Mode varsayılanı gönderdi, ancak Google etiketi onu hiç uygulamadı; etiketleri varsayılan olmadan çalıştı. Tag Assistant, Consent Mode'u yapılandırılmamış gösterir.",
|
|
@@ -1151,6 +1217,10 @@ const tr = {
|
|
|
1151
1217
|
page_crashed: "Aynı hatayı alan ziyaretçiler bozuk bir sayfa görür ve sayfadaki etiketler çalışmayabilir.",
|
|
1152
1218
|
gclid_not_stored: "Dönüşümler ziyaretçiyi getiren reklam tıklamasına bağlanmıyor; Google Ads onları eksik raporluyor ve daha az veriyle teklif veriyor.",
|
|
1153
1219
|
client_id_changed: "Bir ziyaretçi GA4'te iki kullanıcıya dönüşüyor: ziyaret bölünüyor, değişiklikten önceki yolculuk dönüşümlerini kaybediyor ve atıf baştan başlıyor.",
|
|
1220
|
+
container_duplicate: "İki kez yüklenen bir container etiketlerini iki kez çalıştırabilir; sayfa görüntülemeleri ve dönüşümler şişer, en iyi ihtimalle kurulumlardan biri gereksizdir.",
|
|
1221
|
+
container_environment: "Ziyaretçiler container'ın Live olmayan bir sürümünü çalıştırıyor; hâlâ test edilen etiketler ya da eski bir kurulum canlı veri topluyor.",
|
|
1222
|
+
datalayer_split: "Bir dataLayer'a gönderilen olaylar ve onay komutları diğerini okuyan container'lara hiç ulaşmaz; bazı etiketler onları kaçırır.",
|
|
1223
|
+
container_send_to_only: "Hedef kendi config'i olmadan çalışıyor; bir config komutunun ona vereceği ayarlar eksik.",
|
|
1154
1224
|
campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
|
|
1155
1225
|
event_id_reused: "İki farklı olay tek bir olay kimliğini paylaşıyor; platform ilkini tutup diğerini sessizce atıyor, gerçek bir olay kayboluyor.",
|
|
1156
1226
|
event_id_mismatch: "Bu olayın tarayıcı ve sunucu kopyaları farklı kimlik taşıyor; Meta ikisini eşleştiremiyor ve dönüşümü iki kez sayıyor.",
|
|
@@ -1191,6 +1261,10 @@ const tr = {
|
|
|
1191
1261
|
page_crashed: "Sayfayı sıradan bir tarayıcıda açın. Orada çalışıyorsa sitenin bot koruması betiklerini denetimin ağına vermedi; çalışmıyorsa bir dağıtım artık sahip olmadığı betik dosyalarını isteyen sayfalar sunuyor.",
|
|
1192
1262
|
gclid_not_stored: "Dönüşüm bağlayıcı etiketini (ya da Ads kimliğiyle Google etiketini) her sayfada, sayfa yüklenince olduğu kadar onay verildiğinde de çalıştırın ve gclid'i o etiket çalışana kadar açılış adresinde tutun.",
|
|
1193
1263
|
client_id_changed: "O adımda _ga'yı yeniden yazan ya da silen şeyi arayın: çerezleri temizleyen bir onay platformu, başka bir cookie_domain ya da cookie_prefix ile ikinci bir GA4 kurulumu, ya da _gl parametresi olmayan alan adları arası bir bağlantı.",
|
|
1264
|
+
container_duplicate: "Tek bir kurulum bırakın: fazladan snippet'i ya da onu diğer container'da yeniden yükleyen etiketi kaldırın.",
|
|
1265
|
+
container_environment: "Canlı sitede Live snippet'ini kullanın: GTM snippet'inden gtm_auth ve gtm_preview'ı çıkarın ya da ortamdaki sürümü Live'a yayımlayın.",
|
|
1266
|
+
datalayer_split: "Tüm container'ları aynı dataLayer'a yönlendirin ya da olayları ve onay komutlarını container'ların okuduğu her dataLayer'a gönderin.",
|
|
1267
|
+
container_send_to_only: "Hedefe gönderen olaylardan önce onun için bir gtag('config', ID) ya da GTM'de onun için bir Google etiketi ekleyin.",
|
|
1194
1268
|
campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
|
|
1195
1269
|
event_id_reused: "Her olay için yeni bir olay kimliği üretin; sayfa yüklemesi başına bir kez değil.",
|
|
1196
1270
|
event_id_mismatch: "Piksel olayı ve sunucu kopyası için tek bir olay kimliği kullanın: aynı değeri iki etikete de verin.",
|
|
@@ -1232,6 +1306,10 @@ const tr = {
|
|
|
1232
1306
|
page_crashed: "hygiene",
|
|
1233
1307
|
gclid_not_stored: "money",
|
|
1234
1308
|
client_id_changed: "money",
|
|
1309
|
+
container_duplicate: "money",
|
|
1310
|
+
container_environment: "hygiene",
|
|
1311
|
+
datalayer_split: "hygiene",
|
|
1312
|
+
container_send_to_only: "hygiene",
|
|
1235
1313
|
campaign_params: "money",
|
|
1236
1314
|
event_id_reused: "money",
|
|
1237
1315
|
event_id_mismatch: "money",
|
package/src/mcp.js
CHANGED
|
@@ -33,6 +33,7 @@ import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, pars
|
|
|
33
33
|
import { hitParams } from "./hit-params.js";
|
|
34
34
|
import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
|
|
35
35
|
import { cmpFacts } from "./cmp.js";
|
|
36
|
+
import { containerRows, loadSources } from "./containers.js";
|
|
36
37
|
import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
|
|
37
38
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
38
39
|
|
|
@@ -102,13 +103,15 @@ function describeHit(h, steps, index) {
|
|
|
102
103
|
|
|
103
104
|
/** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
|
|
104
105
|
function describePush(p) {
|
|
106
|
+
// Pushes to a dataLayer of another name (a container's l=) say which.
|
|
107
|
+
const layer = p.layer ? `[${p.layer}] ` : "";
|
|
105
108
|
if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
|
|
106
109
|
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]);
|
|
107
|
-
return `- consent ${p.value[1]}: ${signals}`;
|
|
110
|
+
return `- ${layer}consent ${p.value[1]}: ${signals}`;
|
|
108
111
|
}
|
|
109
112
|
const json = JSON.stringify(p.value) ?? String(p.value);
|
|
110
113
|
const whole = json.length > PUSH_LIMIT ? wholeFieldsIn(p.value) : [];
|
|
111
|
-
return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
|
|
114
|
+
return `- ${layer}${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
/** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent. */
|
|
@@ -150,6 +153,13 @@ function cookieLines(session, step) {
|
|
|
150
153
|
return lines;
|
|
151
154
|
}
|
|
152
155
|
|
|
156
|
+
/** One container load in words: its ID, what inserted it, its dataLayer and where it came from. */
|
|
157
|
+
function describeLoad(l) {
|
|
158
|
+
const c = messages("en").ui.containers;
|
|
159
|
+
const extra = [l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment)].filter(Boolean);
|
|
160
|
+
return ` ${l.id} (${c.script[l.script]}): ${c.kinds[l.kind](l.by)}${extra.length ? `; ${extra.join(", ")}` : ""}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
153
163
|
export function stepCapture(audit, step) {
|
|
154
164
|
const { session } = audit;
|
|
155
165
|
const hits = session.hits.filter((h) => h.step === step);
|
|
@@ -168,6 +178,8 @@ export function stepCapture(audit, step) {
|
|
|
168
178
|
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(", ")}`);
|
|
169
179
|
if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
|
|
170
180
|
if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
|
|
181
|
+
const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), dataLayerLog: session.dataLayerLog });
|
|
182
|
+
if (loads.length) lines.push("Google containers loaded:", ...loads.map(describeLoad));
|
|
171
183
|
lines.push(...cookieLines(session, step));
|
|
172
184
|
const tag = session.steps[step]?.tagConsent;
|
|
173
185
|
if (tag) {
|
|
@@ -214,6 +226,31 @@ export function hitDetail(audit, step, index) {
|
|
|
214
226
|
* hits it sent with their consent, its consent commands and other tracking calls, the Google tag's consent when it
|
|
215
227
|
* changed, and the cookies and ids that changed.
|
|
216
228
|
*/
|
|
229
|
+
/** A container load or row as compact JSON: the ID, its source, and the dataLayer, host and environment when they are not the usual ones. */
|
|
230
|
+
function containerJson(l) {
|
|
231
|
+
return {
|
|
232
|
+
id: l.id, source: l.kind, ...(l.by ? { by: l.by } : {}),
|
|
233
|
+
...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
|
|
234
|
+
...(l.served === "site" ? { served_from: l.host } : {}),
|
|
235
|
+
...(l.environment ? { gtm_environment: l.environment } : {}),
|
|
236
|
+
...(l.pages ? { pages: l.pages } : {}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The Google containers of the runs and how each reached the page, as lines for a tool result; [] when none loaded. */
|
|
241
|
+
export function containerLines(runs) {
|
|
242
|
+
const rows = containerRows(runs);
|
|
243
|
+
if (!rows.length) return [];
|
|
244
|
+
const c = messages("en").ui.containers;
|
|
245
|
+
return [
|
|
246
|
+
"Google containers (how each reached the page):",
|
|
247
|
+
...rows.map((r) => {
|
|
248
|
+
const extra = [r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
|
|
249
|
+
return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
|
|
250
|
+
}),
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
|
|
217
254
|
export function timelineOf(run) {
|
|
218
255
|
let lastIds = null;
|
|
219
256
|
return (run.steps ?? []).map((st) => {
|
|
@@ -230,6 +267,8 @@ export function timelineOf(run) {
|
|
|
230
267
|
if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
|
|
231
268
|
const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
|
|
232
269
|
if (Object.keys(cookies).length) entry.cookies = cookies;
|
|
270
|
+
const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), dataLayerLog: run.dataLayerLog });
|
|
271
|
+
if (loads.length) entry.containers = loads.map(containerJson);
|
|
233
272
|
if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
|
|
234
273
|
if (st.identity) lastIds = st.identity;
|
|
235
274
|
return entry;
|
|
@@ -466,15 +505,33 @@ export function buildServer(opts) {
|
|
|
466
505
|
},
|
|
467
506
|
);
|
|
468
507
|
|
|
508
|
+
server.registerTool(
|
|
509
|
+
"get_containers",
|
|
510
|
+
{
|
|
511
|
+
title: "List the Google containers loaded so far",
|
|
512
|
+
description:
|
|
513
|
+
"Returns every GTM container (gtm.js) and Google tag (gtag/js) this audit's pages loaded so far, and how each reached the page: a script tag in the HTML, the inline snippet, another script (named by its address, such as a consent platform), a Custom HTML tag or a Google tag in another GTM container, a container loading another itself (a Zone or a tag template), a gtag('config') command, an event's send_to with no config, or a destination of the Google tag. Also the dataLayer each reads, whether it was served from the site's own domain, the GTM environment when one is named, and the step it loaded at. Duplicates, GTM environments and split dataLayers are flagged by finish_audit.",
|
|
514
|
+
inputSchema: { audit_id: z.string() },
|
|
515
|
+
annotations: { readOnlyHint: true },
|
|
516
|
+
},
|
|
517
|
+
async ({ audit_id }) => {
|
|
518
|
+
const { session } = getAudit(audit_id);
|
|
519
|
+
const loads = loadSources({ containerLoads: session.containerLoads ?? [], dataLayerLog: session.dataLayerLog });
|
|
520
|
+
if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
|
|
521
|
+
const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
|
|
522
|
+
return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify(loads.map((l) => ({ step: l.step, page: l.page, ...containerJson(l) })))}`);
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
|
|
469
526
|
server.registerTool(
|
|
470
527
|
"get_report",
|
|
471
528
|
{
|
|
472
529
|
title: "Read a report this server wrote",
|
|
473
530
|
description:
|
|
474
|
-
"Returns a report finish_audit or run_audit wrote, so it can be read without access to the computer running this server. part markdown (default): report.md, the verdict, findings, consent and journeys in about 120 lines. summary: summary.json, the counts, journeys and findings as JSON. timeline: one JSON entry per step of each journey (hits with their consent, consent commands, the Google tag's consent, cookies and ids).",
|
|
531
|
+
"Returns a report finish_audit or run_audit wrote, so it can be read without access to the computer running this server. part markdown (default): report.md, the verdict, findings, consent and journeys in about 120 lines. summary: summary.json, the counts, journeys and findings as JSON. timeline: one JSON entry per step of each journey (hits with their consent, consent commands, the Google tag's consent, cookies and ids, the Google containers loaded). containers: every GTM container and Google tag the journeys loaded as JSON, with how each reached the page (source html, inline, script, custom_html, google_tag, nested, config, send_to or destination, and by: the container or script that loaded it), its dataLayer, host, GTM environment and pages.",
|
|
475
532
|
inputSchema: {
|
|
476
533
|
report: z.string().describe("The report name finish_audit or run_audit gave."),
|
|
477
|
-
part: z.enum(["markdown", "summary", "timeline"]).optional().describe("Default markdown."),
|
|
534
|
+
part: z.enum(["markdown", "summary", "timeline", "containers"]).optional().describe("Default markdown."),
|
|
478
535
|
},
|
|
479
536
|
annotations: { readOnlyHint: true },
|
|
480
537
|
},
|
|
@@ -485,6 +542,7 @@ export function buildServer(opts) {
|
|
|
485
542
|
if (part === "markdown") return text(await readFile(path.join(dir, "report.md"), "utf8"));
|
|
486
543
|
if (part === "summary") return text(await readFile(path.join(dir, "summary.json"), "utf8"));
|
|
487
544
|
const capture = JSON.parse(await readFile(path.join(dir, "capture.json"), "utf8"));
|
|
545
|
+
if (part === "containers") return text(JSON.stringify(containerRows(capture.runs ?? []).map(containerJson)));
|
|
488
546
|
return text(JSON.stringify((capture.runs ?? []).map((r) => ({ journey: r.journeyId, viewport: r.viewport, steps: timelineOf(r) }))));
|
|
489
547
|
} catch (err) {
|
|
490
548
|
return fail(`Could not read the report: ${err.message}`);
|
|
@@ -594,13 +652,14 @@ export function buildServer(opts) {
|
|
|
594
652
|
`${run.hits.length} tracking hits, ${run.dataLayerLog.length} dataLayer pushes and ${run.network.length} analytics or ad network calls captured over ${run.steps.length} steps.`,
|
|
595
653
|
skippedLine(run),
|
|
596
654
|
...((platform) => (platform ? [`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`] : []))(cmpFacts(messages("en"), [run])),
|
|
655
|
+
...containerLines([run]),
|
|
597
656
|
opts.allowFiles
|
|
598
657
|
? `Full report with the event timeline: ${reportPath}`
|
|
599
658
|
: opts.reportsUrl
|
|
600
659
|
? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
|
|
601
660
|
: "",
|
|
602
661
|
"",
|
|
603
|
-
`To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary or
|
|
662
|
+
`To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary, timeline or containers).`,
|
|
604
663
|
"Explain each problem to the user with its likely cause and fix.",
|
|
605
664
|
];
|
|
606
665
|
return {
|
|
@@ -746,8 +805,9 @@ export function buildServer(opts) {
|
|
|
746
805
|
...(result.baseline ? [`${result.baseline.accepted} accepted from the baseline${result.baseline.partial ? `, ${result.baseline.partial} accepted in some places only (still counted)` : ""}, ${result.baseline.stale.length} stale baseline entries`] : []),
|
|
747
806
|
...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
|
|
748
807
|
...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
|
|
808
|
+
...containerLines(result.runs),
|
|
749
809
|
`Report: ${result.reportPath}`,
|
|
750
|
-
`To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary or
|
|
810
|
+
`To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary, timeline or containers).`,
|
|
751
811
|
].join("\n"),
|
|
752
812
|
);
|
|
753
813
|
},
|
package/src/report.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createProvider } from "./providers.js";
|
|
|
5
5
|
import { canonicalEvent, groupFindings, summarize } from "./checks.js";
|
|
6
6
|
import { messages } from "./i18n.js";
|
|
7
7
|
import { buildInventory } from "./inventory.js";
|
|
8
|
+
import { containerRows } from "./containers.js";
|
|
8
9
|
import { computeMetrics } from "./metrics.js";
|
|
9
10
|
import { historySummaryLines, sparkline } from "./history.js";
|
|
10
11
|
import { cardIds } from "./html/lib.js";
|
|
@@ -396,6 +397,21 @@ function mdInventory(m, runs) {
|
|
|
396
397
|
return lines;
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
/** The Google containers and how each reached the page, one row per container and source. Omitted when none loaded. */
|
|
401
|
+
function mdContainers(m, runs) {
|
|
402
|
+
const rows = containerRows(runs);
|
|
403
|
+
if (!rows.length) return [];
|
|
404
|
+
const c = m.ui.containers;
|
|
405
|
+
const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|"];
|
|
406
|
+
for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
|
|
407
|
+
const served = r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`;
|
|
408
|
+
lines.push(`| ${mdText(r.id)} (${c.script[r.script]}) | ${mdText(c.kinds[r.kind](r.by))}${r.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${r.pages.length} |`);
|
|
409
|
+
}
|
|
410
|
+
if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
|
|
411
|
+
lines.push("");
|
|
412
|
+
return lines;
|
|
413
|
+
}
|
|
414
|
+
|
|
399
415
|
/** The trend in up to five lines: the text sparkline with the direction, then the open-longest and flapping lines. */
|
|
400
416
|
function mdTrend(m, trend, lang) {
|
|
401
417
|
if (!trend || trend.runs < 2) return [];
|
|
@@ -447,6 +463,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
|
|
|
447
463
|
lines.push(...mdJourneys(m, runs, rows));
|
|
448
464
|
lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
|
|
449
465
|
lines.push(...mdInventory(m, runs));
|
|
466
|
+
lines.push(...mdContainers(m, runs));
|
|
450
467
|
// A report with its own name or logo carries no Analitik İşler credit; the ai tag debugger one stays.
|
|
451
468
|
if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
|
|
452
469
|
else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
|