@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 +7 -0
- package/README.md +8 -0
- package/README.tr.md +8 -0
- package/package.json +1 -1
- package/src/agent.js +4 -3
- package/src/browser.js +19 -4
- package/src/checks.js +27 -12
- package/src/containers.js +3 -2
- package/src/discover.js +5 -2
- package/src/frames.js +90 -0
- package/src/html/scope.js +1 -1
- package/src/i18n.js +20 -0
- package/src/mcp.js +22 -7
- package/src/report.js +1 -1
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.
|
|
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
|
|
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", (
|
|
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", (
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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("");
|