@analitikisler/ai-tag-debugger 0.22.4 → 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,13 @@
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
+
3
11
  ## 0.22.4 (2026-11-04)
4
12
 
5
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.
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
- [The guide](docs/guides/containers-and-widgets.md) explains each finding below with what to do. 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
 
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
- [Rehber](docs/guides/containers-and-widgets.tr.md) aşağıdaki her bulguyu ne yapılacağıyla anlatır. 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
 
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.4",
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: [], frameMessages: [], 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,8 @@ 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()],
262
264
  frameMessages: session.frameMessages,
263
265
  network: session.network,
264
266
  providers: session.providers,
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, 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
@@ -106,6 +106,9 @@ 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 {}
@@ -516,6 +519,17 @@ export function googleConsentStateInPage() {
516
519
  return { entries, usedDefault: Boolean(ics.usedDefault), usedUpdate: Boolean(ics.usedUpdate), wasSetLate: Boolean(ics.wasSetLate), waitPeriodTimedOut: Boolean(ics.waitPeriodTimedOut) };
517
520
  }
518
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
+
519
533
  /**
520
534
  * Runs in the page: whether it is something other than the site, so what a step captured there says nothing about
521
535
  * the site's tracking. A bot challenge in its place (Cloudflare, DataDome, HUMAN, Akamai), or a page that crashed in
@@ -577,6 +591,8 @@ export class AuditSession {
577
591
  this.dataLayerLog = [];
578
592
  this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
579
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
580
596
  this.frameMessages = []; // each named message an iframe posted to the page: origin, name and field names, never values
581
597
  // The order hits, dataLayer pushes and iframe messages were recorded in. Several often share a millisecond, and
582
598
  // telling which came first matters: a page's push answers the message just before it (see messageOutcomes).
@@ -870,6 +886,12 @@ export class AuditSession {
870
886
  recordLoad(entry, frame = null) {
871
887
  const container = containerOf(entry?.src);
872
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
+ }
873
895
  this.record("containerLoads", {
874
896
  ...container,
875
897
  how: entry.how === "parser" ? "parser" : "script",
@@ -1240,6 +1262,15 @@ export class AuditSession {
1240
1262
  const tagConsent = await this.evaluateWithin(googleConsentStateInPage);
1241
1263
  const before = this.steps.slice(0, this.currentStep).map((st) => st.tagConsent).filter(Boolean).at(-1);
1242
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
+ }
1243
1274
  }
1244
1275
 
1245
1276
  async perform(a) {
package/src/checks.js CHANGED
@@ -5,7 +5,7 @@ import { messages } from "./i18n.js";
5
5
  import { isOwnAddress } from "./redact.js";
6
6
  import { campaignParamsIn } from "./campaign.js";
7
7
  import { CONSENT_SIGNALS } from "./parsers.js";
8
- import { duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
8
+ import { containersNotRun, duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
9
9
  import { messageOutcomes, widgetSummaries, widgetsOf } from "./frames.js";
10
10
 
11
11
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
@@ -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);
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/html/scope.js CHANGED
@@ -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
  }
package/src/i18n.js CHANGED
@@ -244,6 +244,7 @@ const en = {
244
244
  size: (bytes) => `${(bytes / 1024).toFixed(1)} KB`,
245
245
  loadTime: (ms) => `loaded in ${ms} ms`,
246
246
  failed: (status) => (status === "failed" ? "failed to load" : `failed to load (HTTP ${status})`),
247
+ neverRan: "never ran",
247
248
  kinds: {
248
249
  html: () => "A script tag in the page's HTML",
249
250
  inline: () => "Inline code in the page (the snippet)",
@@ -254,6 +255,8 @@ const en = {
254
255
  config: (by) => `A gtag('config') command, loaded by ${by}`,
255
256
  send_to: (by) => `An event's send_to with no config, loaded by ${by}`,
256
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",
257
260
  },
258
261
  },
259
262
  provider: "Provider",
@@ -385,6 +388,8 @@ const en = {
385
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.`,
386
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.`,
387
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.`,
388
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.`,
389
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.`,
390
395
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
@@ -449,6 +454,8 @@ const en = {
449
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.",
450
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.",
451
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.",
452
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.",
453
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.",
454
461
  campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
@@ -500,6 +507,8 @@ const en = {
500
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.",
501
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.",
502
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.",
503
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.",
504
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.",
505
514
  campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
@@ -551,6 +560,8 @@ const en = {
551
560
  widget_tracking: "hygiene",
552
561
  iframe_container: "hygiene",
553
562
  container_failed: "money",
563
+ container_not_run: "money",
564
+ container_held: "hygiene",
554
565
  widget_events_unshared: "money",
555
566
  iframe_message_unhandled: "money",
556
567
  campaign_params: "money",
@@ -1113,6 +1124,7 @@ const tr = {
1113
1124
  size: (bytes) => `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`,
1114
1125
  loadTime: (ms) => `${ms} ms'de yüklendi`,
1115
1126
  failed: (status) => (status === "failed" ? "yüklenemedi" : `yüklenemedi (HTTP ${status})`),
1127
+ neverRan: "hiç çalışmadı",
1116
1128
  kinds: {
1117
1129
  html: () => "Sayfanın HTML'indeki bir script etiketi",
1118
1130
  inline: () => "Sayfadaki satır içi kod (snippet)",
@@ -1123,6 +1135,8 @@ const tr = {
1123
1135
  config: (by) => `Bir gtag('config') komutu, ${by} yükledi`,
1124
1136
  send_to: (by) => `config olmadan bir olayın send_to değeri, ${by} yükledi`,
1125
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",
1126
1140
  },
1127
1141
  },
1128
1142
  provider: "Sağlayıcı",
@@ -1250,6 +1264,8 @@ const tr = {
1250
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.`,
1251
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.`,
1252
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.`,
1253
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.`,
1254
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.`,
1255
1271
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
@@ -1313,6 +1329,8 @@ const tr = {
1313
1329
  widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
1314
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.",
1315
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.",
1316
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.",
1317
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.",
1318
1336
  campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
@@ -1362,6 +1380,8 @@ const tr = {
1362
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.",
1363
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.",
1364
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.",
1365
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.",
1366
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.",
1367
1387
  campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
@@ -1412,6 +1432,8 @@ const tr = {
1412
1432
  widget_tracking: "hygiene",
1413
1433
  iframe_container: "hygiene",
1414
1434
  container_failed: "money",
1435
+ container_not_run: "money",
1436
+ container_held: "hygiene",
1415
1437
  widget_events_unshared: "money",
1416
1438
  iframe_message_unhandled: "money",
1417
1439
  campaign_params: "money",
package/src/mcp.js CHANGED
@@ -33,7 +33,7 @@ import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, pars
33
33
  import { hitParams } from "./hit-params.js";
34
34
  import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
35
35
  import { cmpFacts } from "./cmp.js";
36
- import { containerRows, loadSources } from "./containers.js";
36
+ import { containerRows, containersNotRun, loadSources, unseenContainers } from "./containers.js";
37
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";
@@ -244,7 +244,7 @@ export function hitDetail(audit, step, index) {
244
244
  function containerJson(l) {
245
245
  return {
246
246
  id: l.id, source: l.kind, ...(l.by ? { by: l.by } : {}),
247
- ...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
247
+ ...(l.layer && l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
248
248
  ...(l.served === "site" ? { served_from: l.host } : {}),
249
249
  ...(l.environment ? { gtm_environment: l.environment } : {}),
250
250
  ...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
@@ -253,6 +253,7 @@ function containerJson(l) {
253
253
  ...(l.transferBytes ? { transfer_bytes: l.transferBytes } : {}),
254
254
  ...(l.ms != null ? { load_ms: l.ms } : {}),
255
255
  ...(l.failed ? { failed: l.failed } : {}),
256
+ ...(typeof l.ran === "boolean" ? { ran: l.ran } : {}),
256
257
  ...(l.pages ? { pages: l.pages } : {}),
257
258
  };
258
259
  }
@@ -265,7 +266,7 @@ export function containerLines(runs) {
265
266
  return [
266
267
  "Google containers (how each reached the page):",
267
268
  ...rows.map((r) => {
268
- 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);
269
270
  return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
270
271
  }),
271
272
  ];
@@ -575,10 +576,20 @@ export function buildServer(opts) {
575
576
  },
576
577
  async ({ audit_id }) => {
577
578
  const { session } = getAudit(audit_id);
578
- const loads = loadSources({ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
579
- 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;
580
589
  const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
581
- 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) } : {}) })))}`);
582
593
  },
583
594
  );
584
595
 
package/src/report.js CHANGED
@@ -398,10 +398,10 @@ 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
405
  }
406
406
 
407
407
  /** The events iframes posted to the page and what the page did next, one row each. Omitted when there were none. */
@@ -444,8 +444,8 @@ function mdContainers(m, runs) {
444
444
  const c = m.ui.containers;
445
445
  const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.scriptCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|---|"];
446
446
  for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
447
- const 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))}` : ""} | ${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} |`);
449
449
  }
450
450
  if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
451
451
  lines.push("");