@analitikisler/ai-tag-debugger 0.22.1 → 0.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.3 (2026-11-04)
4
+
5
+ - Runs can report a finding 0.22.2 did not on the same site (`widget_events_unshared`). A baseline written with 0.22.2 can then list new entries; `--update-baseline` rewrites it.
6
+ - New **Embedded widgets** card under the inventory and a table in `report.md`: per third-party widget (a booking, chat or payment widget, a video), the pages it was embedded in, the containers it loaded, the tracking it sent to its own accounts and to the site's, the events and consent commands in its own dataLayer, and the events it pushed that never reached the site's accounts.
7
+ - New check `widget_events_unshared` (info): a widget pushed events to its own dataLayer, such as a completed booking, that none of its hits to the site's accounts carried. The page's tags can't read a widget's dataLayer, so those conversions can be missing from the site's analytics and ad accounts.
8
+ - MCP: the new `get_widgets` tool lists the embedded widgets an open audit's pages had so far. `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "widgets"`, and the JSON timeline lists a widget's dataLayer events per step under `widget_pushes`.
9
+
10
+ ## 0.22.2 (2026-11-04)
11
+
12
+ - Runs can report a finding 0.22.1 did not on the same site (`container_failed`). A baseline written with 0.22.1 can then list new entries; `--update-baseline` rewrites it.
13
+ - Each Google container shows its published version (the number its `gtm.js` or `gtag/js` data opens with, as in GTM's Versions tab), the script's size and how long it took to load, in the Google containers card, `report.md` (a new **Version and size** column) and over MCP: step results, `get_containers`, `finish_audit`, `run_audit`, the JSON timeline and `get_report` with `part: "containers"` (`version`, `size_bytes`, `transfer_bytes`, `load_ms`). `capture.json` keeps each container script's answer under `containerScripts`.
14
+ - New check `container_failed` (warning): a container script that failed to load or was answered with an error status, such as a wrong container ID or a server container that doesn't serve `gtm.js`, so none of its tags ran on that page.
15
+
3
16
  ## 0.22.1 (2026-11-04)
4
17
 
5
18
  - Runs can report different findings than 0.22.0 on the same site: an embedded widget's own hits no longer count as the site's, so a planned event only a widget sent is now missing, and the new `widget_tracking` and `iframe_container` (info) findings appear. A baseline written with 0.22.0 can then list stale or new entries; `--update-baseline` rewrites it.
package/README.md CHANGED
@@ -325,9 +325,9 @@ Under the inventory, **Google containers** lists every GTM container (`gtm.js`)
325
325
  | An event's `send_to` with no config | The Google tag loaded the destination only because an event was sent to it |
326
326
  | A destination of the Google tag | The Google tag loaded it with no command in the page, such as a linked destination |
327
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.
328
+ Each row also gives the container's published version (the number its `gtm.js` or `gtag/js` data opens with, as in GTM's Versions tab), the script's size and how long it took to load, 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
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.
330
+ Five checks read it: `container_failed` (warning) for a container script that failed to load or was answered with an error, so none of its tags ran, `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
331
 
332
332
  ### Embedded widgets and iframes
333
333
 
@@ -335,6 +335,8 @@ Hits, dataLayer pushes, vendor calls and container loads sent from inside an ifr
335
335
 
336
336
  A widget's own hits are left out of everything that judges the site's tracking: plan matching, coverage, duplicates, unplanned events and the scores. A widget's `purchase` to its vendor's property can't pass for the site's, and its events aren't listed as the site's. They still count for consent and personal data, since the site answers for what the widgets it embeds collect; those findings begin with "In an embedded widget from …". Each widget that tracked is named once in a `widget_tracking` (info) finding with its accounts and events, and a container running in a same-site iframe beside one in the page is noted as `iframe_container` (info): the page's dataLayer pushes and consent never reach it. A widget's dataLayer pushes are kept apart too, so its consent commands don't stand in for the site's.
337
337
 
338
+ Each widget also gets a line in the **Embedded widgets** card under the inventory and a table in `report.md`: the pages it was embedded in, the containers it loaded itself, the tracking it sent to its own accounts and to the site's, and the events and consent commands in its own dataLayer. The page's tags can't read a widget's dataLayer, so an event the widget pushed there (a `booking_complete`, say) reaches the site's accounts only if the widget sends it to them. The events no hit to the site's accounts carried are listed as not sent, and reported as `widget_events_unshared` (info): a booking or payment completed in the widget can be missing from the site's analytics and ad accounts.
339
+
338
340
  `capture.json` keeps the widget's hits under `widgetHits` and its pushes under `widgetDataLayerLog`, and every iframe entry has a `frame`. The Google containers card says which ran in an iframe. Over MCP, step results mark each hit from an iframe and whether the plan checks will count it, pushes from an iframe are prefixed with it, and the JSON timeline lists a widget's hits per step under `widget_hits`. Captures from older versions have no frames, so all of their hits stay the site's.
339
341
 
340
342
  ### Folder layout and plan IDs
@@ -666,7 +668,8 @@ Tools the server offers:
666
668
  | `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. |
667
669
  | `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. |
668
670
  | `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. |
669
- | `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. |
671
+ | `get_widgets` | Lists every embedded third-party widget the open audit's pages had so far (see [Embedded widgets and iframes](#embedded-widgets-and-iframes)): where it was embedded, its containers, the tracking it sent to its own accounts and to the site's, its own dataLayer events, and the events that never reached the site's accounts, as text and JSON. |
672
+ | `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) `containers` (every Google container with how it reached the page, as JSON) or `widgets` (every embedded widget with its tracking and dataLayer events, as JSON). Only this server's own reports, over HTTP too. |
670
673
  | `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. |
671
674
 
672
675
  `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
@@ -325,9 +325,9 @@ Envanterin altında **Google container'ları**, sayfaların yüklediği her GTM
325
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
326
  | Google etiketinin bir hedefi | Google etiketi onu sayfada bir komut olmadan yükledi, örneğin bağlı bir hedef |
327
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.
328
+ Her satır ayrıca container'ın yayımlanmış sürümünü (`gtm.js` ya da `gtag/js` verisinin başladığı numara; GTM'deki Sürümler sekmesindekiyle aynı), betiğin boyutunu ve ne kadar sürede yüklendiğini, 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
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).
330
+ Beş kontrol bunu okur: yüklenemeyen ya da hatayla yanıtlanan, bu yüzden etiketlerinin hiçbiri çalışmayan bir container betiği için `container_failed` (uyarı), 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
331
 
332
332
  ### Gömülü widget'lar ve iframe'ler
333
333
 
@@ -335,6 +335,8 @@ Bir iframe içinden gönderilen istekler, dataLayer push'ları, sağlayıcı ça
335
335
 
336
336
  Bir widget'ın kendi istekleri, sitenin izlemesini değerlendiren her şeyin dışında tutulur: plan eşleştirme, kapsam, tekrarlar, planda olmayan olaylar ve puanlar. Widget'ın sağlayıcısının mülküne giden bir `purchase` sitenin yerine geçemez ve olayları sitenin olayları olarak listelenmez. Yine de onay ve kişisel veri için sayılırlar, çünkü site gömdüğü widget'ların topladıklarından sorumludur; bu bulgular "… kaynaklı gömülü bir widget içinde:" ile başlar. İzleme yapan her widget, hesapları ve olaylarıyla bir kez `widget_tracking` (bilgi) bulgusunda adlandırılır; sayfadakinin yanında aynı sitedeki bir iframe'de çalışan container ise `iframe_container` (bilgi) olarak not edilir: sayfanın dataLayer push'ları ve onayı ona hiç ulaşmaz. Widget'ın dataLayer push'ları da ayrı tutulur, böylece onay komutları sitenin komutlarının yerine geçmez.
337
337
 
338
+ Her widget ayrıca envanterin altındaki **Gömülü widget'lar** kartında bir satır, `report.md` içinde de bir tablo alır: gömülü olduğu sayfalar, kendi yüklediği container'lar, kendi hesaplarına ve sitenin hesaplarına gönderdiği izleme ve kendi dataLayer'ındaki olaylar ve onay komutları. Sayfanın etiketleri bir widget'ın dataLayer'ını okuyamaz; bu yüzden widget'ın oraya gönderdiği bir olay (örneğin `booking_complete`) sitenin hesaplarına ancak widget onu oraya gönderirse ulaşır. Sitenin hesaplarına giden hiçbir isteğin taşımadığı olaylar gönderilmemiş olarak listelenir ve `widget_events_unshared` (bilgi) olarak bildirilir: widget içinde tamamlanan bir rezervasyon ya da ödeme sitenin analitik ve reklam hesaplarında eksik kalabilir.
339
+
338
340
  `capture.json` widget'ın isteklerini `widgetHits`, push'larını `widgetDataLayerLog` altında saklar ve her iframe kaydında bir `frame` bulunur. Google container'ları kartı hangilerinin bir iframe'de çalıştığını söyler. MCP'de adım sonuçları iframe'den gelen her isteği ve plan kontrollerinin onu sayıp saymayacağını işaretler, iframe'den gelen push'ların başına iframe yazılır ve JSON zaman akışı bir widget'ın isteklerini adım adım `widget_hits` altında listeler. Eski sürümlerin kayıtlarında frame bilgisi yoktur, bu yüzden tüm istekleri sitenin olarak kalır.
339
341
 
340
342
  ### Klasör yapısı ve plan kimlikleri
@@ -666,7 +668,8 @@ Sunucunun sunduğu araçlar:
666
668
  | `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. |
667
669
  | `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. |
668
670
  | `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. |
669
- | `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. |
671
+ | `get_widgets` | Açık denetimin sayfalarında şimdiye kadar bulunan her gömülü üçüncü taraf widget'ı ([Gömülü widget'lar ve iframe'ler](#gömülü-widgetlar-ve-iframeler)) listeler: gömülü olduğu yer, container'ları, kendi hesaplarına ve sitenin hesaplarına gönderdiği izleme, kendi dataLayer olayları ve sitenin hesaplarına hiç ulaşmayan olaylar; metin ve JSON olarak. |
672
+ | `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) `containers` (sayfaya nasıl geldiğiyle her Google container'ı, JSON olarak) ya da `widgets` (izlemesi ve dataLayer olaylarıyla her gömülü widget, JSON olarak). Yalnızca bu sunucunun kendi raporları, HTTP üzerinden de. |
670
673
  | `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. |
671
674
 
672
675
  `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.22.1",
4
+ "version": "0.22.3",
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/agent.js CHANGED
@@ -238,7 +238,7 @@ export const journeyError = (err) => `The journey stopped with an error: ${err?.
238
238
  export function failedRun(journey, viewport, err, providers) {
239
239
  return {
240
240
  journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
241
- cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
241
+ cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], containerScripts: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
242
242
  };
243
243
  }
244
244
 
@@ -258,6 +258,7 @@ export function collect(session, journey, viewport) {
258
258
  // Hits let through to the real accounts (block_hits false, or a live MCP audit): the reports say so.
259
259
  ...(session.opts.blockHits === false ? { live: true } : {}),
260
260
  containerLoads: session.containerLoads,
261
+ containerScripts: session.containerScripts,
261
262
  network: session.network,
262
263
  providers: session.providers,
263
264
  skipped: session.skipped,
package/src/browser.js CHANGED
@@ -10,7 +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
+ import { callersOf, containerOf, containerVersion } from "./containers.js";
14
14
  import { frameInfo } from "./frames.js";
15
15
 
16
16
  // Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
@@ -36,7 +36,7 @@ export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk
36
36
  // Efilli draws its banner inside the open shadow root of <efilli-layout-dynamic>, so the checks below also look inside open shadow roots.
37
37
 
38
38
  // Caps on what one audit records, so a looping page can't grow memory without limit.
39
- const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500 };
39
+ const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500, containerScripts: 500 };
40
40
 
41
41
  // Secrets the browser process has no use for. It gets the rest of the environment
42
42
  // (PATH, HOME, DISPLAY...) so Chromium still starts normally.
@@ -548,6 +548,7 @@ export class AuditSession {
548
548
  this.errors = [];
549
549
  this.dataLayerLog = [];
550
550
  this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
551
+ this.containerScripts = []; // each container script's answer: status, version, size and load time
551
552
  this.network = [];
552
553
  this.providers = resolveProviders(this.opts.providers);
553
554
  this.landing = null; // the first page: { step, requested, chain, final }
@@ -683,6 +684,11 @@ export class AuditSession {
683
684
  const call = calls.get(request);
684
685
  // Keepalive requests answered below are still reported as aborted; the page got the 204.
685
686
  if (call && call.status === null) call.status = "failed";
687
+ // A script answered with an error status has a response, but Chromium aborts its body: the status is kept.
688
+ if (containerOf(request.url())) this.recordScript(request, request.response().catch(() => null)).catch(() => {});
689
+ });
690
+ this.context.on("requestfinished", (request) => {
691
+ if (containerOf(request.url())) this.recordScript(request, request.response()).catch(() => {});
686
692
  });
687
693
 
688
694
  // Hits come from the request event rather than the route below: a request sent while
@@ -843,6 +849,31 @@ export class AuditSession {
843
849
  });
844
850
  }
845
851
 
852
+ /**
853
+ * A container script's answer: its HTTP status (or "failed"), the container version its published data opens
854
+ * with, the size of the script as the page got it (decoded) and over the wire, and how long it took to load.
855
+ * response resolves to null when no answer came at all. Only the ID and host are kept, not the address.
856
+ */
857
+ async recordScript(request, response) {
858
+ const container = containerOf(request.url());
859
+ if (!container) return;
860
+ const step = this.currentStep;
861
+ const res = await response;
862
+ const body = res ? await res.body().catch(() => null) : null;
863
+ const sizes = res ? await request.sizes().catch(() => null) : null;
864
+ const timing = request.timing();
865
+ const ms = timing.responseEnd > 0 ? Math.round(timing.responseEnd) : null;
866
+ const version = body ? containerVersion(body.subarray(0, 4096).toString("utf8")) : null;
867
+ this.record("containerScripts", {
868
+ id: container.id, host: container.host, step,
869
+ status: res ? res.status() : "failed",
870
+ ...(version ? { version } : {}),
871
+ ...(body ? { bytes: body.length } : {}),
872
+ ...(sizes?.responseBodySize > 0 ? { transferBytes: sizes.responseBodySize } : {}),
873
+ ...(ms !== null ? { ms } : {}),
874
+ });
875
+ }
876
+
846
877
  /** The iframe a request came from, as frameInfo gives it; null for the page itself or a worker. */
847
878
  frameOf(request) {
848
879
  try {
package/src/checks.js CHANGED
@@ -5,8 +5,8 @@ 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";
9
- import { widgetsOf } from "./frames.js";
8
+ import { duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
9
+ import { widgetSummaries, widgetsOf } from "./frames.js";
10
10
 
11
11
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
12
12
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
@@ -389,11 +389,20 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
389
389
  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) });
390
390
  }
391
391
 
392
+ // A container script that failed to load: none of its tags ran. Said once per container and host.
393
+ for (const sc of (run.containerScripts ?? []).filter((x, i, all) => scriptFailed(x) && all.findIndex((y) => scriptFailed(y) && y.id === x.id && y.host === x.host) === i)) {
394
+ findings.push({ ...base, severity: "warning", check: "container_failed", step: stepLabel(sc.step), detail: d.containerFailed(sc.id, sc.host, String(sc.status)) });
395
+ }
396
+
392
397
  // Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
393
398
  for (const w of widgetsOf(run.widgetHits ?? [])) {
394
399
  const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
395
400
  findings.push({ ...base, severity: "info", check: "widget_tracking", step: stepLabel(first.step), detail: d.widgetTracking(w.origin, w.platforms) });
396
401
  }
402
+ // Events a widget pushed to its own dataLayer that never reached the site's accounts: the page's tags can't see them.
403
+ for (const w of widgetSummaries([run], (a, b) => canonicalEvent(a) === canonicalEvent(b)).filter((x) => x.unshared.length)) {
404
+ findings.push({ ...base, severity: "info", check: "widget_events_unshared", step: stepLabel(w.step), detail: d.widgetEventsUnshared(w.origin, w.unshared.slice(0, 10)) });
405
+ }
397
406
  // A container in a same-site iframe beside one in the page: two documents, two dataLayers.
398
407
  const iframeLoad = loads.find((l) => l.frame?.party === "site" && loads.some((t) => !t.frame && t.step <= l.step));
399
408
  if (iframeLoad) findings.push({ ...base, severity: "info", check: "iframe_container", step: stepLabel(iframeLoad.step), detail: d.iframeContainer(iframeLoad.id, iframeLoad.frame.origin, iframeLoad.frame.parent) });
package/src/containers.js CHANGED
@@ -31,6 +31,40 @@ export function containerOf(url) {
31
31
  };
32
32
  }
33
33
 
34
+ /**
35
+ * The container version a gtm.js or gtag/js script was published as: its data opens with
36
+ * `var data = { "resource": { "version": "153", ...`. Null when the script says nothing of the kind.
37
+ */
38
+ export function containerVersion(text) {
39
+ return String(text ?? "").match(/"resource"\s*:\s*\{\s*"version"\s*:\s*"(\d+)"/)?.[1] ?? null;
40
+ }
41
+
42
+ /** Whether a container script's answer means it never ran: the request failed or the server answered with an error. */
43
+ export const scriptFailed = (script) => script.status === "failed" || (typeof script.status === "number" && script.status >= 400);
44
+
45
+ /**
46
+ * The answer to a load's script request (see recordScript in browser.js): the first one for the same container and
47
+ * host from the load's step on, else the last before it. Null for runs saved by older versions.
48
+ */
49
+ export function scriptOf(run, load) {
50
+ const same = (run.containerScripts ?? []).filter((s) => s.id === load.id && s.host === load.host);
51
+ return same.find((s) => s.step >= (load.step ?? 0)) ?? same.at(-1) ?? null;
52
+ }
53
+
54
+ /** What a row or load shows of its script's answer: { version?, bytes?, transferBytes?, ms?, failed? }. */
55
+ export function scriptFacts(script) {
56
+ if (!script) return {};
57
+ // A load that already carries the facts (from loadSources) passes them on as they are.
58
+ if (!("status" in script)) return Object.fromEntries(["version", "bytes", "transferBytes", "ms", "failed"].filter((k) => script[k] != null).map((k) => [k, script[k]]));
59
+ return {
60
+ ...(script.version ? { version: script.version } : {}),
61
+ ...(script.bytes ? { bytes: script.bytes } : {}),
62
+ ...(script.transferBytes ? { transferBytes: script.transferBytes } : {}),
63
+ ...(script.ms != null ? { ms: script.ms } : {}),
64
+ ...(scriptFailed(script) ? { failed: String(script.status) } : {}),
65
+ };
66
+ }
67
+
34
68
  /** origin + path, so a caller's query string (which may carry user data) is never kept. */
35
69
  export function scriptPath(url) {
36
70
  try {
@@ -106,15 +140,16 @@ export function sourceOf(load, pushes = []) {
106
140
  return { kind: "inline" };
107
141
  }
108
142
 
109
- /** A run's container loads with their sources, in the order they happened. Runs saved by older versions have none. */
143
+ /** A run's container loads with their sources and their scripts' answers (scriptFacts), in the order they happened. Runs saved by older versions have none. */
110
144
  export function loadSources(run) {
111
145
  const pushes = run.dataLayerLog ?? [];
112
- return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes) }));
146
+ return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes), ...scriptFacts(scriptOf(run, load)) }));
113
147
  }
114
148
 
115
149
  /**
116
150
  * One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
117
- * environment?, kind, by?, frame?, pages, journeys }, where frame is the iframe it loaded in, if any.
151
+ * environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, pages, journeys }. frame is the
152
+ * iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the row.
118
153
  */
119
154
  export function containerRows(runs) {
120
155
  const rows = new Map();
@@ -125,6 +160,7 @@ export function containerRows(runs) {
125
160
  id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
126
161
  ...(l.environment ? { environment: l.environment } : {}), kind: l.kind, ...(l.by ? { by: l.by } : {}),
127
162
  ...(l.frame ? { frame: { origin: l.frame.origin, party: l.frame.party } } : {}),
163
+ ...scriptFacts(l),
128
164
  pages: new Set(), journeys: new Set(),
129
165
  };
130
166
  if (l.page) row.pages.add(l.page);
package/src/frames.js CHANGED
@@ -88,3 +88,69 @@ export function widgetsOf(widgetHits = []) {
88
88
  platforms: [...w.platforms.values()].map((p) => ({ platform: p.platform, ids: [...p.ids], events: [...p.events] })),
89
89
  }));
90
90
  }
91
+
92
+ /** The event a dataLayer push names, { event: "x" } or gtag's ["event", "x", ...], or null. GTM's own (gtm.js, gtm.dom...) are left out. */
93
+ export function pushedEvent(value) {
94
+ const name = Array.isArray(value) ? (value[0] === "event" ? value[1] : null) : value?.event;
95
+ return typeof name === "string" && name && !name.startsWith("gtm.") ? name : null;
96
+ }
97
+
98
+ const plainName = (n) => String(n).toLowerCase().replace(/[^a-z0-9]/g, "");
99
+ const isConsentPush = (v) => Array.isArray(v) && v[0] === "consent";
100
+
101
+ /**
102
+ * One summary per embedded widget (third-party iframe origin) across runs: { origin, parents, containers,
103
+ * own, shared, pushes, unshared, step }.
104
+ * - parents: the pages it was embedded in; containers: [{ id, script }] it loaded itself
105
+ * - own: [{ platform, id, events }] it sent to its own accounts; shared: the same for what it sent to the site's
106
+ * - pushes: { total, consent, events: [{ name, count }] } of its own dataLayer
107
+ * - unshared: the events it pushed that none of its hits to the site's accounts carried
108
+ * - step: the first step it was seen at, in the first run it was seen in
109
+ * same(a, b) compares event names; checks.js passes canonicalEvent so add_to_cart and addToCart are one event.
110
+ */
111
+ export function widgetSummaries(runs, same = (a, b) => plainName(a) === plainName(b)) {
112
+ const byOrigin = new Map();
113
+ const of = (frame, step) => {
114
+ const w = byOrigin.get(frame.origin) ?? {
115
+ origin: frame.origin, parents: new Set(), containers: new Map(), own: new Map(), shared: new Map(),
116
+ total: 0, consent: 0, events: new Map(), step,
117
+ };
118
+ if (frame.parent) w.parents.add(frame.parent);
119
+ byOrigin.set(frame.origin, w);
120
+ return w;
121
+ };
122
+ const addHit = (map, h) => {
123
+ const k = `${h.platform}|${h.id ?? ""}`;
124
+ const e = map.get(k) ?? { platform: h.platform, ...(h.id ? { id: h.id } : {}), events: new Set() };
125
+ e.events.add(h.name);
126
+ map.set(k, e);
127
+ };
128
+ for (const run of runs) {
129
+ for (const l of run.containerLoads ?? []) if (l.frame?.party === "third") of(l.frame, l.step).containers.set(l.id, { id: l.id, script: l.script });
130
+ for (const h of run.widgetHits ?? []) addHit(of(h.frame, h.step).own, h);
131
+ for (const h of run.hits ?? []) if (h.frame?.party === "third") addHit(of(h.frame, h.step).shared, h);
132
+ for (const e of run.widgetDataLayerLog ?? []) {
133
+ if (!e.frame) continue;
134
+ const w = of(e.frame, e.step);
135
+ w.total++;
136
+ if (isConsentPush(e.value)) w.consent++;
137
+ const name = pushedEvent(e.value);
138
+ if (name) w.events.set(name, (w.events.get(name) ?? 0) + 1);
139
+ }
140
+ }
141
+ const list = (map) => [...map.values()].map((e) => ({ ...e, events: [...e.events] }));
142
+ return [...byOrigin.values()].map((w) => {
143
+ const shared = list(w.shared);
144
+ const sent = shared.flatMap((e) => e.events);
145
+ return {
146
+ origin: w.origin,
147
+ parents: [...w.parents].sort(),
148
+ containers: [...w.containers.values()],
149
+ own: list(w.own),
150
+ shared,
151
+ pushes: { total: w.total, consent: w.consent, events: [...w.events].map(([name, count]) => ({ name, count })) },
152
+ unshared: [...w.events.keys()].filter((name) => !sent.some((s) => same(s, name))),
153
+ step: w.step,
154
+ };
155
+ });
156
+ }
package/src/html/scope.js CHANGED
@@ -4,6 +4,8 @@
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
6
  import { containerRows } from "../containers.js";
7
+ import { widgetSummaries } from "../frames.js";
8
+ import { canonicalEvent } from "../checks.js";
7
9
  import { providerOf } from "../parsers.js";
8
10
  import { siteOf } from "../redact.js";
9
11
  import { VERSION, BUCKET_ORDER, coverageLines, pcSort, planChangeItems, routeList } from "../report-shared.js";
@@ -200,18 +202,40 @@ function containersCard(ctx) {
200
202
  const rows = containerRows(runs);
201
203
  if (!rows.length) return null;
202
204
  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.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${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("");
205
+ 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.version ? plain(esc(c.version(r.version)), "mono") : ""}${r.bytes ? plain(esc(c.size(r.bytes)), "mono") : ""}${r.ms != null ? plain(esc(c.loadTime(r.ms))) : ""}${r.failed ? badge("broken", c.failed(r.failed)) : ""}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${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
206
  const ids = uniq(rows.map((r) => r.id));
205
207
  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
208
  }
207
209
 
210
+ /** The embedded widgets card: per third-party iframe, where it was embedded, its containers, its tracking and its dataLayer. Null when none. */
211
+ function widgetsCard(ctx) {
212
+ const { m, runs } = ctx;
213
+ const widgets = widgetSummaries(runs, (a, b) => canonicalEvent(a) === canonicalEvent(b));
214
+ if (!widgets.length) return null;
215
+ const w = m.ui.widgets;
216
+ const line = (label, html) => `<li><span class="inv-n">${esc(label)}</span>${html}</li>`;
217
+ const hits = (list) => (list.length ? list.map((e) => code(w.hits(e.platform, e.id, e.events))).join(" ") : `<span class="muted xs">${esc(w.none)}</span>`);
218
+ const items = widgets.map((x) => {
219
+ const events = x.pushes.events.map((e) => code(w.event(e.name, e.count))).join(" ");
220
+ return `<li><details class="card inv"><summary><code>${esc(x.origin)}</code>${plain(esc(w.pushes(x.pushes.total, x.pushes.consent)))}${x.unshared.length ? badge("info", `${m.ui.widgets.unshared}: ${x.unshared.length}`) : ""}</summary><div class="body"><ul class="inv-list">
221
+ ${line(m.ui.embeddedCol, x.parents.map((p) => `${use("ui-page", 13)}<code>${esc(p)}</code>`).join(" ") || `<span class="muted xs">${esc(w.none)}</span>`)}
222
+ ${line(w.containers, x.containers.length ? x.containers.map((c) => code(c.id)).join(" ") : `<span class="muted xs">${esc(w.none)}</span>`)}
223
+ ${line(m.ui.ownCol, hits(x.own))}
224
+ ${line(m.ui.sharedCol, hits(x.shared))}
225
+ ${line(m.ui.pushesCol, events || `<span class="muted xs">${esc(w.none)}</span>`)}
226
+ ${x.unshared.length ? line(w.unshared, x.unshared.map(code).join(" ")) : ""}</ul></div></details></li>`;
227
+ }).join("");
228
+ return `<li><details class="card inv" id="inv-widgets"><summary><span class="t">${esc(m.ui.widgetsTitle)}</span>${plain(n(widgets.length))}<span class="inv-m">${esc(widgets.slice(0, 3).map((x) => x.origin.replace(/^https?:\/\//, "")).join(", "))}${widgets.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.widgetsIntro)}</p><ul class="inv-rows">${items}</ul></div></details></li>`;
229
+ }
230
+
208
231
  function inventorySection(ctx) {
209
232
  const { m, runs } = ctx;
210
233
  const t = m.html.scope;
211
234
  const { lines, otherCookies } = inventoryLines(ctx);
212
235
  const skipped = skippedTotals(runs);
213
236
  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>`);
237
+ const widgets = widgetsCard(ctx);
238
+ if (!lines.length && !skipped.length && !containers && !widgets) return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<p class="muted">${esc(m.ui.noInventory)}</p>`);
215
239
  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>`;
216
240
  const out = lines.map((x) => {
217
241
  const id = `inv-${safeKey(x.p)}`;
@@ -227,6 +251,7 @@ function inventorySection(ctx) {
227
251
  <div role="tabpanel" id="${tid("pages")}" data-label="${esc(m.ui.pagesCol)}">${pages ? `<ul class="inv-list">${pages}</ul>` : none}</div>
228
252
  <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>`;
229
253
  });
254
+ if (widgets) out.unshift(widgets);
230
255
  if (containers) out.unshift(containers);
231
256
  const skippedN = skipped.reduce((a, [, k]) => a + k, 0);
232
257
  if (skipped.length) {
package/src/i18n.js CHANGED
@@ -209,11 +209,31 @@ const en = {
209
209
  sourceCol: "Loaded by",
210
210
  layerCol: "dataLayer",
211
211
  servedCol: "Served from",
212
+ scriptCol: "Version and size",
213
+ widgetsTitle: "Embedded widgets",
214
+ widgetsIntro: "Third-party iframes on the site's pages, such as booking, chat or payment widgets: the containers they loaded, the tracking they sent to their own accounts and to the site's, and the events in their own dataLayer, which the page's tags never see.",
215
+ widgetCol: "Widget",
216
+ embeddedCol: "Embedded in",
217
+ ownCol: "Own tracking",
218
+ sharedCol: "To the site's accounts",
219
+ pushesCol: "dataLayer events",
220
+ widgets: {
221
+ containers: "Containers",
222
+ unshared: "Not sent to the site's accounts",
223
+ pushes: (n, consent) => `${n} ${n === 1 ? "push" : "pushes"}${consent ? `, ${consent} consent ${consent === 1 ? "command" : "commands"}` : ""}`,
224
+ event: (name, count) => (count > 1 ? `${name} ×${count}` : name),
225
+ hits: (platform, id, events) => `${label(platform)}${id ? ` ${id}` : ""}: ${events.join(", ")}`,
226
+ none: "none",
227
+ },
212
228
  containers: {
213
229
  script: { gtm: "GTM container", gtag: "Google tag" },
214
230
  served: { google: "Google", site: "The site's own domain" },
215
231
  environment: (env) => (env === "unnamed" ? "GTM environment" : `GTM environment ${env}`),
216
232
  iframe: (origin, party) => `In an iframe from ${origin} (${party === "site" ? "same site" : "third party"})`,
233
+ version: (v) => `version ${v}`,
234
+ size: (bytes) => `${(bytes / 1024).toFixed(1)} KB`,
235
+ loadTime: (ms) => `loaded in ${ms} ms`,
236
+ failed: (status) => (status === "failed" ? "failed to load" : `failed to load (HTTP ${status})`),
217
237
  kinds: {
218
238
  html: () => "A script tag in the page's HTML",
219
239
  inline: () => "Inline code in the page (the snippet)",
@@ -354,6 +374,8 @@ const en = {
354
374
  fromWidget: (origin, text) => `In an embedded widget from ${origin}: ${text}`,
355
375
  widgetTracking: (origin, platforms) => `An embedded widget from ${origin} sent its own tracking: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. It goes to accounts the site's own tags don't use, so it was left out of the plan, coverage and duplicate checks; the consent and personal data checks still include it.`,
356
376
  iframeContainer: (id, origin, parent) => `${id} runs in an iframe from ${origin} embedded in ${parent}. The iframe has its own dataLayer: events and consent commands pushed in the page never reach it, so it needs its own.`,
377
+ containerFailed: (id, host, status) => `The ${id} script from ${host} ${status === "failed" ? "failed to load" : `was answered with HTTP ${status}`}, so none of its tags ran on that page.`,
378
+ widgetEventsUnshared: (origin, names) => `An embedded widget from ${origin} pushed ${names.map(q).join(", ")} to its own dataLayer, but none of its hits to the site's accounts carried ${names.length === 1 ? "it" : "them"}. The page's tags can't read a widget's dataLayer, so ${names.length === 1 ? "this event reaches" : "these events reach"} only the widget's own tracking.`,
357
379
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
358
380
  consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
359
381
  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.",
@@ -415,6 +437,8 @@ const en = {
415
437
  container_send_to_only: "The destination runs without its own config, so the settings a config command would give it are missing.",
416
438
  widget_tracking: "Nothing is wrong with the site's tracking: the widget's own hits were kept out of the plan checks so they can't pass for the site's.",
417
439
  iframe_container: "Tags in the iframe miss what the page pushes, so conversions completed in it can go untracked or be sent without the visitor's consent.",
440
+ container_failed: "None of the container's tags ran: page views, conversions and consent signals it sends were all lost on that page.",
441
+ widget_events_unshared: "Conversions completed in the widget, such as a booking, a payment or a form, can be missing from the site's analytics and ad accounts.",
418
442
  campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
419
443
  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.",
420
444
  event_id_mismatch: "The browser and server copies of this event carry different IDs, so Meta cannot pair them and counts the conversion twice.",
@@ -463,6 +487,8 @@ const en = {
463
487
  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.",
464
488
  widget_tracking: "Nothing to fix for the plan. If the widget sets cookies or sends data before consent, the consent findings name it; ask its vendor how to hold it back.",
465
489
  iframe_container: "Push the events and the consent state inside the iframe as well (or pass them in with postMessage), or track the iframe's steps from the page.",
490
+ container_failed: "Check the container ID and the address in the snippet; for a server container or the Google tag gateway, check that it serves gtm.js and gtag/js.",
491
+ widget_events_unshared: "If the site needs these events, have the widget send them to the site's accounts (most booking and form widgets take a GA4 or GTM ID), or pass them to the page with postMessage and push them to the page's dataLayer.",
466
492
  campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
467
493
  event_id_reused: "Generate a new event ID for every event, not once per page load.",
468
494
  event_id_mismatch: "Use one event ID for the pixel event and the server copy: pass the same value to both tags.",
@@ -511,6 +537,8 @@ const en = {
511
537
  container_send_to_only: "hygiene",
512
538
  widget_tracking: "hygiene",
513
539
  iframe_container: "hygiene",
540
+ container_failed: "money",
541
+ widget_events_unshared: "money",
514
542
  campaign_params: "money",
515
543
  event_id_reused: "money",
516
544
  event_id_mismatch: "money",
@@ -1036,11 +1064,31 @@ const tr = {
1036
1064
  sourceCol: "Yükleyen",
1037
1065
  layerCol: "dataLayer",
1038
1066
  servedCol: "Sunulduğu yer",
1067
+ scriptCol: "Sürüm ve boyut",
1068
+ widgetsTitle: "Gömülü widget'lar",
1069
+ widgetsIntro: "Sitenin sayfalarındaki üçüncü taraf iframe'ler, örneğin rezervasyon, sohbet ya da ödeme widget'ları: yükledikleri container'lar, kendi hesaplarına ve sitenin hesaplarına gönderdikleri izleme ve sayfanın etiketlerinin hiç görmediği kendi dataLayer'larındaki olaylar.",
1070
+ widgetCol: "Widget",
1071
+ embeddedCol: "Gömülü olduğu yer",
1072
+ ownCol: "Kendi izlemesi",
1073
+ sharedCol: "Sitenin hesaplarına",
1074
+ pushesCol: "dataLayer olayları",
1075
+ widgets: {
1076
+ containers: "Container'lar",
1077
+ unshared: "Sitenin hesaplarına gönderilmeyen",
1078
+ pushes: (n, consent) => `${n} push${consent ? `, ${consent} onay komutu` : ""}`,
1079
+ event: (name, count) => (count > 1 ? `${name} ×${count}` : name),
1080
+ hits: (platform, id, events) => `${label(platform)}${id ? ` ${id}` : ""}: ${events.join(", ")}`,
1081
+ none: "yok",
1082
+ },
1039
1083
  containers: {
1040
1084
  script: { gtm: "GTM container'ı", gtag: "Google etiketi" },
1041
1085
  served: { google: "Google", site: "Sitenin kendi alan adı" },
1042
1086
  environment: (env) => (env === "unnamed" ? "GTM ortamı" : `GTM ortamı ${env}`),
1043
1087
  iframe: (origin, party) => `${origin} kaynaklı bir iframe içinde (${party === "site" ? "aynı site" : "üçüncü taraf"})`,
1088
+ version: (v) => `sürüm ${v}`,
1089
+ size: (bytes) => `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`,
1090
+ loadTime: (ms) => `${ms} ms'de yüklendi`,
1091
+ failed: (status) => (status === "failed" ? "yüklenemedi" : `yüklenemedi (HTTP ${status})`),
1044
1092
  kinds: {
1045
1093
  html: () => "Sayfanın HTML'indeki bir script etiketi",
1046
1094
  inline: () => "Sayfadaki satır içi kod (snippet)",
@@ -1177,6 +1225,8 @@ const tr = {
1177
1225
  fromWidget: (origin, text) => `${origin} kaynaklı gömülü bir widget içinde: ${text}`,
1178
1226
  widgetTracking: (origin, platforms) => `${origin} kaynaklı gömülü bir widget kendi izlemesini gönderdi: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. Bunlar sitenin kendi etiketlerinin kullanmadığı hesaplara gidiyor; bu yüzden plan, kapsam ve tekrar kontrollerinin dışında tutuldu, onay ve kişisel veri kontrolleri ise onları yine içeriyor.`,
1179
1227
  iframeContainer: (id, origin, parent) => `${id}, ${parent} içine gömülü ${origin} kaynaklı bir iframe içinde çalışıyor. iframe'in kendi dataLayer'ı var: sayfada gönderilen olaylar ve onay komutları ona hiç ulaşmaz, bu yüzden kendi olaylarına ve onayına ihtiyacı var.`,
1228
+ containerFailed: (id, host, status) => `${host} adresinden gelen ${id} betiği ${status === "failed" ? "yüklenemedi" : `HTTP ${status} ile yanıtlandı`}; bu yüzden o sayfada etiketlerinin hiçbiri çalışmadı.`,
1229
+ widgetEventsUnshared: (origin, names) => `${origin} kaynaklı gömülü bir widget kendi dataLayer'ına ${names.map(q).join(", ")} gönderdi, ancak sitenin hesaplarına giden isteklerinin hiçbiri ${names.length === 1 ? "onu" : "onları"} taşımadı. Sayfanın etiketleri bir widget'ın dataLayer'ını okuyamaz; bu yüzden ${names.length === 1 ? "bu olay" : "bu olaylar"} yalnızca widget'ın kendi izlemesine ulaşıyor.`,
1180
1230
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
1181
1231
  consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
1182
1232
  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.",
@@ -1237,6 +1287,8 @@ const tr = {
1237
1287
  container_send_to_only: "Hedef kendi config'i olmadan çalışıyor; bir config komutunun ona vereceği ayarlar eksik.",
1238
1288
  widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
1239
1289
  iframe_container: "iframe içindeki etiketler sayfanın gönderdiklerini görmez; içinde tamamlanan dönüşümler izlenmeyebilir ya da ziyaretçinin onayı olmadan gönderilebilir.",
1290
+ container_failed: "Container'ın etiketlerinin hiçbiri çalışmadı: gönderdiği sayfa görüntülemeleri, dönüşümler ve onay sinyalleri o sayfada kayboldu.",
1291
+ widget_events_unshared: "Widget içinde tamamlanan dönüşümler, örneğin bir rezervasyon, ödeme ya da form, sitenin analitik ve reklam hesaplarında eksik kalabilir.",
1240
1292
  campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
1241
1293
  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.",
1242
1294
  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.",
@@ -1283,6 +1335,8 @@ const tr = {
1283
1335
  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.",
1284
1336
  widget_tracking: "Plan için düzeltilecek bir şey yok. Widget onaydan önce çerez yazıyor ya da veri gönderiyorsa onay bulguları onu adlandırır; nasıl durdurulacağını sağlayıcısına sorun.",
1285
1337
  iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
1338
+ container_failed: "Snippet'teki container kimliğini ve adresi kontrol edin; sunucu container'ı ya da Google etiket ağ geçidi için gtm.js ve gtag/js sunduğunu kontrol edin.",
1339
+ widget_events_unshared: "Site bu olaylara ihtiyaç duyuyorsa widget'ın onları sitenin hesaplarına göndermesini sağlayın (çoğu rezervasyon ve form widget'ı bir GA4 ya da GTM kimliği kabul eder) ya da onları postMessage ile sayfaya iletip sayfanın dataLayer'ına gönderin.",
1286
1340
  campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
1287
1341
  event_id_reused: "Her olay için yeni bir olay kimliği üretin; sayfa yüklemesi başına bir kez değil.",
1288
1342
  event_id_mismatch: "Piksel olayı ve sunucu kopyası için tek bir olay kimliği kullanın: aynı değeri iki etikete de verin.",
@@ -1330,6 +1384,8 @@ const tr = {
1330
1384
  container_send_to_only: "hygiene",
1331
1385
  widget_tracking: "hygiene",
1332
1386
  iframe_container: "hygiene",
1387
+ container_failed: "money",
1388
+ widget_events_unshared: "money",
1333
1389
  campaign_params: "money",
1334
1390
  event_id_reused: "money",
1335
1391
  event_id_mismatch: "money",
package/src/mcp.js CHANGED
@@ -17,13 +17,13 @@ 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, isConsentCommand, summarize } from "./checks.js";
20
+ import { canonicalEvent, 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";
24
24
  import { readBaseline } from "./baseline.js";
25
25
  import { MAX_CONCURRENCY } from "./pool.js";
26
- import { scriptCspSource } from "./report.js";
26
+ import { scriptCell, scriptCspSource } from "./report.js";
27
27
  import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
28
28
  import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
29
29
  import { createProvider } from "./providers.js";
@@ -34,7 +34,7 @@ import { hitParams } from "./hit-params.js";
34
34
  import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
35
35
  import { cmpFacts } from "./cmp.js";
36
36
  import { containerRows, loadSources } from "./containers.js";
37
- import { isWidgetHit, siteIds } from "./frames.js";
37
+ import { isWidgetHit, pushedEvent, siteIds, splitRun, widgetSummaries } from "./frames.js";
38
38
  import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
39
39
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
40
40
 
@@ -166,7 +166,8 @@ function cookieLines(session, step) {
166
166
  /** One container load in words: its ID, what inserted it, its dataLayer and where it came from. */
167
167
  function describeLoad(l) {
168
168
  const c = messages("en").ui.containers;
169
- const extra = [l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment), l.frame && c.iframe(l.frame.origin, l.frame.party)].filter(Boolean);
169
+ const script = scriptCell(c, l);
170
+ const extra = [script,l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment), l.frame && c.iframe(l.frame.origin, l.frame.party)].filter(Boolean);
170
171
  return ` ${l.id} (${c.script[l.script]}): ${c.kinds[l.kind](l.by)}${extra.length ? `; ${extra.join(", ")}` : ""}`;
171
172
  }
172
173
 
@@ -189,7 +190,7 @@ export function stepCapture(audit, step) {
189
190
  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(", ")}`);
190
191
  if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
191
192
  if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
192
- const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), dataLayerLog: session.dataLayerLog });
193
+ const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
193
194
  if (loads.length) lines.push("Google containers loaded:", ...loads.map(describeLoad));
194
195
  lines.push(...cookieLines(session, step));
195
196
  const tag = session.steps[step]?.tagConsent;
@@ -245,6 +246,11 @@ function containerJson(l) {
245
246
  ...(l.served === "site" ? { served_from: l.host } : {}),
246
247
  ...(l.environment ? { gtm_environment: l.environment } : {}),
247
248
  ...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
249
+ ...(l.version ? { version: l.version } : {}),
250
+ ...(l.bytes ? { size_bytes: l.bytes } : {}),
251
+ ...(l.transferBytes ? { transfer_bytes: l.transferBytes } : {}),
252
+ ...(l.ms != null ? { load_ms: l.ms } : {}),
253
+ ...(l.failed ? { failed: l.failed } : {}),
248
254
  ...(l.pages ? { pages: l.pages } : {}),
249
255
  };
250
256
  }
@@ -257,12 +263,33 @@ export function containerLines(runs) {
257
263
  return [
258
264
  "Google containers (how each reached the page):",
259
265
  ...rows.map((r) => {
260
- const extra = [r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
266
+ const extra = [scriptCell(c, r), r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
261
267
  return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
262
268
  }),
263
269
  ];
264
270
  }
265
271
 
272
+ /** The embedded widgets of the runs as summaries (see widgetSummaries), compared by canonical event name. */
273
+ const widgetsIn = (runs) => widgetSummaries(runs, (a, b) => canonicalEvent(a) === canonicalEvent(b));
274
+
275
+ /** The embedded widgets as lines for a tool result; [] when there were none. */
276
+ export function widgetLines(runs) {
277
+ const widgets = widgetsIn(runs);
278
+ if (!widgets.length) return [];
279
+ const w = messages("en").ui.widgets;
280
+ const hits = (list) => list.map((e) => w.hits(e.platform, e.id, e.events)).join("; ") || w.none;
281
+ return [
282
+ "Embedded widgets (third-party iframes; their own dataLayer is invisible to the page's tags):",
283
+ ...widgets.map((x) => [
284
+ ` ${x.origin}, embedded in ${x.parents.join(", ") || "?"}${x.containers.length ? `; containers ${x.containers.map((c) => c.id).join(", ")}` : ""}`,
285
+ ` own tracking: ${hits(x.own)}`,
286
+ ` to the site's accounts: ${hits(x.shared)}`,
287
+ ` dataLayer: ${w.pushes(x.pushes.total, x.pushes.consent)}${x.pushes.events.length ? `: ${x.pushes.events.map((e) => w.event(e.name, e.count)).join(", ")}` : ""}`,
288
+ ...(x.unshared.length ? [` not sent to the site's accounts: ${x.unshared.join(", ")}`] : []),
289
+ ].join("\n")),
290
+ ];
291
+ }
292
+
266
293
  export function timelineOf(run) {
267
294
  let lastIds = null;
268
295
  return (run.steps ?? []).map((st) => {
@@ -276,13 +303,15 @@ export function timelineOf(run) {
276
303
  if (hits.length) entry.hits = hits.map((h) => ({ platform: h.platform, event: h.name, ...(h.consent ? { consent: h.consent } : {}), ...(h.late ? { late_ms: h.late.ms } : {}), ...(h.frame ? { iframe: h.frame.origin } : {}) }));
277
304
  // An embedded widget's own hits, kept apart from the site's (see src/frames.js).
278
305
  const widget = (run.widgetHits ?? []).filter((h) => h.step === st.index);
306
+ const widgetPushes = (run.widgetDataLayerLog ?? []).filter((e) => e.step === st.index && e.frame && pushedEvent(e.value));
307
+ if (widgetPushes.length) entry.widget_pushes = widgetPushes.map((e) => ({ widget: e.frame.origin, event: pushedEvent(e.value) }));
279
308
  if (widget.length) entry.widget_hits = widget.map((h) => ({ widget: h.frame.origin, platform: h.platform, event: h.name, ...(h.id ? { id: h.id } : {}), ...(h.consent ? { consent: h.consent } : {}) }));
280
309
  if (others.length) entry.other_tracking_calls = others.length;
281
310
  if (commands.length) entry.consent_commands = commands.map((e) => ({ [e.value[1]]: e.value[2] }));
282
311
  if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
283
312
  const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
284
313
  if (Object.keys(cookies).length) entry.cookies = cookies;
285
- const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), dataLayerLog: run.dataLayerLog });
314
+ const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), containerScripts: run.containerScripts, dataLayerLog: run.dataLayerLog });
286
315
  if (loads.length) entry.containers = loads.map(containerJson);
287
316
  if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
288
317
  if (st.identity) lastIds = st.identity;
@@ -531,22 +560,40 @@ export function buildServer(opts) {
531
560
  },
532
561
  async ({ audit_id }) => {
533
562
  const { session } = getAudit(audit_id);
534
- const loads = loadSources({ containerLoads: session.containerLoads ?? [], dataLayerLog: session.dataLayerLog });
563
+ const loads = loadSources({ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
535
564
  if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
536
565
  const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
537
566
  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) })))}`);
538
567
  },
539
568
  );
540
569
 
570
+ server.registerTool(
571
+ "get_widgets",
572
+ {
573
+ title: "List the embedded widgets seen so far",
574
+ description:
575
+ "Returns every third-party iframe (a booking, chat or payment widget, a video) this audit's pages embedded that tracked or loaded a Google container so far: the pages it was embedded in, the containers it loaded, the tracking it sent to its own accounts and to the site's, the events and consent commands in its own dataLayer, and the events it pushed that never reached the site's accounts. The page's tags can't read a widget's dataLayer, so those events only reach the widget's own tracking. Widgets' own hits are left out of the plan checks; finish_audit still checks them for consent and personal data.",
576
+ inputSchema: { audit_id: z.string() },
577
+ annotations: { readOnlyHint: true },
578
+ },
579
+ async ({ audit_id }) => {
580
+ const { session } = getAudit(audit_id);
581
+ const run = { ...splitRun(session), containerLoads: session.containerLoads };
582
+ const lines = widgetLines([run]);
583
+ if (!lines.length) return text("No embedded third-party widget has tracked or loaded a Google container on this audit's pages yet.");
584
+ return text(`${lines.join("\n")}\n\nAs JSON: ${JSON.stringify(widgetsIn([run]))}`);
585
+ },
586
+ );
587
+
541
588
  server.registerTool(
542
589
  "get_report",
543
590
  {
544
591
  title: "Read a report this server wrote",
545
592
  description:
546
- "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.",
593
+ "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, version, size, load time and pages. widgets: every embedded third-party widget as JSON: where it was embedded, the containers it loaded, the tracking it sent to its own accounts and to the site's, its own dataLayer events, and the events it pushed that never reached the site's accounts (unshared).",
547
594
  inputSchema: {
548
595
  report: z.string().describe("The report name finish_audit or run_audit gave."),
549
- part: z.enum(["markdown", "summary", "timeline", "containers"]).optional().describe("Default markdown."),
596
+ part: z.enum(["markdown", "summary", "timeline", "containers", "widgets"]).optional().describe("Default markdown."),
550
597
  },
551
598
  annotations: { readOnlyHint: true },
552
599
  },
@@ -558,6 +605,7 @@ export function buildServer(opts) {
558
605
  if (part === "summary") return text(await readFile(path.join(dir, "summary.json"), "utf8"));
559
606
  const capture = JSON.parse(await readFile(path.join(dir, "capture.json"), "utf8"));
560
607
  if (part === "containers") return text(JSON.stringify(containerRows(capture.runs ?? []).map(containerJson)));
608
+ if (part === "widgets") return text(JSON.stringify(widgetsIn(capture.runs ?? [])));
561
609
  return text(JSON.stringify((capture.runs ?? []).map((r) => ({ journey: r.journeyId, viewport: r.viewport, steps: timelineOf(r) }))));
562
610
  } catch (err) {
563
611
  return fail(`Could not read the report: ${err.message}`);
@@ -668,13 +716,14 @@ export function buildServer(opts) {
668
716
  skippedLine(run),
669
717
  ...((platform) => (platform ? [`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`] : []))(cmpFacts(messages("en"), [run])),
670
718
  ...containerLines([run]),
719
+ ...widgetLines([run]),
671
720
  opts.allowFiles
672
721
  ? `Full report with the event timeline: ${reportPath}`
673
722
  : opts.reportsUrl
674
723
  ? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
675
724
  : "",
676
725
  "",
677
- `To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary, timeline or containers).`,
726
+ `To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary, timeline, containers or widgets).`,
678
727
  "Explain each problem to the user with its likely cause and fix.",
679
728
  ];
680
729
  return {
@@ -821,8 +870,9 @@ export function buildServer(opts) {
821
870
  ...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
822
871
  ...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
823
872
  ...containerLines(result.runs),
873
+ ...widgetLines(result.runs),
824
874
  `Report: ${result.reportPath}`,
825
- `To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary, timeline or containers).`,
875
+ `To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary, timeline, containers or widgets).`,
826
876
  ].join("\n"),
827
877
  );
828
878
  },
package/src/report.js CHANGED
@@ -6,6 +6,7 @@ import { canonicalEvent, groupFindings, summarize } from "./checks.js";
6
6
  import { messages } from "./i18n.js";
7
7
  import { buildInventory } from "./inventory.js";
8
8
  import { containerRows } from "./containers.js";
9
+ import { widgetSummaries } from "./frames.js";
9
10
  import { computeMetrics } from "./metrics.js";
10
11
  import { historySummaryLines, sparkline } from "./history.js";
11
12
  import { cardIds } from "./html/lib.js";
@@ -397,15 +398,38 @@ function mdInventory(m, runs) {
397
398
  return lines;
398
399
  }
399
400
 
401
+ /** A container row's version, size and load time, or that it failed to load, in a few words. */
402
+ export function scriptCell(c, r) {
403
+ if (r.failed) return c.failed(r.failed);
404
+ return [r.version && c.version(r.version), r.bytes && c.size(r.bytes), r.ms != null && c.loadTime(r.ms)].filter(Boolean).join(", ");
405
+ }
406
+
407
+ /** The embedded widgets, one row each: where they were embedded, their tracking and their own dataLayer events. Omitted when none. */
408
+ function mdWidgets(m, runs) {
409
+ const widgets = widgetSummaries(runs, (a, b) => canonicalEvent(a) === canonicalEvent(b));
410
+ if (!widgets.length) return [];
411
+ const w = m.ui.widgets;
412
+ const hits = (list) => list.map((e) => w.hits(e.platform, e.id, e.events)).join("; ") || w.none;
413
+ const lines = [`## ${m.ui.widgetsTitle}`, "", `| ${m.ui.widgetCol} | ${m.ui.embeddedCol} | ${m.ui.ownCol} | ${m.ui.sharedCol} | ${m.ui.pushesCol} |`, "|---|---|---|---|---|"];
414
+ for (const x of widgets.slice(0, MD_INVENTORY_ROWS)) {
415
+ const events = x.pushes.events.map((e) => w.event(e.name, e.count)).join(", ") || w.none;
416
+ const unshared = x.unshared.length ? ` (${w.unshared}: ${x.unshared.join(", ")})` : "";
417
+ lines.push(`| ${mdText(x.origin)} | ${mdText(x.parents.join(", ")) || "–"} | ${mdText(hits(x.own))} | ${mdText(hits(x.shared))} | ${mdText(events + unshared)} |`);
418
+ }
419
+ if (widgets.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(widgets.length - MD_INVENTORY_ROWS, "report.html"));
420
+ lines.push("");
421
+ return lines;
422
+ }
423
+
400
424
  /** The Google containers and how each reached the page, one row per container and source. Omitted when none loaded. */
401
425
  function mdContainers(m, runs) {
402
426
  const rows = containerRows(runs);
403
427
  if (!rows.length) return [];
404
428
  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} |`, "|---|---|---|---|---|"];
429
+ const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.scriptCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|---|"];
406
430
  for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
407
431
  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.frame ? `, ${mdText(c.iframe(r.frame.origin, r.frame.party))}` : ""}${r.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${r.pages.length} |`);
432
+ lines.push(`| ${mdText(r.id)} (${c.script[r.script]}) | ${mdText(c.kinds[r.kind](r.by))}${r.frame ? `, ${mdText(c.iframe(r.frame.origin, r.frame.party))}` : ""}${r.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${mdText(scriptCell(c, r)) || "–"} | ${r.pages.length} |`);
409
433
  }
410
434
  if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
411
435
  lines.push("");
@@ -464,6 +488,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
464
488
  lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
465
489
  lines.push(...mdInventory(m, runs));
466
490
  lines.push(...mdContainers(m, runs));
491
+ lines.push(...mdWidgets(m, runs));
467
492
  // A report with its own name or logo carries no Analitik İşler credit; the ai tag debugger one stays.
468
493
  if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
469
494
  else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");