@analitikisler/ai-tag-debugger 0.22.3 → 0.22.5

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.5 (2026-11-04)
4
+
5
+ - Runs can report findings 0.22.4 did not on the same site (`container_not_run`, `container_held`). A baseline written with 0.22.4 can then list new entries; `--update-baseline` rewrites it.
6
+ - After every step the tool reads Google's own registry of the containers that ran (`google_tag_manager`), so the Google containers card, `report.md` and MCP say when a container never ran, however its script got into the page.
7
+ - New check `container_not_run` (warning): a GTM container was downloaded or asked for but never ran on any page of the run, so none of its tags fired. When a consent platform held it back and the run never consented, `container_held` (info) says so instead.
8
+ - Fixed: a container the page only preloaded (`<link rel="preload">`, as Next.js does before its own code adds the script) or that ran without the tool seeing it added (as when a consent platform's auto-blocking replaces the browser's own functions) was missing from the containers card. It now has a row saying which.
9
+ - Found on a live Next.js site: GTM was only preloaded and never ran, because the site's own scripts were served as HTML and its app crashed on start. `page_crashed` reported the crash; the containers card now also shows that GTM never ran.
10
+
11
+ ## 0.22.4 (2026-11-04)
12
+
13
+ - Runs can report a finding 0.22.3 did not on the same site (`iframe_message_unhandled`). A baseline written with 0.22.3 can then list new entries; `--update-baseline` rewrites it.
14
+ - New **Messages from iframes** card under the inventory and a table in `report.md`: each event an iframe posted to the page with `postMessage` (a form or checkout saying it is done), and what the page did next: pushed or sent the same event, did something else within 3 seconds (perhaps the same event under another name), or nothing. Only the message's name and field names are recorded, never values. Housekeeping messages (resizing, ready and heartbeat pings) are left out. `capture.json` keeps them under `frameMessages`.
15
+ - New check `iframe_message_unhandled` (info): an iframe posted an event to the page, but the page pushed nothing to its dataLayer and sent no hit right after, so a conversion completed in the iframe can go untracked.
16
+ - MCP: step results list the messages iframes posted at that step, `finish_audit` and `run_audit` end with them and what the page did, the JSON timeline lists them per step under `iframe_messages`, and `get_report` returns them with `part: "messages"`.
17
+ - New guide, [Google containers, embedded widgets and iframes](docs/guides/containers-and-widgets.md), in English and Turkish: what the containers, widgets and messages cards show, what each of their findings usually means and how to fix it, with a page listener that relays an iframe's event to the dataLayer.
18
+
3
19
  ## 0.22.3 (2026-11-04)
4
20
 
5
21
  - 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.
package/README.md CHANGED
@@ -327,7 +327,7 @@ Under the inventory, **Google containers** lists every GTM container (`gtm.js`)
327
327
 
328
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
- 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.
330
+ [The guide](docs/guides/containers-and-widgets.md) explains each finding below with what to do. After every step the tool also reads Google's own registry of the containers that ran (`google_tag_manager`), so a container that downloaded but never ran is reported, and a container the page only preloaded (`<link rel="preload">`), or that ran without the tool seeing it added, still gets a row. Seven checks read it: `container_not_run` (warning) for a GTM container that was downloaded or asked for but never ran, so none of its tags fired, or `container_held` (info) instead when a consent platform held it back and the run never consented; `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
 
@@ -337,6 +337,8 @@ A widget's own hits are left out of everything that judges the site's tracking:
337
337
 
338
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
339
 
340
+ When an iframe posts an event to the page with `postMessage` (a form or checkout saying it is done), the **Messages from iframes** card lists it with what the page did next: pushed or sent the same event, did something else within 3 seconds (perhaps the same event under another name), or nothing, which is reported as `iframe_message_unhandled` (info). Only the message's name and field names are recorded, never values; housekeeping messages (resizing, ready and heartbeat pings) are left out. `capture.json` keeps them under `frameMessages`. The guide [Google containers, embedded widgets and iframes](docs/guides/containers-and-widgets.md) explains every finding here with what to do, including a page listener that relays an iframe's event to the dataLayer.
341
+
340
342
  `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.
341
343
 
342
344
  ### Folder layout and plan IDs
@@ -669,7 +671,7 @@ Tools the server offers:
669
671
  | `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. |
670
672
  | `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. |
671
673
  | `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. |
674
+ | `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) `widgets` (every embedded widget with its tracking and dataLayer events, as JSON) or `messages` (every event an iframe posted to the page, with what the page did next, as JSON). Only this server's own reports, over HTTP too. |
673
675
  | `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. |
674
676
 
675
677
  `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
@@ -327,7 +327,7 @@ Envanterin altında **Google container'ları**, sayfaların yüklediği her GTM
327
327
 
328
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
- 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).
330
+ [Rehber](docs/guides/containers-and-widgets.tr.md) aşağıdaki her bulguyu ne yapılacağıyla anlatır. Araç her adımdan sonra Google'ın çalışan container'lar kaydını (`google_tag_manager`) da okur; böylece indirilip hiç çalışmayan bir container bildirilir, sayfanın yalnızca ön yüklediği (`<link rel="preload">`) ya da eklendiği görülmeden çalışan bir container da yine bir satır alır. Yedi kontrol bunu okur: indirilen ya da istenen ama hiç çalışmayan, bu yüzden etiketlerinin hiçbiri çalışmayan bir GTM container'ı için `container_not_run` (uyarı), bir onay platformu onu beklettiyse ve çalıştırmada onay verilmediyse bunun yerine `container_held` (bilgi); 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
 
@@ -337,6 +337,8 @@ Bir widget'ın kendi istekleri, sitenin izlemesini değerlendiren her şeyin dı
337
337
 
338
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
339
 
340
+ Bir iframe sayfaya `postMessage` ile bir olay gönderdiğinde (bir formun ya da ödemenin bittiğini söylemesi), **iframe'lerden gelen mesajlar** kartı onu sayfanın ardından ne yaptığıyla listeler: aynı olayı dataLayer'a gönderdi ya da iletti, 3 saniye içinde başka bir şey yaptı (belki aynı olayı başka bir adla) ya da hiçbir şey yapmadı; sonuncusu `iframe_message_unhandled` (bilgi) olarak bildirilir. Mesajın yalnızca adı ve alan adları kaydedilir, değerleri asla; düzenleme mesajları (boyutlandırma, hazır ve canlılık sinyalleri) dışarıda bırakılır. `capture.json` bunları `frameMessages` altında saklar. [Google container'ları, gömülü widget'lar ve iframe'ler](docs/guides/containers-and-widgets.tr.md) rehberi buradaki her bulguyu ne yapılacağıyla anlatır; bir iframe'in olayını dataLayer'a ileten bir sayfa dinleyicisi de içerir.
341
+
340
342
  `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.
341
343
 
342
344
  ### Klasör yapısı ve plan kimlikleri
@@ -669,7 +671,7 @@ Sunucunun sunduğu araçlar:
669
671
  | `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. |
670
672
  | `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. |
671
673
  | `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. |
674
+ | `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) `widgets` (izlemesi ve dataLayer olaylarıyla her gömülü widget, JSON olarak) ya da `messages` (bir iframe'in sayfaya gönderdiği her olay, sayfanın ardından ne yaptığıyla, JSON olarak). Yalnızca bu sunucunun kendi raporları, HTTP üzerinden de. |
673
675
  | `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. |
674
676
 
675
677
  `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.3",
4
+ "version": "0.22.5",
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: [], containerScripts: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
241
+ cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], containerScripts: [], containersRunning: [], containerPreloads: [], frameMessages: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
242
242
  };
243
243
  }
244
244
 
@@ -259,6 +259,9 @@ export function collect(session, journey, viewport) {
259
259
  ...(session.opts.blockHits === false ? { live: true } : {}),
260
260
  containerLoads: session.containerLoads,
261
261
  containerScripts: session.containerScripts,
262
+ containersRunning: [...session.containersRunning].map(([id, step]) => ({ id, step })),
263
+ containerPreloads: [...session.containerPreloads.values()],
264
+ frameMessages: session.frameMessages,
262
265
  network: session.network,
263
266
  providers: session.providers,
264
267
  skipped: session.skipped,
package/src/browser.js CHANGED
@@ -5,12 +5,12 @@ import { readFile, rm, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { chromium, devices } from "playwright";
7
7
  import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders, PROVIDERS } from "./parsers.js";
8
- import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
8
+ import { redactBody, redactParams, redactPush, redactUrl, siteOf } from "./redact.js";
9
9
  import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
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, containerVersion } from "./containers.js";
13
+ import { callersOf, containerOf, containerVersion, isContainerId } 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, containerScripts: 500 };
39
+ const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500, containerScripts: 500, frameMessages: 1000 };
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.
@@ -106,11 +106,41 @@ const CONTAINER_HOOK = `
106
106
  new MutationObserver((list) => {
107
107
  for (const m of list) for (const n of m.addedNodes) {
108
108
  if (n instanceof HTMLScriptElement && n.src && !seen.has(n)) { seen.add(n); report(n, n.src, "parser"); }
109
+ // A preload hint downloads a container without running it; caught as it is added, since a page's own code
110
+ // can rewrite <head> and take it out again.
111
+ else if (n instanceof HTMLLinkElement && /(^|\\s)(preload|modulepreload|prefetch)(\\s|$)/i.test(n.rel) && n.href && !seen.has(n)) { seen.add(n); report(n, n.href, "preload"); }
109
112
  }
110
113
  }).observe(document, { childList: true, subtree: true });
111
114
  } catch {}
112
115
  `;
113
116
 
117
+ // Runs inside INIT_SCRIPT, in the page itself only: records each message an iframe posts to the page, through
118
+ // window.__atdMessage. Only a name and the field names are kept, never the values, since a form iframe's message
119
+ // can carry what the visitor typed. A message with nothing that reads as a name is not reported.
120
+ const MESSAGE_HOOK = `
121
+ if (window === window.top) {
122
+ const nameKeys = ["event", "eventName", "event_name", "type", "action", "name", "message"];
123
+ const summarize = (data) => {
124
+ let d = data;
125
+ if (typeof d === "string") {
126
+ try { d = JSON.parse(d); } catch {
127
+ return d.length <= 80 && /^[\\w.:\\- ]+$/.test(d) ? { name: d, keys: [] } : null;
128
+ }
129
+ }
130
+ if (!d || typeof d !== "object" || Array.isArray(d)) return null;
131
+ const key = nameKeys.find((k) => typeof d[k] === "string" && d[k] && d[k].length <= 80);
132
+ return key ? { name: d[key], nameKey: key, keys: Object.keys(d).slice(0, 20) } : null;
133
+ };
134
+ addEventListener("message", (e) => {
135
+ try {
136
+ if (e.source === window || !window.__atdMessage) return;
137
+ const info = summarize(e.data);
138
+ if (info) window.__atdMessage({ origin: String(e.origin), ...info });
139
+ } catch {}
140
+ }, true);
141
+ }
142
+ `;
143
+
114
144
  const INIT_SCRIPT = `(() => {
115
145
  const record = (entry, layer = "dataLayer") => {
116
146
  let value = entry;
@@ -172,6 +202,7 @@ const INIT_SCRIPT = `(() => {
172
202
  };
173
203
  watch("dataLayer");
174
204
  ${CONTAINER_HOOK}
205
+ ${MESSAGE_HOOK}
175
206
  })();`;
176
207
 
177
208
  // Labels the interactive elements of one frame with numeric refs, starting at
@@ -488,6 +519,17 @@ export function googleConsentStateInPage() {
488
519
  return { entries, usedDefault: Boolean(ics.usedDefault), usedUpdate: Boolean(ics.usedUpdate), wasSetLate: Boolean(ics.wasSetLate), waitPeriodTimedOut: Boolean(ics.waitPeriodTimedOut) };
489
520
  }
490
521
 
522
+ /**
523
+ * Runs in the page: the IDs Google's own registry (window.google_tag_manager) holds, which are the containers and
524
+ * Google tags that actually ran, however their script got in; and the addresses the page only preloads
525
+ * (<link rel="preload">, "modulepreload", "prefetch"), which download a script without running it.
526
+ */
527
+ export function containersInPage() {
528
+ const registry = window.google_tag_manager && typeof window.google_tag_manager === "object" ? Object.keys(window.google_tag_manager) : [];
529
+ const preloads = [...document.querySelectorAll('link[rel~="preload"], link[rel~="modulepreload"], link[rel~="prefetch"]')].map((l) => l.href).filter(Boolean);
530
+ return { registry: registry.slice(0, 200), preloads: preloads.slice(0, 200) };
531
+ }
532
+
491
533
  /**
492
534
  * Runs in the page: whether it is something other than the site, so what a step captured there says nothing about
493
535
  * the site's tracking. A bot challenge in its place (Cloudflare, DataDome, HUMAN, Akamai), or a page that crashed in
@@ -549,6 +591,12 @@ export class AuditSession {
549
591
  this.dataLayerLog = [];
550
592
  this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
551
593
  this.containerScripts = []; // each container script's answer: status, version, size and load time
594
+ this.containersRunning = new Map(); // container ID -> the step it was first seen running at (google_tag_manager)
595
+ this.containerPreloads = new Map(); // "id|host" -> { id, host, step } for containers the page only preloaded
596
+ this.frameMessages = []; // each named message an iframe posted to the page: origin, name and field names, never values
597
+ // The order hits, dataLayer pushes and iframe messages were recorded in. Several often share a millisecond, and
598
+ // telling which came first matters: a page's push answers the message just before it (see messageOutcomes).
599
+ this.seq = 0;
552
600
  this.network = [];
553
601
  this.providers = resolveProviders(this.opts.providers);
554
602
  this.landing = null; // the first page: { step, requested, chain, final }
@@ -593,8 +641,9 @@ export class AuditSession {
593
641
  const frame = frameInfo(source?.frame);
594
642
  if (frame) entry = { ...entry, frame };
595
643
  this.lastTrackingAt = Date.now();
596
- this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
644
+ this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now(), seq: ++this.seq });
597
645
  });
646
+ await this.context.exposeBinding("__atdMessage", (source, m) => this.recordMessage(m, source?.frame));
598
647
  await this.context.exposeBinding("__atdLoad", (source, entry) => this.recordLoad(entry, frameInfo(source?.frame)));
599
648
  await this.context.addInitScript(INIT_SCRIPT);
600
649
  this.page = await this.context.newPage();
@@ -626,7 +675,7 @@ export class AuditSession {
626
675
  const frame = this.frameOf(request);
627
676
  for (const hit of hits) {
628
677
  if (!this.checks(hit.platform)) continue;
629
- const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now(), ...(frame ? { frame } : {}) };
678
+ const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now(), seq: ++this.seq, ...(frame ? { frame } : {}) };
630
679
  const late = this.lateFor(hit, entry);
631
680
  if (late) Object.assign(entry, late);
632
681
  if (this.record("hits", entry)) recorded.push(entry);
@@ -837,6 +886,12 @@ export class AuditSession {
837
886
  recordLoad(entry, frame = null) {
838
887
  const container = containerOf(entry?.src);
839
888
  if (!container) return;
889
+ // A preload hint is not a load: it downloads the script without running it (see unseenContainers).
890
+ if (entry.how === "preload") {
891
+ const k = `${container.id}|${container.host}`;
892
+ if (!frame && !this.containerPreloads.has(k)) this.containerPreloads.set(k, { id: container.id, host: container.host, step: this.currentStep, page: pagePath(entry.page) });
893
+ return;
894
+ }
840
895
  this.record("containerLoads", {
841
896
  ...container,
842
897
  how: entry.how === "parser" ? "parser" : "script",
@@ -874,6 +929,28 @@ export class AuditSession {
874
929
  });
875
930
  }
876
931
 
932
+ /**
933
+ * A message an iframe posted to the page: the iframe's origin, whether it is on the site's own site, the name the
934
+ * message carries and the names of its fields. The page's main frame reports it; values never leave the page.
935
+ */
936
+ recordMessage(m, frame) {
937
+ if (!m || (frame && frame !== this.page?.mainFrame())) return;
938
+ let party = "third";
939
+ try {
940
+ party = siteOf(new URL(m.origin).hostname) === siteOf(new URL(this.page.url()).hostname) ? "site" : "third";
941
+ } catch { /* an opaque origin ("null"): a sandboxed iframe, someone else's */ }
942
+ this.record("frameMessages", {
943
+ origin: String(m.origin).slice(0, 200),
944
+ party,
945
+ name: String(m.name ?? "").slice(0, 80),
946
+ ...(m.nameKey ? { nameKey: String(m.nameKey).slice(0, 20) } : {}),
947
+ keys: (Array.isArray(m.keys) ? m.keys : []).slice(0, 20).map((k) => String(k).slice(0, 40)),
948
+ step: this.currentStep,
949
+ t: Date.now(),
950
+ seq: ++this.seq,
951
+ });
952
+ }
953
+
877
954
  /** The iframe a request came from, as frameInfo gives it; null for the page itself or a worker. */
878
955
  frameOf(request) {
879
956
  try {
@@ -1185,6 +1262,15 @@ export class AuditSession {
1185
1262
  const tagConsent = await this.evaluateWithin(googleConsentStateInPage);
1186
1263
  const before = this.steps.slice(0, this.currentStep).map((st) => st.tagConsent).filter(Boolean).at(-1);
1187
1264
  if (tagConsent && JSON.stringify(tagConsent) !== JSON.stringify(before)) this.steps[this.currentStep].tagConsent = tagConsent;
1265
+ // Which containers ran, and which the page only preloaded: a container can download and never run.
1266
+ const found = await this.evaluateWithin(containersInPage);
1267
+ for (const id of (found?.registry ?? []).map((k) => String(k).toUpperCase()).filter(isContainerId)) {
1268
+ if (!this.containersRunning.has(id)) this.containersRunning.set(id, this.currentStep);
1269
+ }
1270
+ for (const c of (found?.preloads ?? []).map(containerOf).filter(Boolean)) {
1271
+ const k = `${c.id}|${c.host}`;
1272
+ if (!this.containerPreloads.has(k)) this.containerPreloads.set(k, { id: c.id, host: c.host, step: this.currentStep, page: pagePath(this.page.url()) });
1273
+ }
1188
1274
  }
1189
1275
 
1190
1276
  async perform(a) {
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, scriptFailed, splitLayers } from "./containers.js";
9
- import { widgetSummaries, widgetsOf } from "./frames.js";
8
+ import { containersNotRun, duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
9
+ import { messageOutcomes, 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"]);
@@ -394,6 +394,15 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
394
394
  findings.push({ ...base, severity: "warning", check: "container_failed", step: stepLabel(sc.step), detail: d.containerFailed(sc.id, sc.host, String(sc.status)) });
395
395
  }
396
396
 
397
+ // A GTM container downloaded or asked for that never ran. Expected while a consent platform holds it back and the
398
+ // run never consented; otherwise none of its tags fired.
399
+ const consented = consentGrantedStep(run) !== null;
400
+ for (const c of containersNotRun(run)) {
401
+ findings.push(run.cmp?.vendor && !consented
402
+ ? { ...base, severity: "info", check: "container_held", step: stepLabel(c.step), detail: d.containerHeld(c.id, run.cmp.vendor) }
403
+ : { ...base, severity: "warning", check: "container_not_run", step: stepLabel(c.step), detail: d.containerNotRun(c.id, c.kind) });
404
+ }
405
+
397
406
  // Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
398
407
  for (const w of widgetsOf(run.widgetHits ?? [])) {
399
408
  const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
@@ -403,6 +412,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
403
412
  for (const w of widgetSummaries([run], (a, b) => canonicalEvent(a) === canonicalEvent(b)).filter((x) => x.unshared.length)) {
404
413
  findings.push({ ...base, severity: "info", check: "widget_events_unshared", step: stepLabel(w.step), detail: d.widgetEventsUnshared(w.origin, w.unshared.slice(0, 10)) });
405
414
  }
415
+ // An event an iframe posted to the page that the page never acted on: no push, no hit, not even under another name.
416
+ const unhandled = new Set();
417
+ for (const msg of messageOutcomes(run, (a, b) => canonicalEvent(a) === canonicalEvent(b)).filter((x) => x.outcome === "nothing")) {
418
+ const k = `${msg.origin}|${canonicalEvent(msg.name)}`;
419
+ if (unhandled.has(k)) continue;
420
+ unhandled.add(k);
421
+ findings.push({ ...base, severity: "info", check: "iframe_message_unhandled", event: msg.name, step: stepLabel(msg.step), detail: d.iframeMessageUnhandled(msg.origin, msg.name, msg.party) });
422
+ }
406
423
  // A container in a same-site iframe beside one in the page: two documents, two dataLayers.
407
424
  const iframeLoad = loads.find((l) => l.frame?.party === "site" && loads.some((t) => !t.frame && t.step <= l.step));
408
425
  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
@@ -7,6 +7,9 @@
7
7
  // Container and destination IDs gtm.js and gtag/js are loaded with.
8
8
  const CONTAINER_ID = /^(GTM|G|GT|AW|DC|MC|HA)-[A-Z0-9]+$/;
9
9
 
10
+ /** Whether a string is a container or Google tag ID: GTM-…, G-…, GT-…, AW-…, DC-… and the like. */
11
+ export const isContainerId = (id) => CONTAINER_ID.test(String(id));
12
+
10
13
  /**
11
14
  * The container a script URL loads, or null: its ID, gtm or gtag, the dataLayer it reads (`l`), the host it
12
15
  * came from (Google's, or the site's own through the Google tag gateway or a server container) and the GTM
@@ -148,13 +151,16 @@ export function loadSources(run) {
148
151
 
149
152
  /**
150
153
  * One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
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.
154
+ * environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, ran?, pages, journeys }. frame is
155
+ * the iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the
156
+ * row; ran says whether Google's registry held it on some step (left out for captures that didn't record it).
157
+ * Containers the page asked for without the tool seeing a script added are rows too (see unseenContainers).
153
158
  */
154
159
  export function containerRows(runs) {
155
160
  const rows = new Map();
156
161
  for (const run of runs) {
157
- for (const l of loadSources(run)) {
162
+ const ran = Array.isArray(run.containersRunning) ? new Set(run.containersRunning.map((c) => c.id)) : null;
163
+ for (const l of [...loadSources(run), ...unseenContainers(run)]) {
158
164
  const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? "", l.frame?.origin ?? ""].join("|");
159
165
  const row = rows.get(k) ?? {
160
166
  id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
@@ -165,12 +171,63 @@ export function containerRows(runs) {
165
171
  };
166
172
  if (l.page) row.pages.add(l.page);
167
173
  row.journeys.add(`${run.journeyId} (${run.viewport})`);
174
+ // Whether it ran (Google's registry held it) in any run that recorded the registry; unknown for older captures.
175
+ if (ran) row.ran = Boolean(row.ran) || ran.has(l.id);
168
176
  rows.set(k, row);
169
177
  }
170
178
  }
171
179
  return [...rows.values()].map((r) => ({ ...r, pages: [...r.pages].sort(), journeys: [...r.journeys] }));
172
180
  }
173
181
 
182
+ /**
183
+ * Containers a run met without seeing a script added for them: a script the page requested or preloaded, or an ID
184
+ * that ran (Google's registry held it), with no load recorded. kind "preload": only a <link rel="preload"> asked for
185
+ * it, which downloads a script without running it; "unseen": something added it in a way the tool couldn't follow,
186
+ * as when a consent platform's auto-blocking replaces the browser's own functions. One per container and host.
187
+ */
188
+ export function unseenContainers(run) {
189
+ const loads = run.containerLoads ?? [];
190
+ const loaded = (id, host) => loads.some((l) => l.id === id && (!host || l.host === host));
191
+ const preloads = run.containerPreloads ?? [];
192
+ const out = new Map();
193
+ // The page a step was on: the preload's own, else the step's (see pagePath in browser.js).
194
+ const pageAt = (step, page) => page ?? run.steps?.[step]?.pagePath ?? undefined;
195
+ const add = (id, host, kind, step, facts = {}, page) => {
196
+ const k = `${id}|${host ?? ""}`;
197
+ if (out.has(k) || loaded(id, host)) return;
198
+ const at = pageAt(step, page);
199
+ out.set(k, {
200
+ id, script: id.startsWith("GTM-") ? "gtm" : "gtag", layer: null,
201
+ ...(host ? { host, served: /(^|\.)googletagmanager\.com$/.test(host) ? "google" : "site" } : {}),
202
+ kind, step, ...(at ? { page: at } : {}), ...facts,
203
+ });
204
+ };
205
+ for (const sc of run.containerScripts ?? []) {
206
+ const pre = preloads.find((p) => p.id === sc.id && p.host === sc.host);
207
+ add(sc.id, sc.host, pre ? "preload" : "unseen", pre?.step ?? sc.step, scriptFacts(sc), pre?.page);
208
+ }
209
+ for (const p of preloads) add(p.id, p.host, "preload", p.step, {}, p.page);
210
+ for (const c of run.containersRunning ?? []) {
211
+ if (!loaded(c.id) && ![...out.values()].some((x) => x.id === c.id)) add(c.id, null, "unseen", c.step);
212
+ }
213
+ return [...out.values()];
214
+ }
215
+
216
+ /**
217
+ * GTM containers a run downloaded or asked for that never ran: Google's registry never held them on any step. Only
218
+ * GTM containers, whose registry entry is certain; [] for captures that didn't record the registry.
219
+ */
220
+ export function containersNotRun(run) {
221
+ if (!Array.isArray(run.containersRunning)) return [];
222
+ const ran = new Set(run.containersRunning.map((c) => c.id));
223
+ const seen = new Map();
224
+ for (const l of [...loadSources(run), ...unseenContainers(run)]) {
225
+ if (!l.id.startsWith("GTM-") || ran.has(l.id) || l.failed || l.frame?.party === "third" || seen.has(l.id)) continue;
226
+ seen.set(l.id, { id: l.id, kind: l.kind, step: l.step ?? 0 });
227
+ }
228
+ return [...seen.values()];
229
+ }
230
+
174
231
  /** The containers loaded more than once into one page, as [{ id, loads }] with the loads in order. */
175
232
  export function duplicateLoads(run) {
176
233
  const byDoc = new Map();
package/src/frames.js CHANGED
@@ -154,3 +154,66 @@ export function widgetSummaries(runs, same = (a, b) => plainName(a) === plainNam
154
154
  };
155
155
  });
156
156
  }
157
+
158
+ // A message counts as an event when it is dataLayer-shaped ({ event: ... }) or its name reads like one; frame
159
+ // housekeeping (resizing, ready and heartbeat pings, scroll and focus) never does.
160
+ const EVENT_KEYS = new Set(["event", "eventName", "event_name"]);
161
+ const EVENT_WORDS = /(submit|complete|success|confirm|purchase|order|lead|book|reserv|sign.?up|register|checkout|payment|paid|conversion|subscribe|contact|quote|enquir|inquir|apply|download|add.?to.?cart)/i;
162
+ const HOUSEKEEPING = /^(resize|height|setheight|size|ready|loaded|load|init|initialized|heartbeat|ping|pong|scroll|focus|blur|close|open|visible|hidden|iframeresizer.*|.*resize.*)$/i;
163
+
164
+ /** Whether an iframe's message reads as an event the page might need to track. */
165
+ export function isEventMessage(m) {
166
+ const name = String(m.name ?? "");
167
+ if (!name || HOUSEKEEPING.test(name.replace(/[^a-z0-9]/gi, ""))) return false;
168
+ return EVENT_KEYS.has(m.nameKey) || EVENT_WORDS.test(name);
169
+ }
170
+
171
+ // How long after a message the page has to react for the reaction to count as its relay under another name.
172
+ export const RELAY_WINDOW_MS = 3000;
173
+
174
+ /**
175
+ * What the page did after each event message an iframe posted to it, one per iframe, name and step:
176
+ * { origin, party, name, keys, step, t, outcome, relay? }. outcome "relayed": the page pushed or sent the same event
177
+ * (relay: { kind: "push"|"hit", name, ms }); "followed": within RELAY_WINDOW_MS it pushed another event or sent a hit,
178
+ * maybe the same event under another name; "nothing": neither. Only the page's own pushes and the site's own hits
179
+ * count (not an iframe's). same(a, b) compares event names, as in widgetSummaries.
180
+ *
181
+ * Each reaction of the page answers one message at most, so one iframe's relayed event never makes another's look
182
+ * handled. A reaction carrying a message's own name belongs to that message. Any other belongs to the latest message
183
+ * before it, since a page's listener reacts as the message arrives; when that message already has its answer, the
184
+ * reaction is a second effect of it (the hit after a push) and answers nothing else.
185
+ */
186
+ export function messageOutcomes(run, same = (a, b) => plainName(a) === plainName(b)) {
187
+ // In recording order: seq where both have one (several often share a millisecond), else the time.
188
+ const order = (a, b) => (a.seq != null && b.seq != null ? a.seq - b.seq : (a.t ?? 0) - (b.t ?? 0));
189
+ const pushes = (run.dataLayerLog ?? []).filter((e) => !e.frame && pushedEvent(e.value)).map((e) => ({ kind: "push", name: pushedEvent(e.value), t: e.t ?? 0, seq: e.seq }));
190
+ const hits = (run.hits ?? []).filter((h) => !h.frame).map((h) => ({ kind: "hit", name: h.name, t: h.t ?? 0, seq: h.seq }));
191
+ const reactions = [...pushes, ...hits].sort(order);
192
+ const seen = new Set();
193
+ const messages = [];
194
+ for (const m of [...(run.frameMessages ?? [])].sort(order)) {
195
+ if (!isEventMessage(m)) continue;
196
+ const k = `${m.origin}|${m.name}|${m.step}`;
197
+ if (seen.has(k)) continue;
198
+ seen.add(k);
199
+ messages.push({ m, relay: null, outcome: "nothing" });
200
+ }
201
+ const used = new Set();
202
+ for (const msg of messages) {
203
+ const r = reactions.find((x) => !used.has(x) && order(x, msg.m) >= 0 && same(x.name, msg.m.name));
204
+ if (!r) continue;
205
+ Object.assign(msg, { relay: r, outcome: "relayed" });
206
+ used.add(r);
207
+ }
208
+ for (const r of reactions) {
209
+ if (used.has(r)) continue;
210
+ const latest = messages.filter((x) => order(x.m, r) <= 0).at(-1);
211
+ if (!latest || latest.relay || r.t - latest.m.t > RELAY_WINDOW_MS) continue;
212
+ Object.assign(latest, { relay: r, outcome: "followed" });
213
+ used.add(r);
214
+ }
215
+ return messages.map(({ m, relay, outcome }) => ({
216
+ origin: m.origin, party: m.party, name: m.name, keys: m.keys ?? [], step: m.step, t: m.t, outcome,
217
+ ...(relay ? { relay: { kind: relay.kind, name: relay.name, ms: Math.max(0, relay.t - m.t) } } : {}),
218
+ }));
219
+ }
package/src/html/scope.js CHANGED
@@ -4,7 +4,7 @@
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";
7
+ import { messageOutcomes, widgetSummaries } from "../frames.js";
8
8
  import { canonicalEvent } from "../checks.js";
9
9
  import { providerOf } from "../parsers.js";
10
10
  import { siteOf } from "../redact.js";
@@ -202,7 +202,7 @@ function containersCard(ctx) {
202
202
  const rows = containerRows(runs);
203
203
  if (!rows.length) return null;
204
204
  const c = m.ui.containers;
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("");
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>${r.ran === false ? badge("broken", c.neverRan) : ""}${r.layer ? plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`) : ""}${r.served ? 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("");
206
206
  const ids = uniq(rows.map((r) => r.id));
207
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>`;
208
208
  }
@@ -228,6 +228,19 @@ ${x.unshared.length ? line(w.unshared, x.unshared.map(code).join(" ")) : ""}</ul
228
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
229
  }
230
230
 
231
+ /** The events iframes posted to the page and what the page did next, one line each. Null when there were none. */
232
+ function messagesCard(ctx) {
233
+ const { m, runs } = ctx;
234
+ const same = (a, b) => canonicalEvent(a) === canonicalEvent(b);
235
+ const list = runs.flatMap((r) => messageOutcomes(r, same).map((x) => ({ ...x, run: r })));
236
+ if (!list.length) return null;
237
+ const t = m.ui.messages;
238
+ const word = (x) => (x.outcome === "nothing" ? t.nothing : t[x.outcome](x.relay.kind, x.relay.name, x.relay.ms));
239
+ const items = list.map((x) => `<li>${code(x.name)}<span class="inv-n">${esc(x.origin)}</span>${plain(esc(m.ui.party[x.party === "site" ? "first" : "third"]))}${x.outcome === "nothing" ? badge("info", word(x)) : plain(esc(word(x)))}<span class="inv-n">${esc(`${x.run.journeyId} · ${x.run.steps?.[x.step]?.label ?? x.step}`)}</span></li>`).join("");
240
+ const silent = list.filter((x) => x.outcome === "nothing").length;
241
+ return `<li><details class="card inv" id="inv-messages"><summary><span class="t">${esc(m.ui.messagesTitle)}</span>${plain(n(list.length))}${silent ? badge("info", `${t.nothing.split(":")[0]}: ${silent}`) : ""}</summary><div class="body"><p class="xs muted">${esc(m.ui.messagesIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
242
+ }
243
+
231
244
  function inventorySection(ctx) {
232
245
  const { m, runs } = ctx;
233
246
  const t = m.html.scope;
@@ -235,7 +248,8 @@ function inventorySection(ctx) {
235
248
  const skipped = skippedTotals(runs);
236
249
  const containers = containersCard(ctx);
237
250
  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>`);
251
+ const messages = messagesCard(ctx);
252
+ if (!lines.length && !skipped.length && !containers && !widgets && !messages) return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<p class="muted">${esc(m.ui.noInventory)}</p>`);
239
253
  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>`;
240
254
  const out = lines.map((x) => {
241
255
  const id = `inv-${safeKey(x.p)}`;
@@ -251,6 +265,7 @@ function inventorySection(ctx) {
251
265
  <div role="tabpanel" id="${tid("pages")}" data-label="${esc(m.ui.pagesCol)}">${pages ? `<ul class="inv-list">${pages}</ul>` : none}</div>
252
266
  <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>`;
253
267
  });
268
+ if (messages) out.unshift(messages);
254
269
  if (widgets) out.unshift(widgets);
255
270
  if (containers) out.unshift(containers);
256
271
  const skippedN = skipped.reduce((a, [, k]) => a + k, 0);
package/src/i18n.js CHANGED
@@ -217,6 +217,16 @@ const en = {
217
217
  ownCol: "Own tracking",
218
218
  sharedCol: "To the site's accounts",
219
219
  pushesCol: "dataLayer events",
220
+ messagesTitle: "Messages from iframes",
221
+ messagesIntro: "Events iframes posted to the page with postMessage, such as a form or checkout telling the page it is done, and what the page did next: pushed or sent the same event, did something else within 3 seconds (perhaps the same event under another name), or nothing.",
222
+ messageCol: "Message",
223
+ fromCol: "From",
224
+ outcomeCol: "What the page did",
225
+ messages: {
226
+ relayed: (kind, name, ms) => `${kind === "push" ? "pushed" : "sent"} ${name} ${ms} ms later`,
227
+ followed: (kind, name, ms) => `${kind === "push" ? "pushed" : "sent"} ${name} ${ms} ms later (another name)`,
228
+ nothing: "nothing: no dataLayer push or hit followed",
229
+ },
220
230
  widgets: {
221
231
  containers: "Containers",
222
232
  unshared: "Not sent to the site's accounts",
@@ -234,6 +244,7 @@ const en = {
234
244
  size: (bytes) => `${(bytes / 1024).toFixed(1)} KB`,
235
245
  loadTime: (ms) => `loaded in ${ms} ms`,
236
246
  failed: (status) => (status === "failed" ? "failed to load" : `failed to load (HTTP ${status})`),
247
+ neverRan: "never ran",
237
248
  kinds: {
238
249
  html: () => "A script tag in the page's HTML",
239
250
  inline: () => "Inline code in the page (the snippet)",
@@ -244,6 +255,8 @@ const en = {
244
255
  config: (by) => `A gtag('config') command, loaded by ${by}`,
245
256
  send_to: (by) => `An event's send_to with no config, loaded by ${by}`,
246
257
  destination: (by) => `A destination of the Google tag ${by}`,
258
+ preload: () => "Only a preload hint in the page (<link rel=\"preload\">): downloaded, never added as a script",
259
+ unseen: () => "Not seen being added: a script on the page may have replaced the browser's own functions, as consent platforms that block scripts do",
247
260
  },
248
261
  },
249
262
  provider: "Provider",
@@ -375,7 +388,10 @@ const en = {
375
388
  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.`,
376
389
  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
390
  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.`,
391
+ containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "was downloaded because of a preload hint in the page, but nothing added it as a script, and it" : "was loaded but"} never ran on any page of this run: Google's own registry never held it, so none of its tags fired.`,
392
+ containerHeld: (id, vendor) => `${id} never ran, which is expected while ${vendor} holds it back until the visitor consents. This run never consented, so its tags weren't checked.`,
378
393
  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.`,
394
+ iframeMessageUnhandled: (origin, name, party) => `${party === "site" ? "An iframe on the site" : "An embedded widget"} from ${origin} posted ${q(name)} to the page, but the page pushed nothing to its dataLayer and sent no hit in the 3 seconds after, nor ${q(name)} later. If it is a conversion, the page isn't relaying it to its tags.`,
379
395
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
380
396
  consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
381
397
  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.",
@@ -438,7 +454,10 @@ const en = {
438
454
  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.",
439
455
  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
456
  container_failed: "None of the container's tags ran: page views, conversions and consent signals it sends were all lost on that page.",
457
+ container_not_run: "None of the container's tags fired: page views, conversions and remarketing were all lost wherever it never ran.",
458
+ container_held: "The container's tags weren't checked on this run, since it waited for a consent the run never gave.",
441
459
  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.",
460
+ iframe_message_unhandled: "A conversion completed in the iframe, such as a form sent or a booking made, can go untracked although the iframe told the page about it.",
442
461
  campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
443
462
  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.",
444
463
  event_id_mismatch: "The browser and server copies of this event carry different IDs, so Meta cannot pair them and counts the conversion twice.",
@@ -488,7 +507,10 @@ const en = {
488
507
  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.",
489
508
  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
509
  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.",
510
+ container_not_run: "Check that something actually adds the container's script, the snippet or the framework component that loads it (a preload alone runs nothing); look for script errors that stop the page's own code, and for a consent platform that never releases it.",
511
+ container_held: "Run the journey with consent accepted, or the consent scenarios, to check the container's tags.",
491
512
  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.",
513
+ iframe_message_unhandled: "Add a message listener on the page that checks event.origin and pushes the event to the dataLayer, so a GTM trigger can fire on it; or have the iframe send the event to the site's accounts itself.",
492
514
  campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
493
515
  event_id_reused: "Generate a new event ID for every event, not once per page load.",
494
516
  event_id_mismatch: "Use one event ID for the pixel event and the server copy: pass the same value to both tags.",
@@ -538,7 +560,10 @@ const en = {
538
560
  widget_tracking: "hygiene",
539
561
  iframe_container: "hygiene",
540
562
  container_failed: "money",
563
+ container_not_run: "money",
564
+ container_held: "hygiene",
541
565
  widget_events_unshared: "money",
566
+ iframe_message_unhandled: "money",
542
567
  campaign_params: "money",
543
568
  event_id_reused: "money",
544
569
  event_id_mismatch: "money",
@@ -1072,6 +1097,16 @@ const tr = {
1072
1097
  ownCol: "Kendi izlemesi",
1073
1098
  sharedCol: "Sitenin hesaplarına",
1074
1099
  pushesCol: "dataLayer olayları",
1100
+ messagesTitle: "iframe'lerden gelen mesajlar",
1101
+ messagesIntro: "iframe'lerin postMessage ile sayfaya gönderdiği olaylar (örneğin bir formun ya da ödemenin sayfaya bittiğini söylemesi) ve sayfanın ardından ne yaptığı: aynı olayı dataLayer'a gönderdi ya da iletti, 3 saniye içinde başka bir şey yaptı (belki aynı olayı başka bir adla) ya da hiçbir şey yapmadı.",
1102
+ messageCol: "Mesaj",
1103
+ fromCol: "Gönderen",
1104
+ outcomeCol: "Sayfanın yaptığı",
1105
+ messages: {
1106
+ relayed: (kind, name, ms) => `${ms} ms sonra ${name} ${kind === "push" ? "dataLayer'a gönderdi" : "isteğini gönderdi"}`,
1107
+ followed: (kind, name, ms) => `${ms} ms sonra ${name} ${kind === "push" ? "dataLayer'a gönderdi" : "isteğini gönderdi"} (başka bir ad)`,
1108
+ nothing: "hiçbir şey: ardından ne dataLayer push'u ne de istek geldi",
1109
+ },
1075
1110
  widgets: {
1076
1111
  containers: "Container'lar",
1077
1112
  unshared: "Sitenin hesaplarına gönderilmeyen",
@@ -1089,6 +1124,7 @@ const tr = {
1089
1124
  size: (bytes) => `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`,
1090
1125
  loadTime: (ms) => `${ms} ms'de yüklendi`,
1091
1126
  failed: (status) => (status === "failed" ? "yüklenemedi" : `yüklenemedi (HTTP ${status})`),
1127
+ neverRan: "hiç çalışmadı",
1092
1128
  kinds: {
1093
1129
  html: () => "Sayfanın HTML'indeki bir script etiketi",
1094
1130
  inline: () => "Sayfadaki satır içi kod (snippet)",
@@ -1099,6 +1135,8 @@ const tr = {
1099
1135
  config: (by) => `Bir gtag('config') komutu, ${by} yükledi`,
1100
1136
  send_to: (by) => `config olmadan bir olayın send_to değeri, ${by} yükledi`,
1101
1137
  destination: (by) => `${by} Google etiketinin bir hedefi`,
1138
+ preload: () => "Sayfada yalnızca bir ön yükleme ipucu (<link rel=\"preload\">): indirildi, hiç betik olarak eklenmedi",
1139
+ unseen: () => "Eklendiği görülmedi: sayfadaki bir betik tarayıcının kendi işlevlerini değiştirmiş olabilir; betikleri engelleyen onay platformlarının yaptığı gibi",
1102
1140
  },
1103
1141
  },
1104
1142
  provider: "Sağlayıcı",
@@ -1226,7 +1264,10 @@ const tr = {
1226
1264
  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.`,
1227
1265
  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
1266
  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ı.`,
1267
+ containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "sayfadaki bir ön yükleme ipucu yüzünden indirildi, ama hiçbir şey onu betik olarak eklemedi ve" : "yüklendi ama"} bu çalıştırmanın hiçbir sayfasında çalışmadı: Google'ın kendi kaydı onu hiç tutmadı, bu yüzden etiketlerinin hiçbiri çalışmadı.`,
1268
+ containerHeld: (id, vendor) => `${id} hiç çalışmadı; ${vendor} onu ziyaretçi onay verene kadar beklettiği sürece bu beklenen bir durum. Bu çalıştırmada onay hiç verilmedi, bu yüzden etiketleri kontrol edilmedi.`,
1229
1269
  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.`,
1270
+ iframeMessageUnhandled: (origin, name, party) => `${origin} kaynaklı ${party === "site" ? "sitedeki bir iframe" : "gömülü bir widget"} sayfaya ${q(name)} gönderdi, ancak sayfa ardından 3 saniye içinde dataLayer'a hiçbir şey göndermedi ve istek yollamadı, sonradan ${q(name)} da göndermedi. Bu bir dönüşümse sayfa onu etiketlerine iletmiyor.`,
1230
1271
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
1231
1272
  consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
1232
1273
  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.",
@@ -1288,7 +1329,10 @@ const tr = {
1288
1329
  widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
1289
1330
  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
1331
  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.",
1332
+ container_not_run: "Container'ın etiketlerinin hiçbiri çalışmadı: hiç çalışmadığı her yerde sayfa görüntülemeleri, dönüşümler ve yeniden pazarlama kayboldu.",
1333
+ container_held: "Container onay beklediği ve bu çalıştırma onay vermediği için etiketleri kontrol edilmedi.",
1291
1334
  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.",
1335
+ iframe_message_unhandled: "iframe içinde tamamlanan bir dönüşüm (gönderilen bir form, yapılan bir rezervasyon), iframe sayfaya bildirdiği hâlde izlenmeyebilir.",
1292
1336
  campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
1293
1337
  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.",
1294
1338
  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.",
@@ -1336,7 +1380,10 @@ const tr = {
1336
1380
  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.",
1337
1381
  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
1382
  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.",
1383
+ container_not_run: "Container betiğini gerçekten bir şeyin eklediğini kontrol edin: snippet ya da onu yükleyen çatı bileşeni (yalnızca ön yükleme hiçbir şeyi çalıştırmaz); sayfanın kendi kodunu durduran betik hatalarına ve onu hiç serbest bırakmayan bir onay platformuna bakın.",
1384
+ container_held: "Container'ın etiketlerini kontrol etmek için yolculuğu onay kabul edilmiş olarak ya da onay senaryolarıyla çalıştırın.",
1339
1385
  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.",
1386
+ iframe_message_unhandled: "Sayfaya event.origin'i kontrol eden ve olayı dataLayer'a gönderen bir message dinleyicisi ekleyin, böylece bir GTM tetikleyicisi onunla çalışabilir; ya da iframe'in olayı sitenin hesaplarına kendisi göndermesini sağlayın.",
1340
1387
  campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
1341
1388
  event_id_reused: "Her olay için yeni bir olay kimliği üretin; sayfa yüklemesi başına bir kez değil.",
1342
1389
  event_id_mismatch: "Piksel olayı ve sunucu kopyası için tek bir olay kimliği kullanın: aynı değeri iki etikete de verin.",
@@ -1385,7 +1432,10 @@ const tr = {
1385
1432
  widget_tracking: "hygiene",
1386
1433
  iframe_container: "hygiene",
1387
1434
  container_failed: "money",
1435
+ container_not_run: "money",
1436
+ container_held: "hygiene",
1388
1437
  widget_events_unshared: "money",
1438
+ iframe_message_unhandled: "money",
1389
1439
  campaign_params: "money",
1390
1440
  event_id_reused: "money",
1391
1441
  event_id_mismatch: "money",
package/src/mcp.js CHANGED
@@ -33,8 +33,8 @@ 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";
37
- import { isWidgetHit, pushedEvent, siteIds, splitRun, widgetSummaries } from "./frames.js";
36
+ import { containerRows, containersNotRun, loadSources, unseenContainers } from "./containers.js";
37
+ import { isEventMessage, isWidgetHit, messageOutcomes, 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
 
@@ -192,6 +192,8 @@ export function stepCapture(audit, step) {
192
192
  if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
193
193
  const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
194
194
  if (loads.length) lines.push("Google containers loaded:", ...loads.map(describeLoad));
195
+ const posted = (session.frameMessages ?? []).filter((x) => x.step === step);
196
+ if (posted.length) lines.push("Messages iframes posted to the page (name and field names only):", ...posted.map((x) => ` ${x.name} from ${x.origin} (${x.party === "site" ? "same site" : "third party"})${x.keys?.length ? `, fields ${x.keys.join(", ")}` : ""}${isEventMessage(x) ? "" : "; housekeeping, not checked"}`));
195
197
  lines.push(...cookieLines(session, step));
196
198
  const tag = session.steps[step]?.tagConsent;
197
199
  if (tag) {
@@ -242,7 +244,7 @@ export function hitDetail(audit, step, index) {
242
244
  function containerJson(l) {
243
245
  return {
244
246
  id: l.id, source: l.kind, ...(l.by ? { by: l.by } : {}),
245
- ...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
247
+ ...(l.layer && l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
246
248
  ...(l.served === "site" ? { served_from: l.host } : {}),
247
249
  ...(l.environment ? { gtm_environment: l.environment } : {}),
248
250
  ...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
@@ -251,6 +253,7 @@ function containerJson(l) {
251
253
  ...(l.transferBytes ? { transfer_bytes: l.transferBytes } : {}),
252
254
  ...(l.ms != null ? { load_ms: l.ms } : {}),
253
255
  ...(l.failed ? { failed: l.failed } : {}),
256
+ ...(typeof l.ran === "boolean" ? { ran: l.ran } : {}),
254
257
  ...(l.pages ? { pages: l.pages } : {}),
255
258
  };
256
259
  }
@@ -263,7 +266,7 @@ export function containerLines(runs) {
263
266
  return [
264
267
  "Google containers (how each reached the page):",
265
268
  ...rows.map((r) => {
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);
269
+ const extra = [scriptCell(c, r), r.layer && 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);
267
270
  return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
268
271
  }),
269
272
  ];
@@ -272,6 +275,17 @@ export function containerLines(runs) {
272
275
  /** The embedded widgets of the runs as summaries (see widgetSummaries), compared by canonical event name. */
273
276
  const widgetsIn = (runs) => widgetSummaries(runs, (a, b) => canonicalEvent(a) === canonicalEvent(b));
274
277
 
278
+ /** The events iframes posted to the page, with what the page did next, as lines for a tool result; [] when none. */
279
+ export function messageLines(runs) {
280
+ const t = messages("en").ui.messages;
281
+ const list = runs.flatMap((r) => messageOutcomes(r, (a, b) => canonicalEvent(a) === canonicalEvent(b)));
282
+ if (!list.length) return [];
283
+ return [
284
+ "Messages from iframes (events posted to the page with postMessage, and what the page did next):",
285
+ ...list.map((x) => ` ${x.name} from ${x.origin} (${x.party === "site" ? "same site" : "third party"}), step ${x.step}: ${x.outcome === "nothing" ? t.nothing : t[x.outcome](x.relay.kind, x.relay.name, x.relay.ms)}`),
286
+ ];
287
+ }
288
+
275
289
  /** The embedded widgets as lines for a tool result; [] when there were none. */
276
290
  export function widgetLines(runs) {
277
291
  const widgets = widgetsIn(runs);
@@ -303,6 +317,8 @@ export function timelineOf(run) {
303
317
  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 } : {}) }));
304
318
  // An embedded widget's own hits, kept apart from the site's (see src/frames.js).
305
319
  const widget = (run.widgetHits ?? []).filter((h) => h.step === st.index);
320
+ const posted = messageOutcomes({ ...run, frameMessages: (run.frameMessages ?? []).filter((x) => x.step === st.index) }, (a, b) => canonicalEvent(a) === canonicalEvent(b));
321
+ if (posted.length) entry.iframe_messages = posted.map((x) => ({ from: x.origin, name: x.name, outcome: x.outcome, ...(x.relay ? { relay: x.relay } : {}) }));
306
322
  const widgetPushes = (run.widgetDataLayerLog ?? []).filter((e) => e.step === st.index && e.frame && pushedEvent(e.value));
307
323
  if (widgetPushes.length) entry.widget_pushes = widgetPushes.map((e) => ({ widget: e.frame.origin, event: pushedEvent(e.value) }));
308
324
  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 } : {}) }));
@@ -560,10 +576,20 @@ export function buildServer(opts) {
560
576
  },
561
577
  async ({ audit_id }) => {
562
578
  const { session } = getAudit(audit_id);
563
- const loads = loadSources({ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
564
- if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
579
+ const run = {
580
+ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog,
581
+ containerPreloads: [...(session.containerPreloads?.values() ?? [])],
582
+ containersRunning: [...(session.containersRunning ?? [])].map(([id, step]) => ({ id, step })),
583
+ };
584
+ const loads = loadSources(run);
585
+ const unseen = unseenContainers(run);
586
+ const notRun = containersNotRun(run);
587
+ if (!loads.length && !unseen.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
588
+ const c = messages("en").ui.containers;
565
589
  const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
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) })))}`);
590
+ if (unseen.length) lines.push("Asked for without a script being seen added:", ...unseen.map((u) => ` ${u.id} (${c.script[u.script]}), step ${u.step}: ${c.kinds[u.kind]()}`));
591
+ if (notRun.length) lines.push(`Never ran so far (Google's registry never held ${notRun.length === 1 ? "it" : "them"}): ${notRun.map((x) => x.id).join(", ")}. If a consent platform holds GTM back, accept consent to see it run.`);
592
+ return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify([...loads, ...unseen].map((l) => ({ step: l.step, page: l.page, ...containerJson(l), ...(l.id.startsWith("GTM-") ? { ran: run.containersRunning.some((r) => r.id === l.id) } : {}) })))}`);
567
593
  },
568
594
  );
569
595
 
@@ -590,10 +616,10 @@ export function buildServer(opts) {
590
616
  {
591
617
  title: "Read a report this server wrote",
592
618
  description:
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).",
619
+ "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). messages: every event an iframe posted to the page with postMessage as JSON, with what the page did next (outcome relayed, followed or nothing, and relay: the push or hit that followed).",
594
620
  inputSchema: {
595
621
  report: z.string().describe("The report name finish_audit or run_audit gave."),
596
- part: z.enum(["markdown", "summary", "timeline", "containers", "widgets"]).optional().describe("Default markdown."),
622
+ part: z.enum(["markdown", "summary", "timeline", "containers", "widgets", "messages"]).optional().describe("Default markdown."),
597
623
  },
598
624
  annotations: { readOnlyHint: true },
599
625
  },
@@ -606,6 +632,7 @@ export function buildServer(opts) {
606
632
  const capture = JSON.parse(await readFile(path.join(dir, "capture.json"), "utf8"));
607
633
  if (part === "containers") return text(JSON.stringify(containerRows(capture.runs ?? []).map(containerJson)));
608
634
  if (part === "widgets") return text(JSON.stringify(widgetsIn(capture.runs ?? [])));
635
+ if (part === "messages") return text(JSON.stringify((capture.runs ?? []).flatMap((r) => messageOutcomes(r, (a, b) => canonicalEvent(a) === canonicalEvent(b)).map((x) => ({ journey: r.journeyId, viewport: r.viewport, ...x })))));
609
636
  return text(JSON.stringify((capture.runs ?? []).map((r) => ({ journey: r.journeyId, viewport: r.viewport, steps: timelineOf(r) }))));
610
637
  } catch (err) {
611
638
  return fail(`Could not read the report: ${err.message}`);
@@ -717,13 +744,14 @@ export function buildServer(opts) {
717
744
  ...((platform) => (platform ? [`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`] : []))(cmpFacts(messages("en"), [run])),
718
745
  ...containerLines([run]),
719
746
  ...widgetLines([run]),
747
+ ...messageLines([run]),
720
748
  opts.allowFiles
721
749
  ? `Full report with the event timeline: ${reportPath}`
722
750
  : opts.reportsUrl
723
751
  ? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
724
752
  : "",
725
753
  "",
726
- `To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary, timeline, containers or widgets).`,
754
+ `To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary, timeline, containers, widgets or messages).`,
727
755
  "Explain each problem to the user with its likely cause and fix.",
728
756
  ];
729
757
  return {
@@ -871,8 +899,9 @@ export function buildServer(opts) {
871
899
  ...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
872
900
  ...containerLines(result.runs),
873
901
  ...widgetLines(result.runs),
902
+ ...messageLines(result.runs),
874
903
  `Report: ${result.reportPath}`,
875
- `To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary, timeline, containers or widgets).`,
904
+ `To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary, timeline, containers, widgets or messages).`,
876
905
  ].join("\n"),
877
906
  );
878
907
  },
package/src/report.js CHANGED
@@ -6,7 +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
+ import { messageOutcomes, widgetSummaries } from "./frames.js";
10
10
  import { computeMetrics } from "./metrics.js";
11
11
  import { historySummaryLines, sparkline } from "./history.js";
12
12
  import { cardIds } from "./html/lib.js";
@@ -398,10 +398,26 @@ function mdInventory(m, runs) {
398
398
  return lines;
399
399
  }
400
400
 
401
- /** A container row's version, size and load time, or that it failed to load, in a few words. */
401
+ /** A container row's version, size and load time, or that it failed to load, and whether it never ran, in a few words. */
402
402
  export function scriptCell(c, r) {
403
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(", ");
404
+ return [r.version && c.version(r.version), r.bytes && c.size(r.bytes), r.ms != null && c.loadTime(r.ms), r.ran === false && c.neverRan].filter(Boolean).join(", ");
405
+ }
406
+
407
+ /** The events iframes posted to the page and what the page did next, one row each. Omitted when there were none. */
408
+ function mdMessages(m, runs) {
409
+ const same = (a, b) => canonicalEvent(a) === canonicalEvent(b);
410
+ const list = runs.flatMap((r) => messageOutcomes(r, same));
411
+ if (!list.length) return [];
412
+ const t = m.ui.messages;
413
+ const lines = [`## ${m.ui.messagesTitle}`, "", `| ${m.ui.messageCol} | ${m.ui.fromCol} | ${m.ui.outcomeCol} |`, "|---|---|---|"];
414
+ for (const x of list.slice(0, MD_INVENTORY_ROWS)) {
415
+ const what = x.outcome === "nothing" ? t.nothing : t[x.outcome](x.relay.kind, x.relay.name, x.relay.ms);
416
+ lines.push(`| ${mdText(x.name)} | ${mdText(x.origin)} (${m.ui.party[x.party === "site" ? "first" : "third"]}) | ${mdText(what)} |`);
417
+ }
418
+ if (list.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(list.length - MD_INVENTORY_ROWS, "report.html"));
419
+ lines.push("");
420
+ return lines;
405
421
  }
406
422
 
407
423
  /** The embedded widgets, one row each: where they were embedded, their tracking and their own dataLayer events. Omitted when none. */
@@ -428,8 +444,8 @@ function mdContainers(m, runs) {
428
444
  const c = m.ui.containers;
429
445
  const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.scriptCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|---|"];
430
446
  for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
431
- const served = r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`;
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} |`);
447
+ const served = !r.served ? "–" : r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`;
448
+ 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))}` : ""} | ${r.layer ? mdText(r.layer) : "–"} | ${mdText(served)} | ${mdText(scriptCell(c, r)) || "–"} | ${r.pages.length} |`);
433
449
  }
434
450
  if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
435
451
  lines.push("");
@@ -489,6 +505,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
489
505
  lines.push(...mdInventory(m, runs));
490
506
  lines.push(...mdContainers(m, runs));
491
507
  lines.push(...mdWidgets(m, runs));
508
+ lines.push(...mdMessages(m, runs));
492
509
  // A report with its own name or logo carries no Analitik İşler credit; the ai tag debugger one stays.
493
510
  if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
494
511
  else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");