@analitikisler/ai-tag-debugger 0.22.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1 (2026-11-04)
4
+
5
+ - 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.
6
+ - Fixed: hits sent from inside an iframe counted as the site's own tracking, so an embedded widget (a booking or chat widget, a payment form, a video) tracking to its vendor's own accounts could satisfy a planned event, be listed as the site's unplanned events, or move the scores. Hits, dataLayer pushes, vendor calls and container loads from an iframe now record its origin, the page it is embedded in and whether it is on the site's own site. A third-party iframe's hits to accounts the site's own tags don't use are the widget's: they are left out of plan matching, coverage, duplicates, unplanned events and the scores, and kept for the consent and personal data checks, whose findings name the widget. A plan drafted with `aitd plan` leaves them out too.
7
+ - New `widget_tracking` (info) finding: each embedded widget that tracked on its own, with its accounts and events. New `iframe_container` (info) finding: a container in a same-site iframe beside one in the page, which the page's dataLayer pushes and consent never reach.
8
+ - The Google containers card and `report.md` table say which containers ran in an iframe. MCP step results mark each hit from an iframe and whether the plan checks count it, and the JSON timeline lists widget hits per step under `widget_hits`.
9
+
3
10
  ## 0.22.0 (2026-11-04)
4
11
 
5
12
  - Runs can report findings 0.21.0 did not on the same site (`container_duplicate`, `container_environment`, `datalayer_split`, `container_send_to_only`). A baseline written with 0.21.0 can then list new entries; `--update-baseline` rewrites it.
package/README.md CHANGED
@@ -329,6 +329,14 @@ Each row also gives the dataLayer the container reads (`l=`), whether it came fr
329
329
 
330
330
  Four checks read it: `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment (`gtm_preview`, `gtm_auth`) loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination that only an event's `send_to` loaded, with no `gtag('config')` for it.
331
331
 
332
+ ### Embedded widgets and iframes
333
+
334
+ Hits, dataLayer pushes, vendor calls and container loads sent from inside an iframe record which iframe: its origin, the page it is embedded in, and whether it is on the site's own site (a checkout or form on a subdomain) or someone else's (a booking or chat widget, a payment form, a video). A hit from a third-party iframe still counts as the site's when it goes to an account the site's own tags use: a GA4 ID, Ads conversion ID, pixel or container the page or a same-site iframe uses or configures, as when a booking engine is set up with the site's GA4 ID. Otherwise it is the widget's own tracking.
335
+
336
+ A widget's own hits are left out of everything that judges the site's tracking: plan matching, coverage, duplicates, unplanned events and the scores. A widget's `purchase` to its vendor's property can't pass for the site's, and its events aren't listed as the site's. They still count for consent and personal data, since the site answers for what the widgets it embeds collect; those findings begin with "In an embedded widget from …". Each widget that tracked is named once in a `widget_tracking` (info) finding with its accounts and events, and a container running in a same-site iframe beside one in the page is noted as `iframe_container` (info): the page's dataLayer pushes and consent never reach it. A widget's dataLayer pushes are kept apart too, so its consent commands don't stand in for the site's.
337
+
338
+ `capture.json` keeps the widget's hits under `widgetHits` and its pushes under `widgetDataLayerLog`, and every iframe entry has a `frame`. The Google containers card says which ran in an iframe. Over MCP, step results mark each hit from an iframe and whether the plan checks will count it, pushes from an iframe are prefixed with it, and the JSON timeline lists a widget's hits per step under `widget_hits`. Captures from older versions have no frames, so all of their hits stay the site's.
339
+
332
340
  ### Folder layout and plan IDs
333
341
 
334
342
  Plans and runs are kept in `aitd/` in the folder you run the tool from:
package/README.tr.md CHANGED
@@ -329,6 +329,14 @@ Her satır ayrıca container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yo
329
329
 
330
330
  Dört kontrol bunu okur: aynı sayfaya birden fazla kez yüklenen bir container için `container_duplicate` (uyarı), Live yerine yüklenen bir GTM ortamı (`gtm_preview`, `gtm_auth`) için `container_environment` (uyarı), aynı sayfada farklı dataLayer'ları okuyan container'lar için `datalayer_split` (bilgi) ve kendisi için `gtag('config')` olmadan yalnızca bir olayın `send_to` değeriyle yüklenen bir hedef için `container_send_to_only` (bilgi).
331
331
 
332
+ ### Gömülü widget'lar ve iframe'ler
333
+
334
+ Bir iframe içinden gönderilen istekler, dataLayer push'ları, sağlayıcı çağrıları ve container yüklemeleri hangi iframe'den geldiklerini kaydeder: kaynağını, gömülü olduğu sayfayı ve sitenin kendi sitesinde mi (bir alt alan adındaki ödeme ya da form) yoksa başkasının mı (bir rezervasyon ya da sohbet widget'ı, bir ödeme formu, bir video) olduğunu. Üçüncü taraf bir iframe'den gelen istek, sitenin kendi etiketlerinin kullandığı bir hesaba gidiyorsa yine sitenindir: sayfanın ya da aynı sitedeki bir iframe'in kullandığı veya yapılandırdığı bir GA4 kimliği, Ads dönüşüm kimliği, piksel ya da container; örneğin sitenin GA4 kimliğiyle kurulmuş bir rezervasyon motoru. Aksi hâlde widget'ın kendi izlemesidir.
335
+
336
+ Bir widget'ın kendi istekleri, sitenin izlemesini değerlendiren her şeyin dışında tutulur: plan eşleştirme, kapsam, tekrarlar, planda olmayan olaylar ve puanlar. Widget'ın sağlayıcısının mülküne giden bir `purchase` sitenin yerine geçemez ve olayları sitenin olayları olarak listelenmez. Yine de onay ve kişisel veri için sayılırlar, çünkü site gömdüğü widget'ların topladıklarından sorumludur; bu bulgular "… kaynaklı gömülü bir widget içinde:" ile başlar. İzleme yapan her widget, hesapları ve olaylarıyla bir kez `widget_tracking` (bilgi) bulgusunda adlandırılır; sayfadakinin yanında aynı sitedeki bir iframe'de çalışan container ise `iframe_container` (bilgi) olarak not edilir: sayfanın dataLayer push'ları ve onayı ona hiç ulaşmaz. Widget'ın dataLayer push'ları da ayrı tutulur, böylece onay komutları sitenin komutlarının yerine geçmez.
337
+
338
+ `capture.json` widget'ın isteklerini `widgetHits`, push'larını `widgetDataLayerLog` altında saklar ve her iframe kaydında bir `frame` bulunur. Google container'ları kartı hangilerinin bir iframe'de çalıştığını söyler. MCP'de adım sonuçları iframe'den gelen her isteği ve plan kontrollerinin onu sayıp saymayacağını işaretler, iframe'den gelen push'ların başına iframe yazılır ve JSON zaman akışı bir widget'ın isteklerini adım adım `widget_hits` altında listeler. Eski sürümlerin kayıtlarında frame bilgisi yoktur, bu yüzden tüm istekleri sitenin olarak kalır.
339
+
332
340
  ### Klasör yapısı ve plan kimlikleri
333
341
 
334
342
  Planlar ve çalıştırmalar, aracı çalıştırdığınız klasördeki `aitd/` içinde tutulur:
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.0",
4
+ "version": "0.22.1",
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
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { AuditSession } from "./browser.js";
3
+ import { splitRun } from "./frames.js";
3
4
  import { withCampaignParams } from "./campaign.js";
4
5
  import { messages } from "./i18n.js";
5
6
  import { createProvider, PROVIDERS } from "./providers.js";
@@ -237,7 +238,7 @@ export const journeyError = (err) => `The journey stopped with an error: ${err?.
237
238
  export function failedRun(journey, viewport, err, providers) {
238
239
  return {
239
240
  journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
240
- cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
241
+ cookieLog: [], cookieChanges: [], dataLayerLog: [], containerLoads: [], network: [], widgetHits: [], widgetDataLayerLog: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
241
242
  };
242
243
  }
243
244
 
@@ -246,7 +247,8 @@ export function collect(session, journey, viewport) {
246
247
  journeyId: journey.id,
247
248
  viewport,
248
249
  ...(journey.goal ? { goal: journey.goal } : {}),
249
- hits: session.hits,
250
+ // The site's own hits and pushes; an embedded widget's are kept apart (see src/frames.js).
251
+ ...splitRun(session),
250
252
  steps: session.steps,
251
253
  consentStep: session.consentStep,
252
254
  rejectStep: session.rejectStep,
@@ -255,7 +257,6 @@ export function collect(session, journey, viewport) {
255
257
  cmp: session.cmp,
256
258
  // Hits let through to the real accounts (block_hits false, or a live MCP audit): the reports say so.
257
259
  ...(session.opts.blockHits === false ? { live: true } : {}),
258
- dataLayerLog: session.dataLayerLog,
259
260
  containerLoads: session.containerLoads,
260
261
  network: session.network,
261
262
  providers: session.providers,
package/src/browser.js CHANGED
@@ -11,6 +11,7 @@ import { startEgressProxy } from "./egress.js";
11
11
  import { campaignParamsIn } from "./campaign.js";
12
12
  import { detectCmpInPage } from "./cmp.js";
13
13
  import { callersOf, containerOf } from "./containers.js";
14
+ import { frameInfo } from "./frames.js";
14
15
 
15
16
  // Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
16
17
  // one of these always blocks the journey, and a reject click is checked against them afterwards.
@@ -587,11 +588,13 @@ export class AuditSession {
587
588
  }
588
589
  const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
589
590
  this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
590
- await this.context.exposeBinding("__atdRecord", (_source, entry) => {
591
+ await this.context.exposeBinding("__atdRecord", (source, entry) => {
592
+ const frame = frameInfo(source?.frame);
593
+ if (frame) entry = { ...entry, frame };
591
594
  this.lastTrackingAt = Date.now();
592
595
  this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
593
596
  });
594
- await this.context.exposeBinding("__atdLoad", (_source, entry) => this.recordLoad(entry));
597
+ await this.context.exposeBinding("__atdLoad", (source, entry) => this.recordLoad(entry, frameInfo(source?.frame)));
595
598
  await this.context.addInitScript(INIT_SCRIPT);
596
599
  this.page = await this.context.newPage();
597
600
  this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
@@ -619,9 +622,10 @@ export class AuditSession {
619
622
  const hits = parseTrackingRequest(request.url(), request.postData());
620
623
  if (!hits) return;
621
624
  const recorded = [];
625
+ const frame = this.frameOf(request);
622
626
  for (const hit of hits) {
623
627
  if (!this.checks(hit.platform)) continue;
624
- const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now() };
628
+ const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now(), ...(frame ? { frame } : {}) };
625
629
  const late = this.lateFor(hit, entry);
626
630
  if (late) Object.assign(entry, late);
627
631
  if (this.record("hits", entry)) recorded.push(entry);
@@ -660,6 +664,7 @@ export class AuditSession {
660
664
  ...this.storeBody(request),
661
665
  blocked: this.opts.blockHits && isCollectionEndpoint(request.url()),
662
666
  status: null,
667
+ ...((frame) => (frame ? { frame } : {}))(this.frameOf(request)),
663
668
  };
664
669
  if (this.record("network", call)) calls.set(request, call);
665
670
  });
@@ -823,7 +828,7 @@ export class AuditSession {
823
828
  * script did and, for a script, the callers on the stack. Only addresses without their query are kept, so
824
829
  * the environment token (gtm_auth) and user data in a page's query string never reach the capture.
825
830
  */
826
- recordLoad(entry) {
831
+ recordLoad(entry, frame = null) {
827
832
  const container = containerOf(entry?.src);
828
833
  if (!container) return;
829
834
  this.record("containerLoads", {
@@ -834,9 +839,19 @@ export class AuditSession {
834
839
  doc: String(entry.doc ?? "").slice(0, 16),
835
840
  step: this.currentStep,
836
841
  t: Date.now(),
842
+ ...(frame ? { frame } : {}),
837
843
  });
838
844
  }
839
845
 
846
+ /** The iframe a request came from, as frameInfo gives it; null for the page itself or a worker. */
847
+ frameOf(request) {
848
+ try {
849
+ return frameInfo(request.frame());
850
+ } catch {
851
+ return null; // service worker requests have no frame
852
+ }
853
+ }
854
+
840
855
  /** Adds an entry to one of the logs, up to its limit. Returns false once the limit is reached. */
841
856
  record(log, entry) {
842
857
  if (this[log].length >= LOG_LIMITS[log]) {
package/src/checks.js CHANGED
@@ -6,6 +6,7 @@ import { isOwnAddress } from "./redact.js";
6
6
  import { campaignParamsIn } from "./campaign.js";
7
7
  import { CONSENT_SIGNALS } from "./parsers.js";
8
8
  import { duplicateLoads, loadSources, splitLayers } from "./containers.js";
9
+ import { widgetsOf } from "./frames.js";
9
10
 
10
11
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
11
12
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
@@ -195,6 +196,11 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
195
196
  if (skipped.length) findings.push({ ...base, severity: "info", check: "planned_out_of_scope", detail: d.plannedOutOfScope(skipped.map((e) => key(e.platform, e.name))) });
196
197
  }
197
198
  const stepLabel = (i) => run.steps[i]?.label ?? `step ${i}`;
199
+ // Embedded widgets' hits (run.widgetHits, see src/frames.js) count for consent and personal data only: the site
200
+ // answers for what the widgets it embeds collect, but they say nothing about its own tracking plan.
201
+ const legalHits = [...run.hits, ...(run.widgetHits ?? [])].sort((a, b) => (a.t ?? 0) - (b.t ?? 0));
202
+ const legalKey = (hit) => `${key(hit.platform, hit.name)}${hit.frame?.party === "third" ? `|${hit.frame.origin}` : ""}`;
203
+ const fromWidget = (hit, text) => ((run.widgetHits ?? []).includes(hit) ? d.fromWidget(hit.frame.origin, text) : text);
198
204
  const planEventFor = (hit) => plan.events.find((e) => hitMatchesEvent(e, hit));
199
205
  const planKeyOf = (hit) => { const e = planEventFor(hit); return e ? key(e.platform, e.name) : key(hit.platform, hit.name); };
200
206
 
@@ -383,6 +389,15 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
383
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) });
384
390
  }
385
391
 
392
+ // Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
393
+ for (const w of widgetsOf(run.widgetHits ?? [])) {
394
+ const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
395
+ findings.push({ ...base, severity: "info", check: "widget_tracking", step: stepLabel(first.step), detail: d.widgetTracking(w.origin, w.platforms) });
396
+ }
397
+ // A container in a same-site iframe beside one in the page: two documents, two dataLayers.
398
+ const iframeLoad = loads.find((l) => l.frame?.party === "site" && loads.some((t) => !t.frame && t.step <= l.step));
399
+ if (iframeLoad) findings.push({ ...base, severity: "info", check: "iframe_container", step: stepLabel(iframeLoad.step), detail: d.iframeContainer(iframeLoad.id, iframeLoad.frame.origin, iframeLoad.frame.parent) });
400
+
386
401
  // One event ID on two different events: the platform keeps the first and drops the other as its
387
402
  // duplicate, so a real event is lost (usually an ID set once per page instead of once per event).
388
403
  const byId = new Map();
@@ -518,15 +533,15 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
518
533
  if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
519
534
  else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
520
535
  const afterReject = new Map();
521
- for (const hit of run.hits) {
536
+ for (const hit of legalHits) {
522
537
  if (hit.step < rejectStep) continue;
523
538
  if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted(hit)) {
524
- const k = key(hit.platform, hit.name);
539
+ const k = legalKey(hit);
525
540
  if (!afterReject.has(k)) afterReject.set(k, hit);
526
541
  }
527
542
  }
528
543
  for (const [, hit] of afterReject) {
529
- findings.push({ ...base, severity: "broken", check: "after_reject", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.afterReject(hit.platform, hit.name) });
544
+ findings.push({ ...base, severity: "broken", check: "after_reject", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: fromWidget(hit, d.afterReject(hit.platform, hit.name)) });
530
545
  }
531
546
  }
532
547
 
@@ -546,14 +561,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
546
561
  }
547
562
 
548
563
  const beforeConsent = new Map();
549
- for (const hit of run.hits) {
564
+ for (const hit of legalHits) {
550
565
  // Hits from the reject click on are covered by the after_reject check.
551
566
  if (rejectStep !== null && hit.step >= rejectStep) continue;
552
567
  const early = granted === null || hit.step < granted;
553
568
  if (!early) continue;
554
569
  const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
555
570
  if (adPlatform || claimsGranted(hit)) {
556
- const k = key(hit.platform, hit.name);
571
+ const k = legalKey(hit);
557
572
  if (!beforeConsent.has(k)) beforeConsent.set(k, hit);
558
573
  }
559
574
  }
@@ -565,26 +580,26 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
565
580
  platform: hit.platform,
566
581
  event: hit.name,
567
582
  step: stepLabel(hit.step),
568
- detail: granted === null ? d.beforeConsentNever(hit.platform, hit.name) : d.beforeConsent(hit.platform, hit.name),
583
+ detail: fromWidget(hit, granted === null ? d.beforeConsentNever(hit.platform, hit.name) : d.beforeConsent(hit.platform, hit.name)),
569
584
  });
570
585
  }
571
586
 
572
587
  // Personal data in hits.
573
588
  const piiSeen = new Set();
574
- for (const hit of run.hits) {
589
+ for (const hit of legalHits) {
575
590
  let url = hit.url;
576
591
  try {
577
592
  url = decodeURIComponent(hit.url);
578
593
  } catch { /* malformed % sequence, e.g. "50%": search the raw URL */ }
579
594
  const haystack = url + " " + JSON.stringify(hit.params);
580
- if (piiSeen.has(key(hit.platform, hit.name))) continue;
595
+ if (piiSeen.has(legalKey(hit))) continue;
581
596
  // User data for ad matching is redacted at capture; userData.plain lists the fields that were not hashed.
582
597
  if (hit.userData?.plain?.length) {
583
- piiSeen.add(key(hit.platform, hit.name));
584
- findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.piiUnhashed(hit.platform, hit.name, hit.userData.plain) });
598
+ piiSeen.add(legalKey(hit));
599
+ findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: fromWidget(hit, d.piiUnhashed(hit.platform, hit.name, hit.userData.plain)) });
585
600
  } else if (visitorEmailIn(haystack, hit)) {
586
- piiSeen.add(key(hit.platform, hit.name));
587
- findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.pii(hit.platform, hit.name) });
601
+ piiSeen.add(legalKey(hit));
602
+ findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: fromWidget(hit, d.pii(hit.platform, hit.name)) });
588
603
  }
589
604
  }
590
605
 
package/src/containers.js CHANGED
@@ -114,16 +114,17 @@ export function loadSources(run) {
114
114
 
115
115
  /**
116
116
  * One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
117
- * environment?, kind, by?, pages, journeys }.
117
+ * environment?, kind, by?, frame?, pages, journeys }, where frame is the iframe it loaded in, if any.
118
118
  */
119
119
  export function containerRows(runs) {
120
120
  const rows = new Map();
121
121
  for (const run of runs) {
122
122
  for (const l of loadSources(run)) {
123
- const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? ""].join("|");
123
+ const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? "", l.frame?.origin ?? ""].join("|");
124
124
  const row = rows.get(k) ?? {
125
125
  id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
126
126
  ...(l.environment ? { environment: l.environment } : {}), kind: l.kind, ...(l.by ? { by: l.by } : {}),
127
+ ...(l.frame ? { frame: { origin: l.frame.origin, party: l.frame.party } } : {}),
127
128
  pages: new Set(), journeys: new Set(),
128
129
  };
129
130
  if (l.page) row.pages.add(l.page);
package/src/discover.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AuditSession } from "./browser.js";
2
2
  import { runAgentJourney, StepTimeout } from "./agent.js";
3
+ import { splitRun } from "./frames.js";
3
4
 
4
5
  /** The goal of the browsing part: one page of each type, nothing submitted. */
5
6
  export const DISCOVER_GOAL = `Explore this site to draft its analytics measurement plan. You are not completing a purchase or any other task: visit ONE example of each kind of page that exists here and look at what a visitor can do on it, then call finish with success true.
@@ -169,14 +170,16 @@ export async function discoverPlan(opts) {
169
170
  const pages = pagesSeen(snapshots);
170
171
  if (!pages.length) throw new Error("The explorer saw no pages, so there is nothing to plan from.");
171
172
  log(`\nExplored ${pages.length} pages${outcome.completed ? "" : ` (${outcome.note})`}. Drafting the plan…`);
172
- const existing = [...new Set(session.hits.map((h) => `${h.platform}:${h.name}`))];
173
+ // An embedded widget's hits are not the site's tracking, so the plan leaves them out.
174
+ const ownHits = splitRun(session).hits;
175
+ const existing = [...new Set(ownHits.map((h) => `${h.platform}:${h.name}`))];
173
176
  const result = await opts.provider.json({
174
177
  system: topic ? `${PLAN_SYSTEM}\nThis plan covers only ${opts.topic}. Propose only ${topic.propose}.` : PLAN_SYSTEM,
175
178
  texts: [`Site: ${opts.site}`, `Pages visited:\n${JSON.stringify(pages, null, 1)}`, `Tracking hits the site already sends: ${existing.length ? existing.join(", ") : "none"}`],
176
179
  schema: PLAN_SCHEMA,
177
180
  });
178
181
  if (result.error) throw new Error(`The model did not return a plan (${result.error}).`);
179
- const plan = toPlan(opts.site, result.value, session.hits);
182
+ const plan = toPlan(opts.site, result.value, ownHits);
180
183
  if (topic) plan.events = plan.events.filter(topic.keep);
181
184
  const coverage = pageTypeCoverage(plan.pages);
182
185
  log(`Page types found: ${coverage.found.join(", ") || "none"}`);
package/src/frames.js ADDED
@@ -0,0 +1,90 @@
1
+ // Whose tracking a hit is: the site's own, or an embedded widget's. Hits, dataLayer pushes, vendor calls and
2
+ // container loads from an iframe carry { origin, party, parent }: party "site" when the iframe is on the page's
3
+ // own site (a checkout or form on a subdomain), "third" when it is someone else's (a booking or chat widget,
4
+ // a payment form, a video). A third-party iframe's hit is still the site's when it goes to an account the
5
+ // site's own tags use (a booking engine set up with the site's GA4 ID); otherwise it is the widget's.
6
+ import { siteOf } from "./redact.js";
7
+
8
+ /**
9
+ * A Playwright frame as { origin, party, parent }, or null for a page's main frame and for frames without an
10
+ * http(s) address (about:blank, srcdoc), which the page's own scripts write. parent is the address of the
11
+ * page it is embedded in, without its query.
12
+ */
13
+ export function frameInfo(frame) {
14
+ if (!frame) return null;
15
+ let top;
16
+ try {
17
+ top = frame.page()?.mainFrame();
18
+ } catch {
19
+ return null;
20
+ }
21
+ if (!top || frame === top) return null;
22
+ let u;
23
+ let t;
24
+ try {
25
+ u = new URL(frame.url());
26
+ t = new URL(top.url());
27
+ } catch {
28
+ return null;
29
+ }
30
+ if (!/^https?:$/.test(u.protocol) || !/^https?:$/.test(t.protocol)) return null;
31
+ return { origin: u.origin, party: siteOf(u.hostname) === siteOf(t.hostname) ? "site" : "third", parent: t.origin + t.pathname };
32
+ }
33
+
34
+ /** Whether a hit, push or load came from the page itself or an iframe on the same site. */
35
+ export const isSiteFrame = (x) => !x?.frame || x.frame.party !== "third";
36
+
37
+ // Account IDs as the hits and the containers name them: GA4's G-…, Google Ads' AW-… (a numeric conversion ID in
38
+ // its hits), Meta's pixel ID and so on. Compared per platform family, without the AW- prefix.
39
+ const idKey = (id) => String(id).trim().toUpperCase().replace(/^AW-/, "");
40
+
41
+ /**
42
+ * The account IDs the site's own tags use: the containers loaded in the page or a same-site iframe, the IDs its
43
+ * hits went to, and the destinations its gtag('config') commands name.
44
+ */
45
+ export function siteIds({ hits = [], containerLoads = [], dataLayerLog = [] }) {
46
+ const ids = new Set();
47
+ for (const l of containerLoads) if (isSiteFrame(l)) ids.add(idKey(l.id));
48
+ for (const h of hits) if (isSiteFrame(h) && h.id) ids.add(idKey(h.id));
49
+ for (const e of dataLayerLog) {
50
+ if (isSiteFrame(e) && Array.isArray(e.value) && e.value[0] === "config" && typeof e.value[1] === "string") ids.add(idKey(e.value[1]));
51
+ }
52
+ return ids;
53
+ }
54
+
55
+ /** Whether a hit is an embedded widget's: sent from a third-party iframe to an account the site's tags don't use. */
56
+ export function isWidgetHit(hit, ids) {
57
+ return !isSiteFrame(hit) && !(hit.id && ids.has(idKey(hit.id)));
58
+ }
59
+
60
+ /**
61
+ * The run's hits and dataLayer pushes split into the site's own and the widgets': { hits, widgetHits,
62
+ * dataLayerLog, widgetDataLayerLog }. Plan, coverage and duplicate checks read only the site's; the consent and
63
+ * personal data checks read both, since the site answers for what the widgets it embeds collect.
64
+ */
65
+ export function splitRun({ hits = [], containerLoads = [], dataLayerLog = [] }) {
66
+ const ids = siteIds({ hits, containerLoads, dataLayerLog });
67
+ return {
68
+ hits: hits.filter((h) => !isWidgetHit(h, ids)),
69
+ widgetHits: hits.filter((h) => isWidgetHit(h, ids)),
70
+ dataLayerLog: dataLayerLog.filter(isSiteFrame),
71
+ widgetDataLayerLog: dataLayerLog.filter((e) => !isSiteFrame(e)),
72
+ };
73
+ }
74
+
75
+ /** The widgets that sent hits, by iframe origin: [{ origin, parent, platforms: [{ platform, ids, events }] }]. */
76
+ export function widgetsOf(widgetHits = []) {
77
+ const byOrigin = new Map();
78
+ for (const h of widgetHits) {
79
+ const w = byOrigin.get(h.frame.origin) ?? { origin: h.frame.origin, parent: h.frame.parent, platforms: new Map() };
80
+ const p = w.platforms.get(h.platform) ?? { platform: h.platform, ids: new Set(), events: new Set() };
81
+ if (h.id) p.ids.add(h.id);
82
+ p.events.add(h.name);
83
+ w.platforms.set(h.platform, p);
84
+ byOrigin.set(h.frame.origin, w);
85
+ }
86
+ return [...byOrigin.values()].map((w) => ({
87
+ origin: w.origin, parent: w.parent,
88
+ platforms: [...w.platforms.values()].map((p) => ({ platform: p.platform, ids: [...p.ids], events: [...p.events] })),
89
+ }));
90
+ }
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.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.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
@@ -213,6 +213,7 @@ const en = {
213
213
  script: { gtm: "GTM container", gtag: "Google tag" },
214
214
  served: { google: "Google", site: "The site's own domain" },
215
215
  environment: (env) => (env === "unnamed" ? "GTM environment" : `GTM environment ${env}`),
216
+ iframe: (origin, party) => `In an iframe from ${origin} (${party === "site" ? "same site" : "third party"})`,
216
217
  kinds: {
217
218
  html: () => "A script tag in the page's HTML",
218
219
  inline: () => "Inline code in the page (the snippet)",
@@ -350,6 +351,9 @@ const en = {
350
351
  containerEnvironment: (id, env) => `${id} is loaded from a GTM environment (${env === "unnamed" ? "gtm_auth" : `gtm_preview=${env}`}) instead of the Live version, so visitors run whatever was last published to that environment, often a staging or development version.`,
351
352
  dataLayerSplit: (list, step) => `Containers on one page at "${step}" read different dataLayers: ${list.join(", ")}. A push to one is never seen by the containers reading another, so events and consent commands may reach only some of them.`,
352
353
  sendToOnly: (id, by) => `${id} was loaded only because an event named it in send_to; no gtag('config', '${id}') ran. ${by} sends to it with default settings, so whatever a config command would set for it does not apply.`,
354
+ fromWidget: (origin, text) => `In an embedded widget from ${origin}: ${text}`,
355
+ 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
+ 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.`,
353
357
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
354
358
  consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
355
359
  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.",
@@ -409,6 +413,8 @@ const en = {
409
413
  container_environment: "Visitors run a non-Live version of the container, so tags still under test, or an old setup, collect production data.",
410
414
  datalayer_split: "Events and consent commands pushed to one dataLayer never reach the containers reading another, so some tags miss them.",
411
415
  container_send_to_only: "The destination runs without its own config, so the settings a config command would give it are missing.",
416
+ 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
+ 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.",
412
418
  campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
413
419
  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.",
414
420
  event_id_mismatch: "The browser and server copies of this event carry different IDs, so Meta cannot pair them and counts the conversion twice.",
@@ -455,6 +461,8 @@ const en = {
455
461
  container_environment: "Use the Live snippet in production: take gtm_auth and gtm_preview out of the GTM snippet, or publish what the environment holds to Live.",
456
462
  datalayer_split: "Point every container at the same dataLayer, or push events and consent commands to each dataLayer the containers read.",
457
463
  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
+ 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
+ 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.",
458
466
  campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
459
467
  event_id_reused: "Generate a new event ID for every event, not once per page load.",
460
468
  event_id_mismatch: "Use one event ID for the pixel event and the server copy: pass the same value to both tags.",
@@ -501,6 +509,8 @@ const en = {
501
509
  container_environment: "hygiene",
502
510
  datalayer_split: "hygiene",
503
511
  container_send_to_only: "hygiene",
512
+ widget_tracking: "hygiene",
513
+ iframe_container: "hygiene",
504
514
  campaign_params: "money",
505
515
  event_id_reused: "money",
506
516
  event_id_mismatch: "money",
@@ -1030,6 +1040,7 @@ const tr = {
1030
1040
  script: { gtm: "GTM container'ı", gtag: "Google etiketi" },
1031
1041
  served: { google: "Google", site: "Sitenin kendi alan adı" },
1032
1042
  environment: (env) => (env === "unnamed" ? "GTM ortamı" : `GTM ortamı ${env}`),
1043
+ iframe: (origin, party) => `${origin} kaynaklı bir iframe içinde (${party === "site" ? "aynı site" : "üçüncü taraf"})`,
1033
1044
  kinds: {
1034
1045
  html: () => "Sayfanın HTML'indeki bir script etiketi",
1035
1046
  inline: () => "Sayfadaki satır içi kod (snippet)",
@@ -1163,6 +1174,9 @@ const tr = {
1163
1174
  containerEnvironment: (id, env) => `${id}, Live sürüm yerine bir GTM ortamından (${env === "unnamed" ? "gtm_auth" : `gtm_preview=${env}`}) yükleniyor; ziyaretçiler o ortama en son ne yayımlandıysa onu, çoğu zaman bir test veya geliştirme sürümünü çalıştırıyor.`,
1164
1175
  dataLayerSplit: (list, step) => `"${step}" adımında aynı sayfadaki container'lar farklı dataLayer'ları okuyor: ${list.join(", ")}. Birine yapılan push diğerini okuyan container'lar tarafından hiç görülmez; olaylar ve onay komutları yalnızca bazılarına ulaşabilir.`,
1165
1176
  sendToOnly: (id, by) => `${id} yalnızca bir olay send_to ile onu adlandırdığı için yüklendi; gtag('config', '${id}') hiç çalışmadı. ${by} ona varsayılan ayarlarla gönderiyor, bu yüzden bir config komutunun onun için ayarlayacağı hiçbir şey uygulanmıyor.`,
1177
+ fromWidget: (origin, text) => `${origin} kaynaklı gömülü bir widget içinde: ${text}`,
1178
+ 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
+ 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.`,
1166
1180
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
1167
1181
  consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
1168
1182
  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.",
@@ -1221,6 +1235,8 @@ const tr = {
1221
1235
  container_environment: "Ziyaretçiler container'ın Live olmayan bir sürümünü çalıştırıyor; hâlâ test edilen etiketler ya da eski bir kurulum canlı veri topluyor.",
1222
1236
  datalayer_split: "Bir dataLayer'a gönderilen olaylar ve onay komutları diğerini okuyan container'lara hiç ulaşmaz; bazı etiketler onları kaçırır.",
1223
1237
  container_send_to_only: "Hedef kendi config'i olmadan çalışıyor; bir config komutunun ona vereceği ayarlar eksik.",
1238
+ widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
1239
+ 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.",
1224
1240
  campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
1225
1241
  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.",
1226
1242
  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.",
@@ -1265,6 +1281,8 @@ const tr = {
1265
1281
  container_environment: "Canlı sitede Live snippet'ini kullanın: GTM snippet'inden gtm_auth ve gtm_preview'ı çıkarın ya da ortamdaki sürümü Live'a yayımlayın.",
1266
1282
  datalayer_split: "Tüm container'ları aynı dataLayer'a yönlendirin ya da olayları ve onay komutlarını container'ların okuduğu her dataLayer'a gönderin.",
1267
1283
  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
+ 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
+ iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
1268
1286
  campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
1269
1287
  event_id_reused: "Her olay için yeni bir olay kimliği üretin; sayfa yüklemesi başına bir kez değil.",
1270
1288
  event_id_mismatch: "Piksel olayı ve sunucu kopyası için tek bir olay kimliği kullanın: aynı değeri iki etikete de verin.",
@@ -1310,6 +1328,8 @@ const tr = {
1310
1328
  container_environment: "hygiene",
1311
1329
  datalayer_split: "hygiene",
1312
1330
  container_send_to_only: "hygiene",
1331
+ widget_tracking: "hygiene",
1332
+ iframe_container: "hygiene",
1313
1333
  campaign_params: "money",
1314
1334
  event_id_reused: "money",
1315
1335
  event_id_mismatch: "money",
package/src/mcp.js CHANGED
@@ -34,6 +34,7 @@ import { hitParams } from "./hit-params.js";
34
34
  import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
35
35
  import { cmpFacts } from "./cmp.js";
36
36
  import { containerRows, loadSources } from "./containers.js";
37
+ import { isWidgetHit, siteIds } from "./frames.js";
37
38
  import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
38
39
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
39
40
 
@@ -91,20 +92,29 @@ const consentText = (c) => Object.entries(c).map(([k, v]) => `${k}=${v}`).join("
91
92
  /** The Google protocol fields of a hit, short: values for the ids and consent strings, bare names for the 1-flags. */
92
93
  const protocolText = (p) => Object.entries(p).map(([k, v]) => (["_fv", "_ss", "_nsi"].includes(k) && v === "1" ? k : `${k}=${v}`)).join(" ");
93
94
 
94
- 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()) {
95
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));
96
106
  const params = [
97
107
  ...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
98
108
  ...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
99
109
  ].join(", ");
100
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}`}")` : "";
101
- 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)}`;
102
112
  }
103
113
 
104
114
  /** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
105
115
  function describePush(p) {
106
116
  // Pushes to a dataLayer of another name (a container's l=) say which.
107
- const layer = p.layer ? `[${p.layer}] ` : "";
117
+ const layer = `${p.frame ? `[iframe ${p.frame.origin}] ` : ""}${p.layer ? `[${p.layer}] ` : ""}`;
108
118
  if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
109
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]);
110
120
  return `- ${layer}consent ${p.value[1]}: ${signals}`;
@@ -156,13 +166,14 @@ function cookieLines(session, step) {
156
166
  /** One container load in words: its ID, what inserted it, its dataLayer and where it came from. */
157
167
  function describeLoad(l) {
158
168
  const c = messages("en").ui.containers;
159
- const extra = [l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment)].filter(Boolean);
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);
160
170
  return ` ${l.id} (${c.script[l.script]}): ${c.kinds[l.kind](l.by)}${extra.length ? `; ${extra.join(", ")}` : ""}`;
161
171
  }
162
172
 
163
173
  export function stepCapture(audit, step) {
164
174
  const { session } = audit;
165
175
  const hits = session.hits.filter((h) => h.step === step);
176
+ const ids = siteIds(session);
166
177
  const pushes = session.dataLayerLog.filter((e) => e.step === step);
167
178
  // Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
168
179
  const lateHere = session.hits.filter((h) => h.late?.during === step);
@@ -173,7 +184,7 @@ export function stepCapture(audit, step) {
173
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.`] : []),
174
185
  `Step ${step}: "${session.steps[step]?.label ?? ""}". Read a hit's full parameters with get_hit (audit_id, step ${step}, its [h] number).`,
175
186
  `Tracking hits sent by this step: ${hits.length}`,
176
- ...hits.map((h, i) => describeHit(h, session.steps, i)),
187
+ ...hits.map((h, i) => describeHit(h, session.steps, i, ids)),
177
188
  ];
178
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(", ")}`);
179
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));
@@ -233,6 +244,7 @@ function containerJson(l) {
233
244
  ...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
234
245
  ...(l.served === "site" ? { served_from: l.host } : {}),
235
246
  ...(l.environment ? { gtm_environment: l.environment } : {}),
247
+ ...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
236
248
  ...(l.pages ? { pages: l.pages } : {}),
237
249
  };
238
250
  }
@@ -245,7 +257,7 @@ export function containerLines(runs) {
245
257
  return [
246
258
  "Google containers (how each reached the page):",
247
259
  ...rows.map((r) => {
248
- const extra = [r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
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);
249
261
  return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
250
262
  }),
251
263
  ];
@@ -261,7 +273,10 @@ export function timelineOf(run) {
261
273
  const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
262
274
  const entry = { step: st.index, action: st.label, ...(st.pagePath ? { page: st.pagePath } : {}) };
263
275
  if (st.error) entry.error = st.error;
264
- 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 } : {}) }));
265
280
  if (others.length) entry.other_tracking_calls = others.length;
266
281
  if (commands.length) entry.consent_commands = commands.map((e) => ({ [e.value[1]]: e.value[2] }));
267
282
  if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
package/src/report.js CHANGED
@@ -405,7 +405,7 @@ function mdContainers(m, runs) {
405
405
  const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|"];
406
406
  for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
407
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.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${r.pages.length} |`);
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
409
  }
410
410
  if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
411
411
  lines.push("");