@analitikisler/ai-tag-debugger 0.22.1 → 0.22.2
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 +6 -0
- package/README.md +2 -2
- package/README.tr.md +2 -2
- package/package.json +1 -1
- package/src/agent.js +2 -1
- package/src/browser.js +33 -2
- package/src/checks.js +6 -1
- package/src/containers.js +39 -3
- package/src/html/scope.js +1 -1
- package/src/i18n.js +18 -0
- package/src/mcp.js +12 -6
- package/src/report.js +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.2 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
- Runs can report a finding 0.22.1 did not on the same site (`container_failed`). A baseline written with 0.22.1 can then list new entries; `--update-baseline` rewrites it.
|
|
6
|
+
- Each Google container shows its published version (the number its `gtm.js` or `gtag/js` data opens with, as in GTM's Versions tab), the script's size and how long it took to load, in the Google containers card, `report.md` (a new **Version and size** column) and over MCP: step results, `get_containers`, `finish_audit`, `run_audit`, the JSON timeline and `get_report` with `part: "containers"` (`version`, `size_bytes`, `transfer_bytes`, `load_ms`). `capture.json` keeps each container script's answer under `containerScripts`.
|
|
7
|
+
- New check `container_failed` (warning): a container script that failed to load or was answered with an error status, such as a wrong container ID or a server container that doesn't serve `gtm.js`, so none of its tags ran on that page.
|
|
8
|
+
|
|
3
9
|
## 0.22.1 (2026-11-04)
|
|
4
10
|
|
|
5
11
|
- Runs can report different findings than 0.22.0 on the same site: an embedded widget's own hits no longer count as the site's, so a planned event only a widget sent is now missing, and the new `widget_tracking` and `iframe_container` (info) findings appear. A baseline written with 0.22.0 can then list stale or new entries; `--update-baseline` rewrites it.
|
package/README.md
CHANGED
|
@@ -325,9 +325,9 @@ Under the inventory, **Google containers** lists every GTM container (`gtm.js`)
|
|
|
325
325
|
| An event's `send_to` with no config | The Google tag loaded the destination only because an event was sent to it |
|
|
326
326
|
| A destination of the Google tag | The Google tag loaded it with no command in the page, such as a linked destination |
|
|
327
327
|
|
|
328
|
-
Each row also gives the dataLayer the container reads (`l=`), whether it came from Google or from the site's own domain (the Google tag gateway or a server container), the GTM environment when the snippet names one, and the pages it loaded on. A container reading a dataLayer of another name has its pushes recorded too, marked with that name. `report.md` has the same table. Over MCP, step results and `get_step` list the containers each step loaded, `get_containers` lists them all while the audit is open, `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "containers"` and the JSON timeline carries them per step. `capture.json` keeps the loads under `containerLoads`. The environment token (`gtm_auth`) and the page's query string are never kept.
|
|
328
|
+
Each row also gives the container's published version (the number its `gtm.js` or `gtag/js` data opens with, as in GTM's Versions tab), the script's size and how long it took to load, the dataLayer the container reads (`l=`), whether it came from Google or from the site's own domain (the Google tag gateway or a server container), the GTM environment when the snippet names one, and the pages it loaded on. A container reading a dataLayer of another name has its pushes recorded too, marked with that name. `report.md` has the same table. Over MCP, step results and `get_step` list the containers each step loaded, `get_containers` lists them all while the audit is open, `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "containers"` and the JSON timeline carries them per step. `capture.json` keeps the loads under `containerLoads`. The environment token (`gtm_auth`) and the page's query string are never kept.
|
|
329
329
|
|
|
330
|
-
|
|
330
|
+
Five checks read it: `container_failed` (warning) for a container script that failed to load or was answered with an error, so none of its tags ran, `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment (`gtm_preview`, `gtm_auth`) loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination that only an event's `send_to` loaded, with no `gtag('config')` for it.
|
|
331
331
|
|
|
332
332
|
### Embedded widgets and iframes
|
|
333
333
|
|
package/README.tr.md
CHANGED
|
@@ -325,9 +325,9 @@ Envanterin altında **Google container'ları**, sayfaların yüklediği her GTM
|
|
|
325
325
|
| config olmadan bir olayın `send_to` değeri | Google etiketi hedefi yalnızca ona bir olay gönderildiği için yükledi |
|
|
326
326
|
| Google etiketinin bir hedefi | Google etiketi onu sayfada bir komut olmadan yükledi, örneğin bağlı bir hedef |
|
|
327
327
|
|
|
328
|
-
Her satır ayrıca container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yoksa sitenin kendi alan adından mı (Google etiket ağ geçidi ya da bir sunucu container'ı) geldiğini, snippet bir GTM ortamı adlandırıyorsa onu ve yüklendiği sayfaları verir. Başka adlı bir dataLayer okuyan container'ın push'ları da o adla işaretlenerek kaydedilir. `report.md` aynı tabloyu içerir. MCP'de adım sonuçları ve `get_step` her adımın yüklediği container'ları listeler, `get_containers` denetim açıkken hepsini listeler, `finish_audit` ve `run_audit` sonuçları onlarla biter, `get_report` onları `part: "containers"` ile döndürür ve JSON zaman akışı onları adım adım taşır. `capture.json` yüklemeleri `containerLoads` altında saklar. Ortam belirteci (`gtm_auth`) ve sayfanın sorgu dizesi asla saklanmaz.
|
|
328
|
+
Her satır ayrıca container'ın yayımlanmış sürümünü (`gtm.js` ya da `gtag/js` verisinin başladığı numara; GTM'deki Sürümler sekmesindekiyle aynı), betiğin boyutunu ve ne kadar sürede yüklendiğini, container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yoksa sitenin kendi alan adından mı (Google etiket ağ geçidi ya da bir sunucu container'ı) geldiğini, snippet bir GTM ortamı adlandırıyorsa onu ve yüklendiği sayfaları verir. Başka adlı bir dataLayer okuyan container'ın push'ları da o adla işaretlenerek kaydedilir. `report.md` aynı tabloyu içerir. MCP'de adım sonuçları ve `get_step` her adımın yüklediği container'ları listeler, `get_containers` denetim açıkken hepsini listeler, `finish_audit` ve `run_audit` sonuçları onlarla biter, `get_report` onları `part: "containers"` ile döndürür ve JSON zaman akışı onları adım adım taşır. `capture.json` yüklemeleri `containerLoads` altında saklar. Ortam belirteci (`gtm_auth`) ve sayfanın sorgu dizesi asla saklanmaz.
|
|
329
329
|
|
|
330
|
-
|
|
330
|
+
Beş kontrol bunu okur: yüklenemeyen ya da hatayla yanıtlanan, bu yüzden etiketlerinin hiçbiri çalışmayan bir container betiği için `container_failed` (uyarı), aynı sayfaya birden fazla kez yüklenen bir container için `container_duplicate` (uyarı), Live yerine yüklenen bir GTM ortamı (`gtm_preview`, `gtm_auth`) için `container_environment` (uyarı), aynı sayfada farklı dataLayer'ları okuyan container'lar için `datalayer_split` (bilgi) ve kendisi için `gtag('config')` olmadan yalnızca bir olayın `send_to` değeriyle yüklenen bir hedef için `container_send_to_only` (bilgi).
|
|
331
331
|
|
|
332
332
|
### Gömülü widget'lar ve iframe'ler
|
|
333
333
|
|
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
|
+
"version": "0.22.2",
|
|
5
5
|
"description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/src/agent.js
CHANGED
|
@@ -238,7 +238,7 @@ export const journeyError = (err) => `The journey stopped with an error: ${err?.
|
|
|
238
238
|
export function failedRun(journey, viewport, err, providers) {
|
|
239
239
|
return {
|
|
240
240
|
journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
|
|
241
|
-
cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
|
|
241
|
+
cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], containerScripts: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
|
|
242
242
|
};
|
|
243
243
|
}
|
|
244
244
|
|
|
@@ -258,6 +258,7 @@ export function collect(session, journey, viewport) {
|
|
|
258
258
|
// Hits let through to the real accounts (block_hits false, or a live MCP audit): the reports say so.
|
|
259
259
|
...(session.opts.blockHits === false ? { live: true } : {}),
|
|
260
260
|
containerLoads: session.containerLoads,
|
|
261
|
+
containerScripts: session.containerScripts,
|
|
261
262
|
network: session.network,
|
|
262
263
|
providers: session.providers,
|
|
263
264
|
skipped: session.skipped,
|
package/src/browser.js
CHANGED
|
@@ -10,7 +10,7 @@ import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from
|
|
|
10
10
|
import { startEgressProxy } from "./egress.js";
|
|
11
11
|
import { campaignParamsIn } from "./campaign.js";
|
|
12
12
|
import { detectCmpInPage } from "./cmp.js";
|
|
13
|
-
import { callersOf, containerOf } from "./containers.js";
|
|
13
|
+
import { callersOf, containerOf, containerVersion } from "./containers.js";
|
|
14
14
|
import { frameInfo } from "./frames.js";
|
|
15
15
|
|
|
16
16
|
// Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
|
|
@@ -36,7 +36,7 @@ export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk
|
|
|
36
36
|
// Efilli draws its banner inside the open shadow root of <efilli-layout-dynamic>, so the checks below also look inside open shadow roots.
|
|
37
37
|
|
|
38
38
|
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
39
|
-
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500 };
|
|
39
|
+
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500, containerScripts: 500 };
|
|
40
40
|
|
|
41
41
|
// Secrets the browser process has no use for. It gets the rest of the environment
|
|
42
42
|
// (PATH, HOME, DISPLAY...) so Chromium still starts normally.
|
|
@@ -548,6 +548,7 @@ export class AuditSession {
|
|
|
548
548
|
this.errors = [];
|
|
549
549
|
this.dataLayerLog = [];
|
|
550
550
|
this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
|
|
551
|
+
this.containerScripts = []; // each container script's answer: status, version, size and load time
|
|
551
552
|
this.network = [];
|
|
552
553
|
this.providers = resolveProviders(this.opts.providers);
|
|
553
554
|
this.landing = null; // the first page: { step, requested, chain, final }
|
|
@@ -683,6 +684,11 @@ export class AuditSession {
|
|
|
683
684
|
const call = calls.get(request);
|
|
684
685
|
// Keepalive requests answered below are still reported as aborted; the page got the 204.
|
|
685
686
|
if (call && call.status === null) call.status = "failed";
|
|
687
|
+
// A script answered with an error status has a response, but Chromium aborts its body: the status is kept.
|
|
688
|
+
if (containerOf(request.url())) this.recordScript(request, request.response().catch(() => null)).catch(() => {});
|
|
689
|
+
});
|
|
690
|
+
this.context.on("requestfinished", (request) => {
|
|
691
|
+
if (containerOf(request.url())) this.recordScript(request, request.response()).catch(() => {});
|
|
686
692
|
});
|
|
687
693
|
|
|
688
694
|
// Hits come from the request event rather than the route below: a request sent while
|
|
@@ -843,6 +849,31 @@ export class AuditSession {
|
|
|
843
849
|
});
|
|
844
850
|
}
|
|
845
851
|
|
|
852
|
+
/**
|
|
853
|
+
* A container script's answer: its HTTP status (or "failed"), the container version its published data opens
|
|
854
|
+
* with, the size of the script as the page got it (decoded) and over the wire, and how long it took to load.
|
|
855
|
+
* response resolves to null when no answer came at all. Only the ID and host are kept, not the address.
|
|
856
|
+
*/
|
|
857
|
+
async recordScript(request, response) {
|
|
858
|
+
const container = containerOf(request.url());
|
|
859
|
+
if (!container) return;
|
|
860
|
+
const step = this.currentStep;
|
|
861
|
+
const res = await response;
|
|
862
|
+
const body = res ? await res.body().catch(() => null) : null;
|
|
863
|
+
const sizes = res ? await request.sizes().catch(() => null) : null;
|
|
864
|
+
const timing = request.timing();
|
|
865
|
+
const ms = timing.responseEnd > 0 ? Math.round(timing.responseEnd) : null;
|
|
866
|
+
const version = body ? containerVersion(body.subarray(0, 4096).toString("utf8")) : null;
|
|
867
|
+
this.record("containerScripts", {
|
|
868
|
+
id: container.id, host: container.host, step,
|
|
869
|
+
status: res ? res.status() : "failed",
|
|
870
|
+
...(version ? { version } : {}),
|
|
871
|
+
...(body ? { bytes: body.length } : {}),
|
|
872
|
+
...(sizes?.responseBodySize > 0 ? { transferBytes: sizes.responseBodySize } : {}),
|
|
873
|
+
...(ms !== null ? { ms } : {}),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
846
877
|
/** The iframe a request came from, as frameInfo gives it; null for the page itself or a worker. */
|
|
847
878
|
frameOf(request) {
|
|
848
879
|
try {
|
package/src/checks.js
CHANGED
|
@@ -5,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, splitLayers } from "./containers.js";
|
|
8
|
+
import { duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
|
|
9
9
|
import { widgetsOf } from "./frames.js";
|
|
10
10
|
|
|
11
11
|
const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
|
|
@@ -389,6 +389,11 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
389
389
|
findings.push({ ...base, severity: "info", check: "container_send_to_only", platform: l.id.startsWith("G-") ? "ga4" : "google_ads", step: stepLabel(l.step), detail: d.sendToOnly(l.id, l.by) });
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
// A container script that failed to load: none of its tags ran. Said once per container and host.
|
|
393
|
+
for (const sc of (run.containerScripts ?? []).filter((x, i, all) => scriptFailed(x) && all.findIndex((y) => scriptFailed(y) && y.id === x.id && y.host === x.host) === i)) {
|
|
394
|
+
findings.push({ ...base, severity: "warning", check: "container_failed", step: stepLabel(sc.step), detail: d.containerFailed(sc.id, sc.host, String(sc.status)) });
|
|
395
|
+
}
|
|
396
|
+
|
|
392
397
|
// Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
|
|
393
398
|
for (const w of widgetsOf(run.widgetHits ?? [])) {
|
|
394
399
|
const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
|
package/src/containers.js
CHANGED
|
@@ -31,6 +31,40 @@ export function containerOf(url) {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The container version a gtm.js or gtag/js script was published as: its data opens with
|
|
36
|
+
* `var data = { "resource": { "version": "153", ...`. Null when the script says nothing of the kind.
|
|
37
|
+
*/
|
|
38
|
+
export function containerVersion(text) {
|
|
39
|
+
return String(text ?? "").match(/"resource"\s*:\s*\{\s*"version"\s*:\s*"(\d+)"/)?.[1] ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether a container script's answer means it never ran: the request failed or the server answered with an error. */
|
|
43
|
+
export const scriptFailed = (script) => script.status === "failed" || (typeof script.status === "number" && script.status >= 400);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The answer to a load's script request (see recordScript in browser.js): the first one for the same container and
|
|
47
|
+
* host from the load's step on, else the last before it. Null for runs saved by older versions.
|
|
48
|
+
*/
|
|
49
|
+
export function scriptOf(run, load) {
|
|
50
|
+
const same = (run.containerScripts ?? []).filter((s) => s.id === load.id && s.host === load.host);
|
|
51
|
+
return same.find((s) => s.step >= (load.step ?? 0)) ?? same.at(-1) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a row or load shows of its script's answer: { version?, bytes?, transferBytes?, ms?, failed? }. */
|
|
55
|
+
export function scriptFacts(script) {
|
|
56
|
+
if (!script) return {};
|
|
57
|
+
// A load that already carries the facts (from loadSources) passes them on as they are.
|
|
58
|
+
if (!("status" in script)) return Object.fromEntries(["version", "bytes", "transferBytes", "ms", "failed"].filter((k) => script[k] != null).map((k) => [k, script[k]]));
|
|
59
|
+
return {
|
|
60
|
+
...(script.version ? { version: script.version } : {}),
|
|
61
|
+
...(script.bytes ? { bytes: script.bytes } : {}),
|
|
62
|
+
...(script.transferBytes ? { transferBytes: script.transferBytes } : {}),
|
|
63
|
+
...(script.ms != null ? { ms: script.ms } : {}),
|
|
64
|
+
...(scriptFailed(script) ? { failed: String(script.status) } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
/** origin + path, so a caller's query string (which may carry user data) is never kept. */
|
|
35
69
|
export function scriptPath(url) {
|
|
36
70
|
try {
|
|
@@ -106,15 +140,16 @@ export function sourceOf(load, pushes = []) {
|
|
|
106
140
|
return { kind: "inline" };
|
|
107
141
|
}
|
|
108
142
|
|
|
109
|
-
/** A run's container loads with their sources, in the order they happened. Runs saved by older versions have none. */
|
|
143
|
+
/** A run's container loads with their sources and their scripts' answers (scriptFacts), in the order they happened. Runs saved by older versions have none. */
|
|
110
144
|
export function loadSources(run) {
|
|
111
145
|
const pushes = run.dataLayerLog ?? [];
|
|
112
|
-
return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes) }));
|
|
146
|
+
return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes), ...scriptFacts(scriptOf(run, load)) }));
|
|
113
147
|
}
|
|
114
148
|
|
|
115
149
|
/**
|
|
116
150
|
* One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
|
|
117
|
-
* environment?, kind, by?, frame?, pages, journeys }
|
|
151
|
+
* environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, pages, journeys }. frame is the
|
|
152
|
+
* iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the row.
|
|
118
153
|
*/
|
|
119
154
|
export function containerRows(runs) {
|
|
120
155
|
const rows = new Map();
|
|
@@ -125,6 +160,7 @@ export function containerRows(runs) {
|
|
|
125
160
|
id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
|
|
126
161
|
...(l.environment ? { environment: l.environment } : {}), kind: l.kind, ...(l.by ? { by: l.by } : {}),
|
|
127
162
|
...(l.frame ? { frame: { origin: l.frame.origin, party: l.frame.party } } : {}),
|
|
163
|
+
...scriptFacts(l),
|
|
128
164
|
pages: new Set(), journeys: new Set(),
|
|
129
165
|
};
|
|
130
166
|
if (l.page) row.pages.add(l.page);
|
package/src/html/scope.js
CHANGED
|
@@ -200,7 +200,7 @@ function containersCard(ctx) {
|
|
|
200
200
|
const rows = containerRows(runs);
|
|
201
201
|
if (!rows.length) return null;
|
|
202
202
|
const c = m.ui.containers;
|
|
203
|
-
const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`)}${plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`))}${r.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("");
|
|
203
|
+
const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`)}${plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`))}${r.version ? plain(esc(c.version(r.version)), "mono") : ""}${r.bytes ? plain(esc(c.size(r.bytes)), "mono") : ""}${r.ms != null ? plain(esc(c.loadTime(r.ms))) : ""}${r.failed ? badge("broken", c.failed(r.failed)) : ""}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
|
|
204
204
|
const ids = uniq(rows.map((r) => r.id));
|
|
205
205
|
return `<li><details class="card inv" id="inv-containers"><summary><span class="t">${esc(m.ui.containersTitle)}</span>${plain(n(ids.length))}<span class="inv-m">${esc(ids.slice(0, 3).join(", "))}${ids.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.containersIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
|
|
206
206
|
}
|
package/src/i18n.js
CHANGED
|
@@ -209,11 +209,16 @@ const en = {
|
|
|
209
209
|
sourceCol: "Loaded by",
|
|
210
210
|
layerCol: "dataLayer",
|
|
211
211
|
servedCol: "Served from",
|
|
212
|
+
scriptCol: "Version and size",
|
|
212
213
|
containers: {
|
|
213
214
|
script: { gtm: "GTM container", gtag: "Google tag" },
|
|
214
215
|
served: { google: "Google", site: "The site's own domain" },
|
|
215
216
|
environment: (env) => (env === "unnamed" ? "GTM environment" : `GTM environment ${env}`),
|
|
216
217
|
iframe: (origin, party) => `In an iframe from ${origin} (${party === "site" ? "same site" : "third party"})`,
|
|
218
|
+
version: (v) => `version ${v}`,
|
|
219
|
+
size: (bytes) => `${(bytes / 1024).toFixed(1)} KB`,
|
|
220
|
+
loadTime: (ms) => `loaded in ${ms} ms`,
|
|
221
|
+
failed: (status) => (status === "failed" ? "failed to load" : `failed to load (HTTP ${status})`),
|
|
217
222
|
kinds: {
|
|
218
223
|
html: () => "A script tag in the page's HTML",
|
|
219
224
|
inline: () => "Inline code in the page (the snippet)",
|
|
@@ -354,6 +359,7 @@ const en = {
|
|
|
354
359
|
fromWidget: (origin, text) => `In an embedded widget from ${origin}: ${text}`,
|
|
355
360
|
widgetTracking: (origin, platforms) => `An embedded widget from ${origin} sent its own tracking: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. It goes to accounts the site's own tags don't use, so it was left out of the plan, coverage and duplicate checks; the consent and personal data checks still include it.`,
|
|
356
361
|
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.`,
|
|
362
|
+
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.`,
|
|
357
363
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
358
364
|
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
359
365
|
consentDefaultNotApplied: () => "The page pushed a Consent Mode default, but the Google tag never applied it, so its tags ran without one. Tag Assistant shows Consent Mode as not configured.",
|
|
@@ -415,6 +421,7 @@ const en = {
|
|
|
415
421
|
container_send_to_only: "The destination runs without its own config, so the settings a config command would give it are missing.",
|
|
416
422
|
widget_tracking: "Nothing is wrong with the site's tracking: the widget's own hits were kept out of the plan checks so they can't pass for the site's.",
|
|
417
423
|
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.",
|
|
424
|
+
container_failed: "None of the container's tags ran: page views, conversions and consent signals it sends were all lost on that page.",
|
|
418
425
|
campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
|
|
419
426
|
event_id_reused: "Two different events share one event ID, so the platform keeps the first and silently drops the other: a real event is lost.",
|
|
420
427
|
event_id_mismatch: "The browser and server copies of this event carry different IDs, so Meta cannot pair them and counts the conversion twice.",
|
|
@@ -463,6 +470,7 @@ const en = {
|
|
|
463
470
|
container_send_to_only: "Add a gtag('config', ID) for the destination before the events that send to it, or a Google tag for it in GTM.",
|
|
464
471
|
widget_tracking: "Nothing to fix for the plan. If the widget sets cookies or sends data before consent, the consent findings name it; ask its vendor how to hold it back.",
|
|
465
472
|
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.",
|
|
473
|
+
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.",
|
|
466
474
|
campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
|
|
467
475
|
event_id_reused: "Generate a new event ID for every event, not once per page load.",
|
|
468
476
|
event_id_mismatch: "Use one event ID for the pixel event and the server copy: pass the same value to both tags.",
|
|
@@ -511,6 +519,7 @@ const en = {
|
|
|
511
519
|
container_send_to_only: "hygiene",
|
|
512
520
|
widget_tracking: "hygiene",
|
|
513
521
|
iframe_container: "hygiene",
|
|
522
|
+
container_failed: "money",
|
|
514
523
|
campaign_params: "money",
|
|
515
524
|
event_id_reused: "money",
|
|
516
525
|
event_id_mismatch: "money",
|
|
@@ -1036,11 +1045,16 @@ const tr = {
|
|
|
1036
1045
|
sourceCol: "Yükleyen",
|
|
1037
1046
|
layerCol: "dataLayer",
|
|
1038
1047
|
servedCol: "Sunulduğu yer",
|
|
1048
|
+
scriptCol: "Sürüm ve boyut",
|
|
1039
1049
|
containers: {
|
|
1040
1050
|
script: { gtm: "GTM container'ı", gtag: "Google etiketi" },
|
|
1041
1051
|
served: { google: "Google", site: "Sitenin kendi alan adı" },
|
|
1042
1052
|
environment: (env) => (env === "unnamed" ? "GTM ortamı" : `GTM ortamı ${env}`),
|
|
1043
1053
|
iframe: (origin, party) => `${origin} kaynaklı bir iframe içinde (${party === "site" ? "aynı site" : "üçüncü taraf"})`,
|
|
1054
|
+
version: (v) => `sürüm ${v}`,
|
|
1055
|
+
size: (bytes) => `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`,
|
|
1056
|
+
loadTime: (ms) => `${ms} ms'de yüklendi`,
|
|
1057
|
+
failed: (status) => (status === "failed" ? "yüklenemedi" : `yüklenemedi (HTTP ${status})`),
|
|
1044
1058
|
kinds: {
|
|
1045
1059
|
html: () => "Sayfanın HTML'indeki bir script etiketi",
|
|
1046
1060
|
inline: () => "Sayfadaki satır içi kod (snippet)",
|
|
@@ -1177,6 +1191,7 @@ const tr = {
|
|
|
1177
1191
|
fromWidget: (origin, text) => `${origin} kaynaklı gömülü bir widget içinde: ${text}`,
|
|
1178
1192
|
widgetTracking: (origin, platforms) => `${origin} kaynaklı gömülü bir widget kendi izlemesini gönderdi: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. Bunlar sitenin kendi etiketlerinin kullanmadığı hesaplara gidiyor; bu yüzden plan, kapsam ve tekrar kontrollerinin dışında tutuldu, onay ve kişisel veri kontrolleri ise onları yine içeriyor.`,
|
|
1179
1193
|
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.`,
|
|
1194
|
+
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ı.`,
|
|
1180
1195
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
1181
1196
|
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
|
1182
1197
|
consentDefaultNotApplied: () => "Sayfa bir Consent Mode varsayılanı gönderdi, ancak Google etiketi onu hiç uygulamadı; etiketleri varsayılan olmadan çalıştı. Tag Assistant, Consent Mode'u yapılandırılmamış gösterir.",
|
|
@@ -1237,6 +1252,7 @@ const tr = {
|
|
|
1237
1252
|
container_send_to_only: "Hedef kendi config'i olmadan çalışıyor; bir config komutunun ona vereceği ayarlar eksik.",
|
|
1238
1253
|
widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
|
|
1239
1254
|
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.",
|
|
1255
|
+
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.",
|
|
1240
1256
|
campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
|
|
1241
1257
|
event_id_reused: "İki farklı olay tek bir olay kimliğini paylaşıyor; platform ilkini tutup diğerini sessizce atıyor, gerçek bir olay kayboluyor.",
|
|
1242
1258
|
event_id_mismatch: "Bu olayın tarayıcı ve sunucu kopyaları farklı kimlik taşıyor; Meta ikisini eşleştiremiyor ve dönüşümü iki kez sayıyor.",
|
|
@@ -1283,6 +1299,7 @@ const tr = {
|
|
|
1283
1299
|
container_send_to_only: "Hedefe gönderen olaylardan önce onun için bir gtag('config', ID) ya da GTM'de onun için bir Google etiketi ekleyin.",
|
|
1284
1300
|
widget_tracking: "Plan için düzeltilecek bir şey yok. Widget onaydan önce çerez yazıyor ya da veri gönderiyorsa onay bulguları onu adlandırır; nasıl durdurulacağını sağlayıcısına sorun.",
|
|
1285
1301
|
iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
|
|
1302
|
+
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.",
|
|
1286
1303
|
campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
|
|
1287
1304
|
event_id_reused: "Her olay için yeni bir olay kimliği üretin; sayfa yüklemesi başına bir kez değil.",
|
|
1288
1305
|
event_id_mismatch: "Piksel olayı ve sunucu kopyası için tek bir olay kimliği kullanın: aynı değeri iki etikete de verin.",
|
|
@@ -1330,6 +1347,7 @@ const tr = {
|
|
|
1330
1347
|
container_send_to_only: "hygiene",
|
|
1331
1348
|
widget_tracking: "hygiene",
|
|
1332
1349
|
iframe_container: "hygiene",
|
|
1350
|
+
container_failed: "money",
|
|
1333
1351
|
campaign_params: "money",
|
|
1334
1352
|
event_id_reused: "money",
|
|
1335
1353
|
event_id_mismatch: "money",
|
package/src/mcp.js
CHANGED
|
@@ -23,7 +23,7 @@ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
|
23
23
|
import { validatePlan } from "./schema.js";
|
|
24
24
|
import { readBaseline } from "./baseline.js";
|
|
25
25
|
import { MAX_CONCURRENCY } from "./pool.js";
|
|
26
|
-
import { scriptCspSource } from "./report.js";
|
|
26
|
+
import { scriptCell, scriptCspSource } from "./report.js";
|
|
27
27
|
import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
|
|
28
28
|
import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
|
|
29
29
|
import { createProvider } from "./providers.js";
|
|
@@ -166,7 +166,8 @@ function cookieLines(session, step) {
|
|
|
166
166
|
/** One container load in words: its ID, what inserted it, its dataLayer and where it came from. */
|
|
167
167
|
function describeLoad(l) {
|
|
168
168
|
const c = messages("en").ui.containers;
|
|
169
|
-
const
|
|
169
|
+
const script = scriptCell(c, l);
|
|
170
|
+
const extra = [script,l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment), l.frame && c.iframe(l.frame.origin, l.frame.party)].filter(Boolean);
|
|
170
171
|
return ` ${l.id} (${c.script[l.script]}): ${c.kinds[l.kind](l.by)}${extra.length ? `; ${extra.join(", ")}` : ""}`;
|
|
171
172
|
}
|
|
172
173
|
|
|
@@ -189,7 +190,7 @@ export function stepCapture(audit, step) {
|
|
|
189
190
|
if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
|
|
190
191
|
if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
|
|
191
192
|
if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
|
|
192
|
-
const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), dataLayerLog: session.dataLayerLog });
|
|
193
|
+
const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
|
|
193
194
|
if (loads.length) lines.push("Google containers loaded:", ...loads.map(describeLoad));
|
|
194
195
|
lines.push(...cookieLines(session, step));
|
|
195
196
|
const tag = session.steps[step]?.tagConsent;
|
|
@@ -245,6 +246,11 @@ function containerJson(l) {
|
|
|
245
246
|
...(l.served === "site" ? { served_from: l.host } : {}),
|
|
246
247
|
...(l.environment ? { gtm_environment: l.environment } : {}),
|
|
247
248
|
...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
|
|
249
|
+
...(l.version ? { version: l.version } : {}),
|
|
250
|
+
...(l.bytes ? { size_bytes: l.bytes } : {}),
|
|
251
|
+
...(l.transferBytes ? { transfer_bytes: l.transferBytes } : {}),
|
|
252
|
+
...(l.ms != null ? { load_ms: l.ms } : {}),
|
|
253
|
+
...(l.failed ? { failed: l.failed } : {}),
|
|
248
254
|
...(l.pages ? { pages: l.pages } : {}),
|
|
249
255
|
};
|
|
250
256
|
}
|
|
@@ -257,7 +263,7 @@ export function containerLines(runs) {
|
|
|
257
263
|
return [
|
|
258
264
|
"Google containers (how each reached the page):",
|
|
259
265
|
...rows.map((r) => {
|
|
260
|
-
const extra = [r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
|
|
266
|
+
const extra = [scriptCell(c, r), r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
|
|
261
267
|
return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
|
|
262
268
|
}),
|
|
263
269
|
];
|
|
@@ -282,7 +288,7 @@ export function timelineOf(run) {
|
|
|
282
288
|
if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
|
|
283
289
|
const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
|
|
284
290
|
if (Object.keys(cookies).length) entry.cookies = cookies;
|
|
285
|
-
const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), dataLayerLog: run.dataLayerLog });
|
|
291
|
+
const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), containerScripts: run.containerScripts, dataLayerLog: run.dataLayerLog });
|
|
286
292
|
if (loads.length) entry.containers = loads.map(containerJson);
|
|
287
293
|
if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
|
|
288
294
|
if (st.identity) lastIds = st.identity;
|
|
@@ -531,7 +537,7 @@ export function buildServer(opts) {
|
|
|
531
537
|
},
|
|
532
538
|
async ({ audit_id }) => {
|
|
533
539
|
const { session } = getAudit(audit_id);
|
|
534
|
-
const loads = loadSources({ containerLoads: session.containerLoads ?? [], dataLayerLog: session.dataLayerLog });
|
|
540
|
+
const loads = loadSources({ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
|
|
535
541
|
if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
|
|
536
542
|
const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
|
|
537
543
|
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) })))}`);
|
package/src/report.js
CHANGED
|
@@ -397,15 +397,21 @@ function mdInventory(m, runs) {
|
|
|
397
397
|
return lines;
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
/** A container row's version, size and load time, or that it failed to load, in a few words. */
|
|
401
|
+
export function scriptCell(c, r) {
|
|
402
|
+
if (r.failed) return c.failed(r.failed);
|
|
403
|
+
return [r.version && c.version(r.version), r.bytes && c.size(r.bytes), r.ms != null && c.loadTime(r.ms)].filter(Boolean).join(", ");
|
|
404
|
+
}
|
|
405
|
+
|
|
400
406
|
/** The Google containers and how each reached the page, one row per container and source. Omitted when none loaded. */
|
|
401
407
|
function mdContainers(m, runs) {
|
|
402
408
|
const rows = containerRows(runs);
|
|
403
409
|
if (!rows.length) return [];
|
|
404
410
|
const c = m.ui.containers;
|
|
405
|
-
const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.pagesCol} |`, "
|
|
411
|
+
const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.scriptCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|---|"];
|
|
406
412
|
for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
|
|
407
413
|
const served = r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`;
|
|
408
|
-
lines.push(`| ${mdText(r.id)} (${c.script[r.script]}) | ${mdText(c.kinds[r.kind](r.by))}${r.frame ? `, ${mdText(c.iframe(r.frame.origin, r.frame.party))}` : ""}${r.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${r.pages.length} |`);
|
|
414
|
+
lines.push(`| ${mdText(r.id)} (${c.script[r.script]}) | ${mdText(c.kinds[r.kind](r.by))}${r.frame ? `, ${mdText(c.iframe(r.frame.origin, r.frame.party))}` : ""}${r.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${mdText(scriptCell(c, r)) || "–"} | ${r.pages.length} |`);
|
|
409
415
|
}
|
|
410
416
|
if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
|
|
411
417
|
lines.push("");
|