@analitikisler/ai-tag-debugger 0.22.0 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +10 -2
- package/README.tr.md +10 -2
- package/package.json +1 -1
- package/src/agent.js +5 -3
- package/src/browser.js +52 -6
- package/src/checks.js +33 -13
- package/src/containers.js +41 -4
- package/src/discover.js +5 -2
- package/src/frames.js +90 -0
- package/src/html/scope.js +1 -1
- package/src/i18n.js +38 -0
- package/src/mcp.js +32 -11
- package/src/report.js +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.2 (2026-11-04)
|
|
4
|
+
|
|
5
|
+
- Runs can report a finding 0.22.1 did not on the same site (`container_failed`). A baseline written with 0.22.1 can then list new entries; `--update-baseline` rewrites it.
|
|
6
|
+
- Each Google container shows its published version (the number its `gtm.js` or `gtag/js` data opens with, as in GTM's Versions tab), the script's size and how long it took to load, in the Google containers card, `report.md` (a new **Version and size** column) and over MCP: step results, `get_containers`, `finish_audit`, `run_audit`, the JSON timeline and `get_report` with `part: "containers"` (`version`, `size_bytes`, `transfer_bytes`, `load_ms`). `capture.json` keeps each container script's answer under `containerScripts`.
|
|
7
|
+
- New check `container_failed` (warning): a container script that failed to load or was answered with an error status, such as a wrong container ID or a server container that doesn't serve `gtm.js`, so none of its tags ran on that page.
|
|
8
|
+
|
|
9
|
+
## 0.22.1 (2026-11-04)
|
|
10
|
+
|
|
11
|
+
- Runs can report different findings than 0.22.0 on the same site: an embedded widget's own hits no longer count as the site's, so a planned event only a widget sent is now missing, and the new `widget_tracking` and `iframe_container` (info) findings appear. A baseline written with 0.22.0 can then list stale or new entries; `--update-baseline` rewrites it.
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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`.
|
|
15
|
+
|
|
3
16
|
## 0.22.0 (2026-11-04)
|
|
4
17
|
|
|
5
18
|
- 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
|
@@ -325,9 +325,17 @@ Under the inventory, **Google containers** lists every GTM container (`gtm.js`)
|
|
|
325
325
|
| An event's `send_to` with no config | The Google tag loaded the destination only because an event was sent to it |
|
|
326
326
|
| A destination of the Google tag | The Google tag loaded it with no command in the page, such as a linked destination |
|
|
327
327
|
|
|
328
|
-
Each row also gives the dataLayer the container reads (`l=`), whether it came from Google or from the site's own domain (the Google tag gateway or a server container), the GTM environment when the snippet names one, and the pages it loaded on. A container reading a dataLayer of another name has its pushes recorded too, marked with that name. `report.md` has the same table. Over MCP, step results and `get_step` list the containers each step loaded, `get_containers` lists them all while the audit is open, `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "containers"` and the JSON timeline carries them per step. `capture.json` keeps the loads under `containerLoads`. The environment token (`gtm_auth`) and the page's query string are never kept.
|
|
328
|
+
Each row also gives the container's published version (the number its `gtm.js` or `gtag/js` data opens with, as in GTM's Versions tab), the script's size and how long it took to load, the dataLayer the container reads (`l=`), whether it came from Google or from the site's own domain (the Google tag gateway or a server container), the GTM environment when the snippet names one, and the pages it loaded on. A container reading a dataLayer of another name has its pushes recorded too, marked with that name. `report.md` has the same table. Over MCP, step results and `get_step` list the containers each step loaded, `get_containers` lists them all while the audit is open, `finish_audit` and `run_audit` end with them, `get_report` returns them with `part: "containers"` and the JSON timeline carries them per step. `capture.json` keeps the loads under `containerLoads`. The environment token (`gtm_auth`) and the page's query string are never kept.
|
|
329
329
|
|
|
330
|
-
|
|
330
|
+
Five checks read it: `container_failed` (warning) for a container script that failed to load or was answered with an error, so none of its tags ran, `container_duplicate` (warning) for a container loaded more than once into one page, `container_environment` (warning) for a GTM environment (`gtm_preview`, `gtm_auth`) loaded instead of Live, `datalayer_split` (info) for containers on one page reading different dataLayers, and `container_send_to_only` (info) for a destination that only an event's `send_to` loaded, with no `gtag('config')` for it.
|
|
331
|
+
|
|
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.
|
|
331
339
|
|
|
332
340
|
### Folder layout and plan IDs
|
|
333
341
|
|
package/README.tr.md
CHANGED
|
@@ -325,9 +325,17 @@ Envanterin altında **Google container'ları**, sayfaların yüklediği her GTM
|
|
|
325
325
|
| config olmadan bir olayın `send_to` değeri | Google etiketi hedefi yalnızca ona bir olay gönderildiği için yükledi |
|
|
326
326
|
| Google etiketinin bir hedefi | Google etiketi onu sayfada bir komut olmadan yükledi, örneğin bağlı bir hedef |
|
|
327
327
|
|
|
328
|
-
Her satır ayrıca container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yoksa sitenin kendi alan adından mı (Google etiket ağ geçidi ya da bir sunucu container'ı) geldiğini, snippet bir GTM ortamı adlandırıyorsa onu ve yüklendiği sayfaları verir. Başka adlı bir dataLayer okuyan container'ın push'ları da o adla işaretlenerek kaydedilir. `report.md` aynı tabloyu içerir. MCP'de adım sonuçları ve `get_step` her adımın yüklediği container'ları listeler, `get_containers` denetim açıkken hepsini listeler, `finish_audit` ve `run_audit` sonuçları onlarla biter, `get_report` onları `part: "containers"` ile döndürür ve JSON zaman akışı onları adım adım taşır. `capture.json` yüklemeleri `containerLoads` altında saklar. Ortam belirteci (`gtm_auth`) ve sayfanın sorgu dizesi asla saklanmaz.
|
|
328
|
+
Her satır ayrıca container'ın yayımlanmış sürümünü (`gtm.js` ya da `gtag/js` verisinin başladığı numara; GTM'deki Sürümler sekmesindekiyle aynı), betiğin boyutunu ve ne kadar sürede yüklendiğini, container'ın okuduğu dataLayer'ı (`l=`), Google'dan mı yoksa sitenin kendi alan adından mı (Google etiket ağ geçidi ya da bir sunucu container'ı) geldiğini, snippet bir GTM ortamı adlandırıyorsa onu ve yüklendiği sayfaları verir. Başka adlı bir dataLayer okuyan container'ın push'ları da o adla işaretlenerek kaydedilir. `report.md` aynı tabloyu içerir. MCP'de adım sonuçları ve `get_step` her adımın yüklediği container'ları listeler, `get_containers` denetim açıkken hepsini listeler, `finish_audit` ve `run_audit` sonuçları onlarla biter, `get_report` onları `part: "containers"` ile döndürür ve JSON zaman akışı onları adım adım taşır. `capture.json` yüklemeleri `containerLoads` altında saklar. Ortam belirteci (`gtm_auth`) ve sayfanın sorgu dizesi asla saklanmaz.
|
|
329
329
|
|
|
330
|
-
|
|
330
|
+
Beş kontrol bunu okur: yüklenemeyen ya da hatayla yanıtlanan, bu yüzden etiketlerinin hiçbiri çalışmayan bir container betiği için `container_failed` (uyarı), aynı sayfaya birden fazla kez yüklenen bir container için `container_duplicate` (uyarı), Live yerine yüklenen bir GTM ortamı (`gtm_preview`, `gtm_auth`) için `container_environment` (uyarı), aynı sayfada farklı dataLayer'ları okuyan container'lar için `datalayer_split` (bilgi) ve kendisi için `gtag('config')` olmadan yalnızca bir olayın `send_to` değeriyle yüklenen bir hedef için `container_send_to_only` (bilgi).
|
|
331
|
+
|
|
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.
|
|
331
339
|
|
|
332
340
|
### Klasör yapısı ve plan kimlikleri
|
|
333
341
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
3
|
"mcpName": "io.github.analitikisler/ai-tag-debugger",
|
|
4
|
-
"version": "0.22.
|
|
4
|
+
"version": "0.22.2",
|
|
5
5
|
"description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/src/agent.js
CHANGED
|
@@ -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: [], containerScripts: [], 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,8 +257,8 @@ 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,
|
|
261
|
+
containerScripts: session.containerScripts,
|
|
260
262
|
network: session.network,
|
|
261
263
|
providers: session.providers,
|
|
262
264
|
skipped: session.skipped,
|
package/src/browser.js
CHANGED
|
@@ -10,7 +10,8 @@ import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from
|
|
|
10
10
|
import { startEgressProxy } from "./egress.js";
|
|
11
11
|
import { campaignParamsIn } from "./campaign.js";
|
|
12
12
|
import { detectCmpInPage } from "./cmp.js";
|
|
13
|
-
import { callersOf, containerOf } from "./containers.js";
|
|
13
|
+
import { callersOf, containerOf, containerVersion } from "./containers.js";
|
|
14
|
+
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.
|
|
@@ -35,7 +36,7 @@ export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk
|
|
|
35
36
|
// Efilli draws its banner inside the open shadow root of <efilli-layout-dynamic>, so the checks below also look inside open shadow roots.
|
|
36
37
|
|
|
37
38
|
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
38
|
-
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500 };
|
|
39
|
+
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000, containerLoads: 500, containerScripts: 500 };
|
|
39
40
|
|
|
40
41
|
// Secrets the browser process has no use for. It gets the rest of the environment
|
|
41
42
|
// (PATH, HOME, DISPLAY...) so Chromium still starts normally.
|
|
@@ -547,6 +548,7 @@ export class AuditSession {
|
|
|
547
548
|
this.errors = [];
|
|
548
549
|
this.dataLayerLog = [];
|
|
549
550
|
this.containerLoads = []; // each Google container script the page added, with how (see src/containers.js)
|
|
551
|
+
this.containerScripts = []; // each container script's answer: status, version, size and load time
|
|
550
552
|
this.network = [];
|
|
551
553
|
this.providers = resolveProviders(this.opts.providers);
|
|
552
554
|
this.landing = null; // the first page: { step, requested, chain, final }
|
|
@@ -587,11 +589,13 @@ export class AuditSession {
|
|
|
587
589
|
}
|
|
588
590
|
const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
|
|
589
591
|
this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
|
|
590
|
-
await this.context.exposeBinding("__atdRecord", (
|
|
592
|
+
await this.context.exposeBinding("__atdRecord", (source, entry) => {
|
|
593
|
+
const frame = frameInfo(source?.frame);
|
|
594
|
+
if (frame) entry = { ...entry, frame };
|
|
591
595
|
this.lastTrackingAt = Date.now();
|
|
592
596
|
this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
|
|
593
597
|
});
|
|
594
|
-
await this.context.exposeBinding("__atdLoad", (
|
|
598
|
+
await this.context.exposeBinding("__atdLoad", (source, entry) => this.recordLoad(entry, frameInfo(source?.frame)));
|
|
595
599
|
await this.context.addInitScript(INIT_SCRIPT);
|
|
596
600
|
this.page = await this.context.newPage();
|
|
597
601
|
this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
|
|
@@ -619,9 +623,10 @@ export class AuditSession {
|
|
|
619
623
|
const hits = parseTrackingRequest(request.url(), request.postData());
|
|
620
624
|
if (!hits) return;
|
|
621
625
|
const recorded = [];
|
|
626
|
+
const frame = this.frameOf(request);
|
|
622
627
|
for (const hit of hits) {
|
|
623
628
|
if (!this.checks(hit.platform)) continue;
|
|
624
|
-
const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now() };
|
|
629
|
+
const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now(), ...(frame ? { frame } : {}) };
|
|
625
630
|
const late = this.lateFor(hit, entry);
|
|
626
631
|
if (late) Object.assign(entry, late);
|
|
627
632
|
if (this.record("hits", entry)) recorded.push(entry);
|
|
@@ -660,6 +665,7 @@ export class AuditSession {
|
|
|
660
665
|
...this.storeBody(request),
|
|
661
666
|
blocked: this.opts.blockHits && isCollectionEndpoint(request.url()),
|
|
662
667
|
status: null,
|
|
668
|
+
...((frame) => (frame ? { frame } : {}))(this.frameOf(request)),
|
|
663
669
|
};
|
|
664
670
|
if (this.record("network", call)) calls.set(request, call);
|
|
665
671
|
});
|
|
@@ -678,6 +684,11 @@ export class AuditSession {
|
|
|
678
684
|
const call = calls.get(request);
|
|
679
685
|
// Keepalive requests answered below are still reported as aborted; the page got the 204.
|
|
680
686
|
if (call && call.status === null) call.status = "failed";
|
|
687
|
+
// A script answered with an error status has a response, but Chromium aborts its body: the status is kept.
|
|
688
|
+
if (containerOf(request.url())) this.recordScript(request, request.response().catch(() => null)).catch(() => {});
|
|
689
|
+
});
|
|
690
|
+
this.context.on("requestfinished", (request) => {
|
|
691
|
+
if (containerOf(request.url())) this.recordScript(request, request.response()).catch(() => {});
|
|
681
692
|
});
|
|
682
693
|
|
|
683
694
|
// Hits come from the request event rather than the route below: a request sent while
|
|
@@ -823,7 +834,7 @@ export class AuditSession {
|
|
|
823
834
|
* script did and, for a script, the callers on the stack. Only addresses without their query are kept, so
|
|
824
835
|
* the environment token (gtm_auth) and user data in a page's query string never reach the capture.
|
|
825
836
|
*/
|
|
826
|
-
recordLoad(entry) {
|
|
837
|
+
recordLoad(entry, frame = null) {
|
|
827
838
|
const container = containerOf(entry?.src);
|
|
828
839
|
if (!container) return;
|
|
829
840
|
this.record("containerLoads", {
|
|
@@ -834,9 +845,44 @@ export class AuditSession {
|
|
|
834
845
|
doc: String(entry.doc ?? "").slice(0, 16),
|
|
835
846
|
step: this.currentStep,
|
|
836
847
|
t: Date.now(),
|
|
848
|
+
...(frame ? { frame } : {}),
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* A container script's answer: its HTTP status (or "failed"), the container version its published data opens
|
|
854
|
+
* with, the size of the script as the page got it (decoded) and over the wire, and how long it took to load.
|
|
855
|
+
* response resolves to null when no answer came at all. Only the ID and host are kept, not the address.
|
|
856
|
+
*/
|
|
857
|
+
async recordScript(request, response) {
|
|
858
|
+
const container = containerOf(request.url());
|
|
859
|
+
if (!container) return;
|
|
860
|
+
const step = this.currentStep;
|
|
861
|
+
const res = await response;
|
|
862
|
+
const body = res ? await res.body().catch(() => null) : null;
|
|
863
|
+
const sizes = res ? await request.sizes().catch(() => null) : null;
|
|
864
|
+
const timing = request.timing();
|
|
865
|
+
const ms = timing.responseEnd > 0 ? Math.round(timing.responseEnd) : null;
|
|
866
|
+
const version = body ? containerVersion(body.subarray(0, 4096).toString("utf8")) : null;
|
|
867
|
+
this.record("containerScripts", {
|
|
868
|
+
id: container.id, host: container.host, step,
|
|
869
|
+
status: res ? res.status() : "failed",
|
|
870
|
+
...(version ? { version } : {}),
|
|
871
|
+
...(body ? { bytes: body.length } : {}),
|
|
872
|
+
...(sizes?.responseBodySize > 0 ? { transferBytes: sizes.responseBodySize } : {}),
|
|
873
|
+
...(ms !== null ? { ms } : {}),
|
|
837
874
|
});
|
|
838
875
|
}
|
|
839
876
|
|
|
877
|
+
/** The iframe a request came from, as frameInfo gives it; null for the page itself or a worker. */
|
|
878
|
+
frameOf(request) {
|
|
879
|
+
try {
|
|
880
|
+
return frameInfo(request.frame());
|
|
881
|
+
} catch {
|
|
882
|
+
return null; // service worker requests have no frame
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
840
886
|
/** Adds an entry to one of the logs, up to its limit. Returns false once the limit is reached. */
|
|
841
887
|
record(log, entry) {
|
|
842
888
|
if (this[log].length >= LOG_LIMITS[log]) {
|
package/src/checks.js
CHANGED
|
@@ -5,7 +5,8 @@ import { messages } from "./i18n.js";
|
|
|
5
5
|
import { isOwnAddress } from "./redact.js";
|
|
6
6
|
import { campaignParamsIn } from "./campaign.js";
|
|
7
7
|
import { CONSENT_SIGNALS } from "./parsers.js";
|
|
8
|
-
import { duplicateLoads, loadSources, splitLayers } from "./containers.js";
|
|
8
|
+
import { duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
|
|
9
|
+
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,20 @@ 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
|
+
// A container script that failed to load: none of its tags ran. Said once per container and host.
|
|
393
|
+
for (const sc of (run.containerScripts ?? []).filter((x, i, all) => scriptFailed(x) && all.findIndex((y) => scriptFailed(y) && y.id === x.id && y.host === x.host) === i)) {
|
|
394
|
+
findings.push({ ...base, severity: "warning", check: "container_failed", step: stepLabel(sc.step), detail: d.containerFailed(sc.id, sc.host, String(sc.status)) });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
|
|
398
|
+
for (const w of widgetsOf(run.widgetHits ?? [])) {
|
|
399
|
+
const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
|
|
400
|
+
findings.push({ ...base, severity: "info", check: "widget_tracking", step: stepLabel(first.step), detail: d.widgetTracking(w.origin, w.platforms) });
|
|
401
|
+
}
|
|
402
|
+
// A container in a same-site iframe beside one in the page: two documents, two dataLayers.
|
|
403
|
+
const iframeLoad = loads.find((l) => l.frame?.party === "site" && loads.some((t) => !t.frame && t.step <= l.step));
|
|
404
|
+
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) });
|
|
405
|
+
|
|
386
406
|
// One event ID on two different events: the platform keeps the first and drops the other as its
|
|
387
407
|
// duplicate, so a real event is lost (usually an ID set once per page instead of once per event).
|
|
388
408
|
const byId = new Map();
|
|
@@ -518,15 +538,15 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
518
538
|
if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
|
|
519
539
|
else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
|
|
520
540
|
const afterReject = new Map();
|
|
521
|
-
for (const hit of
|
|
541
|
+
for (const hit of legalHits) {
|
|
522
542
|
if (hit.step < rejectStep) continue;
|
|
523
543
|
if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted(hit)) {
|
|
524
|
-
const k =
|
|
544
|
+
const k = legalKey(hit);
|
|
525
545
|
if (!afterReject.has(k)) afterReject.set(k, hit);
|
|
526
546
|
}
|
|
527
547
|
}
|
|
528
548
|
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) });
|
|
549
|
+
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
550
|
}
|
|
531
551
|
}
|
|
532
552
|
|
|
@@ -546,14 +566,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
546
566
|
}
|
|
547
567
|
|
|
548
568
|
const beforeConsent = new Map();
|
|
549
|
-
for (const hit of
|
|
569
|
+
for (const hit of legalHits) {
|
|
550
570
|
// Hits from the reject click on are covered by the after_reject check.
|
|
551
571
|
if (rejectStep !== null && hit.step >= rejectStep) continue;
|
|
552
572
|
const early = granted === null || hit.step < granted;
|
|
553
573
|
if (!early) continue;
|
|
554
574
|
const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
|
|
555
575
|
if (adPlatform || claimsGranted(hit)) {
|
|
556
|
-
const k =
|
|
576
|
+
const k = legalKey(hit);
|
|
557
577
|
if (!beforeConsent.has(k)) beforeConsent.set(k, hit);
|
|
558
578
|
}
|
|
559
579
|
}
|
|
@@ -565,26 +585,26 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
565
585
|
platform: hit.platform,
|
|
566
586
|
event: hit.name,
|
|
567
587
|
step: stepLabel(hit.step),
|
|
568
|
-
detail: granted === null ? d.beforeConsentNever(hit.platform, hit.name) : d.beforeConsent(hit.platform, hit.name),
|
|
588
|
+
detail: fromWidget(hit, granted === null ? d.beforeConsentNever(hit.platform, hit.name) : d.beforeConsent(hit.platform, hit.name)),
|
|
569
589
|
});
|
|
570
590
|
}
|
|
571
591
|
|
|
572
592
|
// Personal data in hits.
|
|
573
593
|
const piiSeen = new Set();
|
|
574
|
-
for (const hit of
|
|
594
|
+
for (const hit of legalHits) {
|
|
575
595
|
let url = hit.url;
|
|
576
596
|
try {
|
|
577
597
|
url = decodeURIComponent(hit.url);
|
|
578
598
|
} catch { /* malformed % sequence, e.g. "50%": search the raw URL */ }
|
|
579
599
|
const haystack = url + " " + JSON.stringify(hit.params);
|
|
580
|
-
if (piiSeen.has(
|
|
600
|
+
if (piiSeen.has(legalKey(hit))) continue;
|
|
581
601
|
// User data for ad matching is redacted at capture; userData.plain lists the fields that were not hashed.
|
|
582
602
|
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) });
|
|
603
|
+
piiSeen.add(legalKey(hit));
|
|
604
|
+
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
605
|
} 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) });
|
|
606
|
+
piiSeen.add(legalKey(hit));
|
|
607
|
+
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
608
|
}
|
|
589
609
|
}
|
|
590
610
|
|
package/src/containers.js
CHANGED
|
@@ -31,6 +31,40 @@ export function containerOf(url) {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The container version a gtm.js or gtag/js script was published as: its data opens with
|
|
36
|
+
* `var data = { "resource": { "version": "153", ...`. Null when the script says nothing of the kind.
|
|
37
|
+
*/
|
|
38
|
+
export function containerVersion(text) {
|
|
39
|
+
return String(text ?? "").match(/"resource"\s*:\s*\{\s*"version"\s*:\s*"(\d+)"/)?.[1] ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether a container script's answer means it never ran: the request failed or the server answered with an error. */
|
|
43
|
+
export const scriptFailed = (script) => script.status === "failed" || (typeof script.status === "number" && script.status >= 400);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The answer to a load's script request (see recordScript in browser.js): the first one for the same container and
|
|
47
|
+
* host from the load's step on, else the last before it. Null for runs saved by older versions.
|
|
48
|
+
*/
|
|
49
|
+
export function scriptOf(run, load) {
|
|
50
|
+
const same = (run.containerScripts ?? []).filter((s) => s.id === load.id && s.host === load.host);
|
|
51
|
+
return same.find((s) => s.step >= (load.step ?? 0)) ?? same.at(-1) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What a row or load shows of its script's answer: { version?, bytes?, transferBytes?, ms?, failed? }. */
|
|
55
|
+
export function scriptFacts(script) {
|
|
56
|
+
if (!script) return {};
|
|
57
|
+
// A load that already carries the facts (from loadSources) passes them on as they are.
|
|
58
|
+
if (!("status" in script)) return Object.fromEntries(["version", "bytes", "transferBytes", "ms", "failed"].filter((k) => script[k] != null).map((k) => [k, script[k]]));
|
|
59
|
+
return {
|
|
60
|
+
...(script.version ? { version: script.version } : {}),
|
|
61
|
+
...(script.bytes ? { bytes: script.bytes } : {}),
|
|
62
|
+
...(script.transferBytes ? { transferBytes: script.transferBytes } : {}),
|
|
63
|
+
...(script.ms != null ? { ms: script.ms } : {}),
|
|
64
|
+
...(scriptFailed(script) ? { failed: String(script.status) } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
/** origin + path, so a caller's query string (which may carry user data) is never kept. */
|
|
35
69
|
export function scriptPath(url) {
|
|
36
70
|
try {
|
|
@@ -106,24 +140,27 @@ export function sourceOf(load, pushes = []) {
|
|
|
106
140
|
return { kind: "inline" };
|
|
107
141
|
}
|
|
108
142
|
|
|
109
|
-
/** A run's container loads with their sources, in the order they happened. Runs saved by older versions have none. */
|
|
143
|
+
/** A run's container loads with their sources and their scripts' answers (scriptFacts), in the order they happened. Runs saved by older versions have none. */
|
|
110
144
|
export function loadSources(run) {
|
|
111
145
|
const pushes = run.dataLayerLog ?? [];
|
|
112
|
-
return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes) }));
|
|
146
|
+
return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes), ...scriptFacts(scriptOf(run, load)) }));
|
|
113
147
|
}
|
|
114
148
|
|
|
115
149
|
/**
|
|
116
150
|
* One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
|
|
117
|
-
* environment?, kind, by?, pages, journeys }.
|
|
151
|
+
* environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, pages, journeys }. frame is the
|
|
152
|
+
* iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the row.
|
|
118
153
|
*/
|
|
119
154
|
export function containerRows(runs) {
|
|
120
155
|
const rows = new Map();
|
|
121
156
|
for (const run of runs) {
|
|
122
157
|
for (const l of loadSources(run)) {
|
|
123
|
-
const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? ""].join("|");
|
|
158
|
+
const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? "", l.frame?.origin ?? ""].join("|");
|
|
124
159
|
const row = rows.get(k) ?? {
|
|
125
160
|
id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
|
|
126
161
|
...(l.environment ? { environment: l.environment } : {}), kind: l.kind, ...(l.by ? { by: l.by } : {}),
|
|
162
|
+
...(l.frame ? { frame: { origin: l.frame.origin, party: l.frame.party } } : {}),
|
|
163
|
+
...scriptFacts(l),
|
|
127
164
|
pages: new Set(), journeys: new Set(),
|
|
128
165
|
};
|
|
129
166
|
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.version ? plain(esc(c.version(r.version)), "mono") : ""}${r.bytes ? plain(esc(c.size(r.bytes)), "mono") : ""}${r.ms != null ? plain(esc(c.loadTime(r.ms))) : ""}${r.failed ? badge("broken", c.failed(r.failed)) : ""}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
|
|
204
204
|
const ids = uniq(rows.map((r) => r.id));
|
|
205
205
|
return `<li><details class="card inv" id="inv-containers"><summary><span class="t">${esc(m.ui.containersTitle)}</span>${plain(n(ids.length))}<span class="inv-m">${esc(ids.slice(0, 3).join(", "))}${ids.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.containersIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
|
|
206
206
|
}
|
package/src/i18n.js
CHANGED
|
@@ -209,10 +209,16 @@ const en = {
|
|
|
209
209
|
sourceCol: "Loaded by",
|
|
210
210
|
layerCol: "dataLayer",
|
|
211
211
|
servedCol: "Served from",
|
|
212
|
+
scriptCol: "Version and size",
|
|
212
213
|
containers: {
|
|
213
214
|
script: { gtm: "GTM container", gtag: "Google tag" },
|
|
214
215
|
served: { google: "Google", site: "The site's own domain" },
|
|
215
216
|
environment: (env) => (env === "unnamed" ? "GTM environment" : `GTM environment ${env}`),
|
|
217
|
+
iframe: (origin, party) => `In an iframe from ${origin} (${party === "site" ? "same site" : "third party"})`,
|
|
218
|
+
version: (v) => `version ${v}`,
|
|
219
|
+
size: (bytes) => `${(bytes / 1024).toFixed(1)} KB`,
|
|
220
|
+
loadTime: (ms) => `loaded in ${ms} ms`,
|
|
221
|
+
failed: (status) => (status === "failed" ? "failed to load" : `failed to load (HTTP ${status})`),
|
|
216
222
|
kinds: {
|
|
217
223
|
html: () => "A script tag in the page's HTML",
|
|
218
224
|
inline: () => "Inline code in the page (the snippet)",
|
|
@@ -350,6 +356,10 @@ const en = {
|
|
|
350
356
|
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
357
|
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
358
|
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.`,
|
|
359
|
+
fromWidget: (origin, text) => `In an embedded widget from ${origin}: ${text}`,
|
|
360
|
+
widgetTracking: (origin, platforms) => `An embedded widget from ${origin} sent its own tracking: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. It goes to accounts the site's own tags don't use, so it was left out of the plan, coverage and duplicate checks; the consent and personal data checks still include it.`,
|
|
361
|
+
iframeContainer: (id, origin, parent) => `${id} runs in an iframe from ${origin} embedded in ${parent}. The iframe has its own dataLayer: events and consent commands pushed in the page never reach it, so it needs its own.`,
|
|
362
|
+
containerFailed: (id, host, status) => `The ${id} script from ${host} ${status === "failed" ? "failed to load" : `was answered with HTTP ${status}`}, so none of its tags ran on that page.`,
|
|
353
363
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
354
364
|
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
355
365
|
consentDefaultNotApplied: () => "The page pushed a Consent Mode default, but the Google tag never applied it, so its tags ran without one. Tag Assistant shows Consent Mode as not configured.",
|
|
@@ -409,6 +419,9 @@ const en = {
|
|
|
409
419
|
container_environment: "Visitors run a non-Live version of the container, so tags still under test, or an old setup, collect production data.",
|
|
410
420
|
datalayer_split: "Events and consent commands pushed to one dataLayer never reach the containers reading another, so some tags miss them.",
|
|
411
421
|
container_send_to_only: "The destination runs without its own config, so the settings a config command would give it are missing.",
|
|
422
|
+
widget_tracking: "Nothing is wrong with the site's tracking: the widget's own hits were kept out of the plan checks so they can't pass for the site's.",
|
|
423
|
+
iframe_container: "Tags in the iframe miss what the page pushes, so conversions completed in it can go untracked or be sent without the visitor's consent.",
|
|
424
|
+
container_failed: "None of the container's tags ran: page views, conversions and consent signals it sends were all lost on that page.",
|
|
412
425
|
campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
|
|
413
426
|
event_id_reused: "Two different events share one event ID, so the platform keeps the first and silently drops the other: a real event is lost.",
|
|
414
427
|
event_id_mismatch: "The browser and server copies of this event carry different IDs, so Meta cannot pair them and counts the conversion twice.",
|
|
@@ -455,6 +468,9 @@ const en = {
|
|
|
455
468
|
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
469
|
datalayer_split: "Point every container at the same dataLayer, or push events and consent commands to each dataLayer the containers read.",
|
|
457
470
|
container_send_to_only: "Add a gtag('config', ID) for the destination before the events that send to it, or a Google tag for it in GTM.",
|
|
471
|
+
widget_tracking: "Nothing to fix for the plan. If the widget sets cookies or sends data before consent, the consent findings name it; ask its vendor how to hold it back.",
|
|
472
|
+
iframe_container: "Push the events and the consent state inside the iframe as well (or pass them in with postMessage), or track the iframe's steps from the page.",
|
|
473
|
+
container_failed: "Check the container ID and the address in the snippet; for a server container or the Google tag gateway, check that it serves gtm.js and gtag/js.",
|
|
458
474
|
campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
|
|
459
475
|
event_id_reused: "Generate a new event ID for every event, not once per page load.",
|
|
460
476
|
event_id_mismatch: "Use one event ID for the pixel event and the server copy: pass the same value to both tags.",
|
|
@@ -501,6 +517,9 @@ const en = {
|
|
|
501
517
|
container_environment: "hygiene",
|
|
502
518
|
datalayer_split: "hygiene",
|
|
503
519
|
container_send_to_only: "hygiene",
|
|
520
|
+
widget_tracking: "hygiene",
|
|
521
|
+
iframe_container: "hygiene",
|
|
522
|
+
container_failed: "money",
|
|
504
523
|
campaign_params: "money",
|
|
505
524
|
event_id_reused: "money",
|
|
506
525
|
event_id_mismatch: "money",
|
|
@@ -1026,10 +1045,16 @@ const tr = {
|
|
|
1026
1045
|
sourceCol: "Yükleyen",
|
|
1027
1046
|
layerCol: "dataLayer",
|
|
1028
1047
|
servedCol: "Sunulduğu yer",
|
|
1048
|
+
scriptCol: "Sürüm ve boyut",
|
|
1029
1049
|
containers: {
|
|
1030
1050
|
script: { gtm: "GTM container'ı", gtag: "Google etiketi" },
|
|
1031
1051
|
served: { google: "Google", site: "Sitenin kendi alan adı" },
|
|
1032
1052
|
environment: (env) => (env === "unnamed" ? "GTM ortamı" : `GTM ortamı ${env}`),
|
|
1053
|
+
iframe: (origin, party) => `${origin} kaynaklı bir iframe içinde (${party === "site" ? "aynı site" : "üçüncü taraf"})`,
|
|
1054
|
+
version: (v) => `sürüm ${v}`,
|
|
1055
|
+
size: (bytes) => `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`,
|
|
1056
|
+
loadTime: (ms) => `${ms} ms'de yüklendi`,
|
|
1057
|
+
failed: (status) => (status === "failed" ? "yüklenemedi" : `yüklenemedi (HTTP ${status})`),
|
|
1033
1058
|
kinds: {
|
|
1034
1059
|
html: () => "Sayfanın HTML'indeki bir script etiketi",
|
|
1035
1060
|
inline: () => "Sayfadaki satır içi kod (snippet)",
|
|
@@ -1163,6 +1188,10 @@ const tr = {
|
|
|
1163
1188
|
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
1189
|
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
1190
|
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.`,
|
|
1191
|
+
fromWidget: (origin, text) => `${origin} kaynaklı gömülü bir widget içinde: ${text}`,
|
|
1192
|
+
widgetTracking: (origin, platforms) => `${origin} kaynaklı gömülü bir widget kendi izlemesini gönderdi: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. Bunlar sitenin kendi etiketlerinin kullanmadığı hesaplara gidiyor; bu yüzden plan, kapsam ve tekrar kontrollerinin dışında tutuldu, onay ve kişisel veri kontrolleri ise onları yine içeriyor.`,
|
|
1193
|
+
iframeContainer: (id, origin, parent) => `${id}, ${parent} içine gömülü ${origin} kaynaklı bir iframe içinde çalışıyor. iframe'in kendi dataLayer'ı var: sayfada gönderilen olaylar ve onay komutları ona hiç ulaşmaz, bu yüzden kendi olaylarına ve onayına ihtiyacı var.`,
|
|
1194
|
+
containerFailed: (id, host, status) => `${host} adresinden gelen ${id} betiği ${status === "failed" ? "yüklenemedi" : `HTTP ${status} ile yanıtlandı`}; bu yüzden o sayfada etiketlerinin hiçbiri çalışmadı.`,
|
|
1166
1195
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
1167
1196
|
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
|
1168
1197
|
consentDefaultNotApplied: () => "Sayfa bir Consent Mode varsayılanı gönderdi, ancak Google etiketi onu hiç uygulamadı; etiketleri varsayılan olmadan çalıştı. Tag Assistant, Consent Mode'u yapılandırılmamış gösterir.",
|
|
@@ -1221,6 +1250,9 @@ const tr = {
|
|
|
1221
1250
|
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
1251
|
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
1252
|
container_send_to_only: "Hedef kendi config'i olmadan çalışıyor; bir config komutunun ona vereceği ayarlar eksik.",
|
|
1253
|
+
widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
|
|
1254
|
+
iframe_container: "iframe içindeki etiketler sayfanın gönderdiklerini görmez; içinde tamamlanan dönüşümler izlenmeyebilir ya da ziyaretçinin onayı olmadan gönderilebilir.",
|
|
1255
|
+
container_failed: "Container'ın etiketlerinin hiçbiri çalışmadı: gönderdiği sayfa görüntülemeleri, dönüşümler ve onay sinyalleri o sayfada kayboldu.",
|
|
1224
1256
|
campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
|
|
1225
1257
|
event_id_reused: "İki farklı olay tek bir olay kimliğini paylaşıyor; platform ilkini tutup diğerini sessizce atıyor, gerçek bir olay kayboluyor.",
|
|
1226
1258
|
event_id_mismatch: "Bu olayın tarayıcı ve sunucu kopyaları farklı kimlik taşıyor; Meta ikisini eşleştiremiyor ve dönüşümü iki kez sayıyor.",
|
|
@@ -1265,6 +1297,9 @@ const tr = {
|
|
|
1265
1297
|
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
1298
|
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
1299
|
container_send_to_only: "Hedefe gönderen olaylardan önce onun için bir gtag('config', ID) ya da GTM'de onun için bir Google etiketi ekleyin.",
|
|
1300
|
+
widget_tracking: "Plan için düzeltilecek bir şey yok. Widget onaydan önce çerez yazıyor ya da veri gönderiyorsa onay bulguları onu adlandırır; nasıl durdurulacağını sağlayıcısına sorun.",
|
|
1301
|
+
iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
|
|
1302
|
+
container_failed: "Snippet'teki container kimliğini ve adresi kontrol edin; sunucu container'ı ya da Google etiket ağ geçidi için gtm.js ve gtag/js sunduğunu kontrol edin.",
|
|
1268
1303
|
campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
|
|
1269
1304
|
event_id_reused: "Her olay için yeni bir olay kimliği üretin; sayfa yüklemesi başına bir kez değil.",
|
|
1270
1305
|
event_id_mismatch: "Piksel olayı ve sunucu kopyası için tek bir olay kimliği kullanın: aynı değeri iki etikete de verin.",
|
|
@@ -1310,6 +1345,9 @@ const tr = {
|
|
|
1310
1345
|
container_environment: "hygiene",
|
|
1311
1346
|
datalayer_split: "hygiene",
|
|
1312
1347
|
container_send_to_only: "hygiene",
|
|
1348
|
+
widget_tracking: "hygiene",
|
|
1349
|
+
iframe_container: "hygiene",
|
|
1350
|
+
container_failed: "money",
|
|
1313
1351
|
campaign_params: "money",
|
|
1314
1352
|
event_id_reused: "money",
|
|
1315
1353
|
event_id_mismatch: "money",
|
package/src/mcp.js
CHANGED
|
@@ -23,7 +23,7 @@ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
|
23
23
|
import { validatePlan } from "./schema.js";
|
|
24
24
|
import { readBaseline } from "./baseline.js";
|
|
25
25
|
import { MAX_CONCURRENCY } from "./pool.js";
|
|
26
|
-
import { scriptCspSource } from "./report.js";
|
|
26
|
+
import { scriptCell, scriptCspSource } from "./report.js";
|
|
27
27
|
import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
|
|
28
28
|
import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
|
|
29
29
|
import { createProvider } from "./providers.js";
|
|
@@ -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,15 @@ 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
|
|
169
|
+
const script = scriptCell(c, l);
|
|
170
|
+
const extra = [script,l.layer !== "dataLayer" && `dataLayer ${l.layer}`, l.served === "site" && `served from ${l.host}`, l.environment && c.environment(l.environment), l.frame && c.iframe(l.frame.origin, l.frame.party)].filter(Boolean);
|
|
160
171
|
return ` ${l.id} (${c.script[l.script]}): ${c.kinds[l.kind](l.by)}${extra.length ? `; ${extra.join(", ")}` : ""}`;
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
export function stepCapture(audit, step) {
|
|
164
175
|
const { session } = audit;
|
|
165
176
|
const hits = session.hits.filter((h) => h.step === step);
|
|
177
|
+
const ids = siteIds(session);
|
|
166
178
|
const pushes = session.dataLayerLog.filter((e) => e.step === step);
|
|
167
179
|
// Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
|
|
168
180
|
const lateHere = session.hits.filter((h) => h.late?.during === step);
|
|
@@ -173,12 +185,12 @@ export function stepCapture(audit, step) {
|
|
|
173
185
|
...(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
186
|
`Step ${step}: "${session.steps[step]?.label ?? ""}". Read a hit's full parameters with get_hit (audit_id, step ${step}, its [h] number).`,
|
|
175
187
|
`Tracking hits sent by this step: ${hits.length}`,
|
|
176
|
-
...hits.map((h, i) => describeHit(h, session.steps, i)),
|
|
188
|
+
...hits.map((h, i) => describeHit(h, session.steps, i, ids)),
|
|
177
189
|
];
|
|
178
190
|
if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
|
|
179
191
|
if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
|
|
180
192
|
if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
|
|
181
|
-
const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), dataLayerLog: session.dataLayerLog });
|
|
193
|
+
const loads = loadSources({ containerLoads: (session.containerLoads ?? []).filter((l) => l.step === step), containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
|
|
182
194
|
if (loads.length) lines.push("Google containers loaded:", ...loads.map(describeLoad));
|
|
183
195
|
lines.push(...cookieLines(session, step));
|
|
184
196
|
const tag = session.steps[step]?.tagConsent;
|
|
@@ -233,6 +245,12 @@ function containerJson(l) {
|
|
|
233
245
|
...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
|
|
234
246
|
...(l.served === "site" ? { served_from: l.host } : {}),
|
|
235
247
|
...(l.environment ? { gtm_environment: l.environment } : {}),
|
|
248
|
+
...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
|
|
249
|
+
...(l.version ? { version: l.version } : {}),
|
|
250
|
+
...(l.bytes ? { size_bytes: l.bytes } : {}),
|
|
251
|
+
...(l.transferBytes ? { transfer_bytes: l.transferBytes } : {}),
|
|
252
|
+
...(l.ms != null ? { load_ms: l.ms } : {}),
|
|
253
|
+
...(l.failed ? { failed: l.failed } : {}),
|
|
236
254
|
...(l.pages ? { pages: l.pages } : {}),
|
|
237
255
|
};
|
|
238
256
|
}
|
|
@@ -245,7 +263,7 @@ export function containerLines(runs) {
|
|
|
245
263
|
return [
|
|
246
264
|
"Google containers (how each reached the page):",
|
|
247
265
|
...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);
|
|
266
|
+
const extra = [scriptCell(c, r), r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
|
|
249
267
|
return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
|
|
250
268
|
}),
|
|
251
269
|
];
|
|
@@ -261,13 +279,16 @@ export function timelineOf(run) {
|
|
|
261
279
|
const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
|
|
262
280
|
const entry = { step: st.index, action: st.label, ...(st.pagePath ? { page: st.pagePath } : {}) };
|
|
263
281
|
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 } : {}) }));
|
|
282
|
+
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 } : {}) }));
|
|
283
|
+
// An embedded widget's own hits, kept apart from the site's (see src/frames.js).
|
|
284
|
+
const widget = (run.widgetHits ?? []).filter((h) => h.step === st.index);
|
|
285
|
+
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
286
|
if (others.length) entry.other_tracking_calls = others.length;
|
|
266
287
|
if (commands.length) entry.consent_commands = commands.map((e) => ({ [e.value[1]]: e.value[2] }));
|
|
267
288
|
if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
|
|
268
289
|
const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
|
|
269
290
|
if (Object.keys(cookies).length) entry.cookies = cookies;
|
|
270
|
-
const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), dataLayerLog: run.dataLayerLog });
|
|
291
|
+
const loads = loadSources({ containerLoads: (run.containerLoads ?? []).filter((l) => l.step === st.index), containerScripts: run.containerScripts, dataLayerLog: run.dataLayerLog });
|
|
271
292
|
if (loads.length) entry.containers = loads.map(containerJson);
|
|
272
293
|
if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
|
|
273
294
|
if (st.identity) lastIds = st.identity;
|
|
@@ -516,7 +537,7 @@ export function buildServer(opts) {
|
|
|
516
537
|
},
|
|
517
538
|
async ({ audit_id }) => {
|
|
518
539
|
const { session } = getAudit(audit_id);
|
|
519
|
-
const loads = loadSources({ containerLoads: session.containerLoads ?? [], dataLayerLog: session.dataLayerLog });
|
|
540
|
+
const loads = loadSources({ containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog });
|
|
520
541
|
if (!loads.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
|
|
521
542
|
const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
|
|
522
543
|
return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify(loads.map((l) => ({ step: l.step, page: l.page, ...containerJson(l) })))}`);
|
package/src/report.js
CHANGED
|
@@ -397,15 +397,21 @@ function mdInventory(m, runs) {
|
|
|
397
397
|
return lines;
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
/** A container row's version, size and load time, or that it failed to load, in a few words. */
|
|
401
|
+
export function scriptCell(c, r) {
|
|
402
|
+
if (r.failed) return c.failed(r.failed);
|
|
403
|
+
return [r.version && c.version(r.version), r.bytes && c.size(r.bytes), r.ms != null && c.loadTime(r.ms)].filter(Boolean).join(", ");
|
|
404
|
+
}
|
|
405
|
+
|
|
400
406
|
/** The Google containers and how each reached the page, one row per container and source. Omitted when none loaded. */
|
|
401
407
|
function mdContainers(m, runs) {
|
|
402
408
|
const rows = containerRows(runs);
|
|
403
409
|
if (!rows.length) return [];
|
|
404
410
|
const c = m.ui.containers;
|
|
405
|
-
const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.pagesCol} |`, "
|
|
411
|
+
const lines = [`## ${m.ui.containersTitle}`, "", `| ${m.ui.containerCol} | ${m.ui.sourceCol} | ${m.ui.layerCol} | ${m.ui.servedCol} | ${m.ui.scriptCol} | ${m.ui.pagesCol} |`, "|---|---|---|---|---|---|"];
|
|
406
412
|
for (const r of rows.slice(0, MD_INVENTORY_ROWS)) {
|
|
407
413
|
const served = r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`;
|
|
408
|
-
lines.push(`| ${mdText(r.id)} (${c.script[r.script]}) | ${mdText(c.kinds[r.kind](r.by))}${r.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${r.pages.length} |`);
|
|
414
|
+
lines.push(`| ${mdText(r.id)} (${c.script[r.script]}) | ${mdText(c.kinds[r.kind](r.by))}${r.frame ? `, ${mdText(c.iframe(r.frame.origin, r.frame.party))}` : ""}${r.environment ? `, ${mdText(c.environment(r.environment))}` : ""} | ${mdText(r.layer)} | ${mdText(served)} | ${mdText(scriptCell(c, r)) || "–"} | ${r.pages.length} |`);
|
|
409
415
|
}
|
|
410
416
|
if (rows.length > MD_INVENTORY_ROWS) lines.push("", m.ui.md.moreIn(rows.length - MD_INVENTORY_ROWS, "report.html"));
|
|
411
417
|
lines.push("");
|