@analitikisler/ai-tag-debugger 0.21.0 → 0.22.1

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/src/mcp.js CHANGED
@@ -33,6 +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, siteIds } from "./frames.js";
36
38
  import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
37
39
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
38
40
 
@@ -90,25 +92,36 @@ const consentText = (c) => Object.entries(c).map(([k, v]) => `${k}=${v}`).join("
90
92
  /** The Google protocol fields of a hit, short: values for the ids and consent strings, bare names for the 1-flags. */
91
93
  const protocolText = (p) => Object.entries(p).map(([k, v]) => (["_fv", "_ss", "_nsi"].includes(k) && v === "1" ? k : `${k}=${v}`)).join(" ");
92
94
 
93
- function describeHit(h, steps, index) {
95
+ /** Where an iframe's hit came from, and whether the plan checks will count it (see src/frames.js). */
96
+ function frameNote(h, ids) {
97
+ if (!h.frame) return "";
98
+ if (h.frame.party === "site") return ` (from an iframe on the site: ${h.frame.origin})`;
99
+ return isWidgetHit(h, ids)
100
+ ? ` (from an embedded widget at ${h.frame.origin}, to an account the site's tags don't use: left out of the plan checks, kept for consent and personal data)`
101
+ : ` (from a third-party iframe at ${h.frame.origin}, to the site's own account)`;
102
+ }
103
+
104
+ function describeHit(h, steps, index, ids = new Set()) {
94
105
  const entries = Object.entries(h.params ?? {}).filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k));
95
106
  const params = [
96
107
  ...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
97
108
  ...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
98
109
  ].join(", ");
99
110
  const late = h.late ? ` (arrived ${(h.late.ms / 1000).toFixed(1)} s late, after the step that loaded its page: "${steps[h.step]?.label ?? `step ${h.step}`}")` : "";
100
- return `- [h${index}] ${h.platform} ${h.name}${h.consent ? ` [consent ${consentText(h.consent)}]` : ""}${h.protocol ? ` {${protocolText(h.protocol)}}` : ""}${params ? `: ${params}` : ""}${late}`;
111
+ return `- [h${index}] ${h.platform} ${h.name}${h.consent ? ` [consent ${consentText(h.consent)}]` : ""}${h.protocol ? ` {${protocolText(h.protocol)}}` : ""}${params ? `: ${params}` : ""}${late}${frameNote(h, ids)}`;
101
112
  }
102
113
 
103
114
  /** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
104
115
  function describePush(p) {
116
+ // Pushes to a dataLayer of another name (a container's l=) say which.
117
+ const layer = `${p.frame ? `[iframe ${p.frame.origin}] ` : ""}${p.layer ? `[${p.layer}] ` : ""}`;
105
118
  if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
106
119
  const signals = p.value[2] && typeof p.value[2] === "object" ? Object.entries(p.value[2]).map(([k, v]) => `${k}=${show(v)}`).join(" ") : show(p.value[2]);
107
- return `- consent ${p.value[1]}: ${signals}`;
120
+ return `- ${layer}consent ${p.value[1]}: ${signals}`;
108
121
  }
109
122
  const json = JSON.stringify(p.value) ?? String(p.value);
110
123
  const whole = json.length > PUSH_LIMIT ? wholeFieldsIn(p.value) : [];
111
- return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
124
+ return `- ${layer}${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
112
125
  }
113
126
 
114
127
  /** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent. */
@@ -150,9 +163,17 @@ function cookieLines(session, step) {
150
163
  return lines;
151
164
  }
152
165
 
166
+ /** One container load in words: its ID, what inserted it, its dataLayer and where it came from. */
167
+ function describeLoad(l) {
168
+ const c = messages("en").ui.containers;
169
+ const extra = [l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment), l.frame && c.iframe(l.frame.origin, l.frame.party)].filter(Boolean);
170
+ return ` ${l.id} (${c.script[l.script]}): ${c.kinds[l.kind](l.by)}${extra.length ? `; ${extra.join(", ")}` : ""}`;
171
+ }
172
+
153
173
  export function stepCapture(audit, step) {
154
174
  const { session } = audit;
155
175
  const hits = session.hits.filter((h) => h.step === step);
176
+ const ids = siteIds(session);
156
177
  const pushes = session.dataLayerLog.filter((e) => e.step === step);
157
178
  // Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
158
179
  const lateHere = session.hits.filter((h) => h.late?.during === step);
@@ -163,11 +184,13 @@ export function stepCapture(audit, step) {
163
184
  ...(problem?.kind === "crash" ? [`WARNING: the page crashed in the browser (the ${problem.vendor} error screen)${problem.refusedScripts ? `; ${problem.refusedScripts} of its own scripts came back as HTML pages, which bot protection or a mismatched deployment does` : ""}. Its tags may never have run.`] : []),
164
185
  `Step ${step}: "${session.steps[step]?.label ?? ""}". Read a hit's full parameters with get_hit (audit_id, step ${step}, its [h] number).`,
165
186
  `Tracking hits sent by this step: ${hits.length}`,
166
- ...hits.map((h, i) => describeHit(h, session.steps, i)),
187
+ ...hits.map((h, i) => describeHit(h, session.steps, i, ids)),
167
188
  ];
168
189
  if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
169
190
  if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
170
191
  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
+ if (loads.length) lines.push("Google containers loaded:", ...loads.map(describeLoad));
171
194
  lines.push(...cookieLines(session, step));
172
195
  const tag = session.steps[step]?.tagConsent;
173
196
  if (tag) {
@@ -214,6 +237,32 @@ export function hitDetail(audit, step, index) {
214
237
  * hits it sent with their consent, its consent commands and other tracking calls, the Google tag's consent when it
215
238
  * changed, and the cookies and ids that changed.
216
239
  */
240
+ /** A container load or row as compact JSON: the ID, its source, and the dataLayer, host and environment when they are not the usual ones. */
241
+ function containerJson(l) {
242
+ return {
243
+ id: l.id, source: l.kind, ...(l.by ? { by: l.by } : {}),
244
+ ...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
245
+ ...(l.served === "site" ? { served_from: l.host } : {}),
246
+ ...(l.environment ? { gtm_environment: l.environment } : {}),
247
+ ...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
248
+ ...(l.pages ? { pages: l.pages } : {}),
249
+ };
250
+ }
251
+
252
+ /** The Google containers of the runs and how each reached the page, as lines for a tool result; [] when none loaded. */
253
+ export function containerLines(runs) {
254
+ const rows = containerRows(runs);
255
+ if (!rows.length) return [];
256
+ const c = messages("en").ui.containers;
257
+ return [
258
+ "Google containers (how each reached the page):",
259
+ ...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);
261
+ return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
262
+ }),
263
+ ];
264
+ }
265
+
217
266
  export function timelineOf(run) {
218
267
  let lastIds = null;
219
268
  return (run.steps ?? []).map((st) => {
@@ -224,12 +273,17 @@ export function timelineOf(run) {
224
273
  const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
225
274
  const entry = { step: st.index, action: st.label, ...(st.pagePath ? { page: st.pagePath } : {}) };
226
275
  if (st.error) entry.error = st.error;
227
- 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 } : {}) }));
276
+ if (hits.length) entry.hits = hits.map((h) => ({ platform: h.platform, event: h.name, ...(h.consent ? { consent: h.consent } : {}), ...(h.late ? { late_ms: h.late.ms } : {}), ...(h.frame ? { iframe: h.frame.origin } : {}) }));
277
+ // An embedded widget's own hits, kept apart from the site's (see src/frames.js).
278
+ const widget = (run.widgetHits ?? []).filter((h) => h.step === st.index);
279
+ 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 } : {}) }));
228
280
  if (others.length) entry.other_tracking_calls = others.length;
229
281
  if (commands.length) entry.consent_commands = commands.map((e) => ({ [e.value[1]]: e.value[2] }));
230
282
  if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
231
283
  const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
232
284
  if (Object.keys(cookies).length) entry.cookies = cookies;
285
+ const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), dataLayerLog: run.dataLayerLog });
286
+ if (loads.length) entry.containers = loads.map(containerJson);
233
287
  if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
234
288
  if (st.identity) lastIds = st.identity;
235
289
  return entry;
@@ -466,15 +520,33 @@ export function buildServer(opts) {
466
520
  },
467
521
  );
468
522
 
523
+ server.registerTool(
524
+ "get_containers",
525
+ {
526
+ title: "List the Google containers loaded so far",
527
+ description:
528
+ "Returns every GTM container (gtm.js) and Google tag (gtag/js) this audit's pages loaded so far, and how each reached the page: a script tag in the HTML, the inline snippet, another script (named by its address, such as a consent platform), a Custom HTML tag or a Google tag in another GTM container, a container loading another itself (a Zone or a tag template), a gtag('config') command, an event's send_to with no config, or a destination of the Google tag. Also the dataLayer each reads, whether it was served from the site's own domain, the GTM environment when one is named, and the step it loaded at. Duplicates, GTM environments and split dataLayers are flagged by finish_audit.",
529
+ inputSchema: { audit_id: z.string() },
530
+ annotations: { readOnlyHint: true },
531
+ },
532
+ async ({ audit_id }) => {
533
+ const { session } = getAudit(audit_id);
534
+ const loads = loadSources({ containerLoads: session.containerLoads ?? [], dataLayerLog: session.dataLayerLog });
535
+ if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
536
+ const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
537
+ return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify(loads.map((l) => ({ step: l.step, page: l.page, ...containerJson(l) })))}`);
538
+ },
539
+ );
540
+
469
541
  server.registerTool(
470
542
  "get_report",
471
543
  {
472
544
  title: "Read a report this server wrote",
473
545
  description:
474
- "Returns a report finish_audit or run_audit wrote, so it can be read without access to the computer running this server. part markdown (default): report.md, the verdict, findings, consent and journeys in about 120 lines. summary: summary.json, the counts, journeys and findings as JSON. timeline: one JSON entry per step of each journey (hits with their consent, consent commands, the Google tag's consent, cookies and ids).",
546
+ "Returns a report finish_audit or run_audit wrote, so it can be read without access to the computer running this server. part markdown (default): report.md, the verdict, findings, consent and journeys in about 120 lines. summary: summary.json, the counts, journeys and findings as JSON. timeline: one JSON entry per step of each journey (hits with their consent, consent commands, the Google tag's consent, cookies and ids, the Google containers loaded). containers: every GTM container and Google tag the journeys loaded as JSON, with how each reached the page (source html, inline, script, custom_html, google_tag, nested, config, send_to or destination, and by: the container or script that loaded it), its dataLayer, host, GTM environment and pages.",
475
547
  inputSchema: {
476
548
  report: z.string().describe("The report name finish_audit or run_audit gave."),
477
- part: z.enum(["markdown", "summary", "timeline"]).optional().describe("Default markdown."),
549
+ part: z.enum(["markdown", "summary", "timeline", "containers"]).optional().describe("Default markdown."),
478
550
  },
479
551
  annotations: { readOnlyHint: true },
480
552
  },
@@ -485,6 +557,7 @@ export function buildServer(opts) {
485
557
  if (part === "markdown") return text(await readFile(path.join(dir, "report.md"), "utf8"));
486
558
  if (part === "summary") return text(await readFile(path.join(dir, "summary.json"), "utf8"));
487
559
  const capture = JSON.parse(await readFile(path.join(dir, "capture.json"), "utf8"));
560
+ if (part === "containers") return text(JSON.stringify(containerRows(capture.runs ?? []).map(containerJson)));
488
561
  return text(JSON.stringify((capture.runs ?? []).map((r) => ({ journey: r.journeyId, viewport: r.viewport, steps: timelineOf(r) }))));
489
562
  } catch (err) {
490
563
  return fail(`Could not read the report: ${err.message}`);
@@ -594,13 +667,14 @@ export function buildServer(opts) {
594
667
  `${run.hits.length} tracking hits, ${run.dataLayerLog.length} dataLayer pushes and ${run.network.length} analytics or ad network calls captured over ${run.steps.length} steps.`,
595
668
  skippedLine(run),
596
669
  ...((platform) => (platform ? [`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`] : []))(cmpFacts(messages("en"), [run])),
670
+ ...containerLines([run]),
597
671
  opts.allowFiles
598
672
  ? `Full report with the event timeline: ${reportPath}`
599
673
  : opts.reportsUrl
600
674
  ? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
601
675
  : "",
602
676
  "",
603
- `To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary or timeline).`,
677
+ `To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary, timeline or containers).`,
604
678
  "Explain each problem to the user with its likely cause and fix.",
605
679
  ];
606
680
  return {
@@ -746,8 +820,9 @@ export function buildServer(opts) {
746
820
  ...(result.baseline ? [`${result.baseline.accepted} accepted from the baseline${result.baseline.partial ? `, ${result.baseline.partial} accepted in some places only (still counted)` : ""}, ${result.baseline.stale.length} stale baseline entries`] : []),
747
821
  ...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
748
822
  ...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
823
+ ...containerLines(result.runs),
749
824
  `Report: ${result.reportPath}`,
750
- `To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary or timeline).`,
825
+ `To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary, timeline or containers).`,
751
826
  ].join("\n"),
752
827
  );
753
828
  },
package/src/report.js CHANGED
@@ -5,6 +5,7 @@ import { createProvider } from "./providers.js";
5
5
  import { canonicalEvent, groupFindings, summarize } from "./checks.js";
6
6
  import { messages } from "./i18n.js";
7
7
  import { buildInventory } from "./inventory.js";
8
+ import { containerRows } from "./containers.js";
8
9
  import { computeMetrics } from "./metrics.js";
9
10
  import { historySummaryLines, sparkline } from "./history.js";
10
11
  import { cardIds } from "./html/lib.js";
@@ -396,6 +397,21 @@ function mdInventory(m, runs) {
396
397
  return lines;
397
398
  }
398
399
 
400
+ /** The Google containers and how each reached the page, one row per container and source. Omitted when none loaded. */
401
+ function mdContainers(m, runs) {
402
+ const rows = containerRows(runs);
403
+ if (!rows.length) return [];
404
+ const c = m.ui.containers;
405
+ const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|"];
406
+ for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
407
+ const served = r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`;
408
+ lines.push(`| ${mdText(r.id)} (${c.script[r.script]}) | ${mdText(c.kinds[r.kind](r.by))}${r.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} |`);
409
+ }
410
+ if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
411
+ lines.push("");
412
+ return lines;
413
+ }
414
+
399
415
  /** The trend in up to five lines: the text sparkline with the direction, then the open-longest and flapping lines. */
400
416
  function mdTrend(m, trend, lang) {
401
417
  if (!trend || trend.runs < 2) return [];
@@ -447,6 +463,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
447
463
  lines.push(...mdJourneys(m, runs, rows));
448
464
  lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
449
465
  lines.push(...mdInventory(m, runs));
466
+ lines.push(...mdContainers(m, runs));
450
467
  // A report with its own name or logo carries no Analitik İşler credit; the ai tag debugger one stays.
451
468
  if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
452
469
  else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");