@analitikisler/ai-tag-debugger 0.19.0 → 0.20.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 +17 -0
- package/README.md +4 -4
- package/README.tr.md +4 -4
- package/package.json +2 -2
- package/src/browser.js +43 -3
- package/src/checks.js +47 -15
- package/src/html/css.js +185 -166
- package/src/html/journeys.js +8 -5
- package/src/i18n.js +17 -0
- package/src/mcp.js +62 -14
- package/src/parsers.js +3 -0
- package/src/plan-html.js +4 -4
- package/src/verify.js +1 -0
- package/src/whitelabel.js +48 -15
package/src/html/journeys.js
CHANGED
|
@@ -65,8 +65,10 @@ function pushesForHit(run, hit) {
|
|
|
65
65
|
|
|
66
66
|
/** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
|
|
67
67
|
function callsForHit(run, hit) {
|
|
68
|
-
|
|
69
|
-
const
|
|
68
|
+
// A late hit was sent during a later step, so its request is recorded there.
|
|
69
|
+
const sentIn = hit.late?.during ?? hit.step;
|
|
70
|
+
const calls = (run.network ?? []).filter((c) => c.step === sentIn && c.url === hit.url);
|
|
71
|
+
const twins = run.hits.filter((h) => (h.late?.during ?? h.step) === sentIn && h.url === hit.url);
|
|
70
72
|
return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -216,7 +218,7 @@ function runState(ctx, rows, run) {
|
|
|
216
218
|
/* ---------- step pane: hits ---------- */
|
|
217
219
|
|
|
218
220
|
/** The 1–3 parameters a practitioner scans first: missing required ones, value, items, the order id; a page title when there is nothing else. */
|
|
219
|
-
function keyBits(ctx, t, hit, flag) {
|
|
221
|
+
function keyBits(ctx, t, hit, flag, run) {
|
|
220
222
|
const { m } = ctx;
|
|
221
223
|
const p = hit.params ?? {};
|
|
222
224
|
const out = [];
|
|
@@ -228,6 +230,7 @@ function keyBits(ctx, t, hit, flag) {
|
|
|
228
230
|
if (p.transaction_id) out.push(kv("transaction_id", p.transaction_id));
|
|
229
231
|
if (hit.delivery === "server") out.push(`<span class="chip plain">${esc(m.ui.serverSideTag)}</span>`);
|
|
230
232
|
if (!out.length && p.page_title) out.push(kv("", p.page_title));
|
|
233
|
+
if (hit.late) out.unshift(`<span class="chip plain" title="${esc(m.ui.lateHitTip((run?.steps ?? []).find((s) => s.index === hit.late.during)?.label ?? hit.late.during))}">${esc(m.ui.lateHit(hit.late.ms))}</span>`);
|
|
231
234
|
if (!out.length) return `<span class="muted">${esc(m.ui.noParameters)}</span>`;
|
|
232
235
|
return out.slice(0, 3).join("");
|
|
233
236
|
}
|
|
@@ -261,10 +264,10 @@ function hitCard(ctx, t, rows, run, ri, step, hit, k) {
|
|
|
261
264
|
const label = PROVIDERS[safeKey(hit.platform)] ?? platformLabel(hit.platform);
|
|
262
265
|
const calls = callsForHit(run, hit);
|
|
263
266
|
const reqLine = calls.length
|
|
264
|
-
? calls.map((c) => `<a class="mono" href="#${requestId(ri, step.index,
|
|
267
|
+
? calls.map((c) => `<a class="mono" href="#${requestId(ri, c.step, (c.step === step.index ? stepCalls : requestsOf(run, c.step)).indexOf(c))}">${esc(c.method ?? "")} ${esc(hostPath(c.url))} ${esc(c.status ?? "")}</a>${c.blocked ? `<span class="muted">${esc(m.html.blockedBy(label))}</span>` : ""}`).join("")
|
|
265
268
|
: `<span class="mono">${esc(hostPath(hit.url))}</span>`;
|
|
266
269
|
const card = flag ? ids.get(flag) : null;
|
|
267
|
-
return `<details class="card jr-hit${sev ? ` sev-${sev}` : ""}" id="${id}"${sev ? ` data-flagged="${sev}" data-print-open` : ""}><summary><span class="jr-c-p">${platChip(hit.platform)}</span><code class="jr-ev">${esc(hit.name)}</code><span class="jr-c-k">${keyBits(ctx, t, hit, flag)}</span><span class="jr-c-c">${hitConsent(t, hit.consent)}</span><span class="jr-c-s">${sev ? badge(sev, m.severity[sev]) : ""}</span><span class="jr-tm">${esc(secs(run, hit.t))}</span></summary>
|
|
270
|
+
return `<details class="card jr-hit${sev ? ` sev-${sev}` : ""}" id="${id}"${sev ? ` data-flagged="${sev}" data-print-open` : ""}><summary><span class="jr-c-p">${platChip(hit.platform)}</span><code class="jr-ev">${esc(hit.name)}</code><span class="jr-c-k">${keyBits(ctx, t, hit, flag, run)}</span><span class="jr-c-c">${hitConsent(t, hit.consent)}</span><span class="jr-c-s">${sev ? badge(sev, m.severity[sev]) : ""}</span><span class="jr-tm">${esc(secs(run, hit.t))}</span></summary>
|
|
268
271
|
<div class="body">${flag ? `<p class="jr-flag sev-${sev}">${use(`sev-${sev}`, 14)}<span>${detailHtml(flag.detail)}</span>${card ? `<a href="#${card}">${esc(m.html.findingLink)}</a>` : ""}</p>` : ""}
|
|
269
272
|
<div class="jr-hb"><div><h4 class="jr-h">${esc(m.ui.parameters)}${n(event.length + missing.length)}</h4>${batch ? `<p class="muted xs jr-batch">${esc(m.ui.inBatch(batch[0], batch[1]))}</p>` : ""}${paramTable}${ctxTable}</div>
|
|
270
273
|
${items.length ? `<div><h4 class="jr-h">${esc(m.ui.items)}${n(items.length)}</h4>${itemsTable(m, t, items)}</div>` : ""}
|
package/src/i18n.js
CHANGED
|
@@ -24,6 +24,8 @@ const journeysOf = (v) => v?.journeys ?? {};
|
|
|
24
24
|
|
|
25
25
|
const en = {
|
|
26
26
|
name: "English",
|
|
27
|
+
// The enhanced measurement settings as GA4's web stream page names them.
|
|
28
|
+
ga4Settings: { pageViews: "Page views", scrolls: "Scrolls", outboundClicks: "Outbound clicks", siteSearch: "Site search", videoEngagement: "Video engagement", fileDownloads: "File downloads", formInteractions: "Form interactions" },
|
|
27
29
|
severity: { broken: "Broken", risk: "Risk", warning: "Warning", info: "Info", ok: "OK" },
|
|
28
30
|
ui: {
|
|
29
31
|
title: (host) => `ai tag debugger report: ${host}`,
|
|
@@ -78,6 +80,8 @@ const en = {
|
|
|
78
80
|
customItemParams: "Custom item parameters",
|
|
79
81
|
noItems: "no items",
|
|
80
82
|
serverSideTag: "server-side",
|
|
83
|
+
lateHit: (ms) => `arrived ${(ms / 1000).toFixed(1)} s late`,
|
|
84
|
+
lateHitTip: (step) => `Sent after this step was over, during "${step}". It belongs to this step's page.`,
|
|
81
85
|
source: "Source",
|
|
82
86
|
request: "Request",
|
|
83
87
|
noPushes: "No dataLayer pushes in this step.",
|
|
@@ -246,6 +250,8 @@ const en = {
|
|
|
246
250
|
journeyIncomplete: (note) => `The journey did not reach its goal${note ? `: ${note}` : "."} Events after that point could not be checked.`,
|
|
247
251
|
missingEvent: (platform, name) => `${label(platform)} ${q(name)} was not sent on this journey, although the journey reached its goal. ${label(platform)} recorded nothing for it.`,
|
|
248
252
|
missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so it is unverified rather than missing.`,
|
|
253
|
+
missingEnhanced: (name, setting) => `GA4 ${q(name)} was not sent on this journey, although the journey reached its goal. GA4 sends it by itself, with no tag, when enhanced measurement is on: in GA4, Admin > Data streams > the web stream > Enhanced measurement, switch on "${en.ga4Settings[setting]}". A tag that sends it instead works too.`,
|
|
254
|
+
missingAutomatic: (name) => `GA4 ${q(name)} was not seen. GA4 makes it by itself, with no tag (first_visit and session_start from flags on its other hits, user_engagement when the page is hidden or left), so it needs no setup and is not counted as missing.`,
|
|
249
255
|
missingParams: (name, params) => `${q(name)} was sent without ${andList(sortParams(params).map(q), "and")}.`,
|
|
250
256
|
duplicate: (name, n) => `${q(name)} was sent ${n} times by one action. Every real one is counted ${n} times.`,
|
|
251
257
|
purchaseValueZero: (platform, name, v) => `${label(platform)} ${q(name)} was sent with value ${q(v)}. A purchase worth 0 or less adds no revenue, so ad platforms cannot optimise bids on it.`,
|
|
@@ -333,6 +339,7 @@ const en = {
|
|
|
333
339
|
pii_datalayer: "The dataLayer holds unhashed personal data, so every script on the page can read it, not only the tag that needs it.",
|
|
334
340
|
journey_incomplete: "The browser could not finish this journey, so every event after that point, including any conversion, is unverified.",
|
|
335
341
|
unplanned_events: "Events fire that are not in the plan: either the plan is out of date or tags send things nobody asked for.",
|
|
342
|
+
ga4_automatic_event: "Nothing is lost: GA4 makes this event itself, and a short test visit can end before it does.",
|
|
336
343
|
planned_out_of_scope: "Some planned events are on platforms this run did not check, so their status is unknown.",
|
|
337
344
|
matches_plan: "These planned events fired with everything the plan requires.",
|
|
338
345
|
},
|
|
@@ -369,6 +376,7 @@ const en = {
|
|
|
369
376
|
pii_datalayer: "Push user data to the dataLayer already hashed, or restrict it to the tag that needs it and clear it afterwards.",
|
|
370
377
|
journey_incomplete: "Read the note for why the browser stopped (a blocked click, a missing element, an error), fix the page or the step, and re-run the journey.",
|
|
371
378
|
unplanned_events: "Add the events to the measurement plan if they are wanted, or remove the tags that send them.",
|
|
379
|
+
ga4_automatic_event: "No fix is needed. Keep the event in the plan for reference, or remove it, since no tag has to send it.",
|
|
372
380
|
planned_out_of_scope: "Include their platforms in the next run's providers so they are checked.",
|
|
373
381
|
matches_plan: "Nothing to fix.",
|
|
374
382
|
},
|
|
@@ -405,6 +413,7 @@ const en = {
|
|
|
405
413
|
pii_datalayer: "legal",
|
|
406
414
|
journey_incomplete: "hygiene",
|
|
407
415
|
unplanned_events: "hygiene",
|
|
416
|
+
ga4_automatic_event: "hygiene",
|
|
408
417
|
planned_out_of_scope: "hygiene",
|
|
409
418
|
matches_plan: "hygiene",
|
|
410
419
|
},
|
|
@@ -720,6 +729,7 @@ const en = {
|
|
|
720
729
|
|
|
721
730
|
const tr = {
|
|
722
731
|
name: "Turkish",
|
|
732
|
+
ga4Settings: { pageViews: "Sayfa görüntülemeleri", scrolls: "Kaydırmalar", outboundClicks: "Giden tıklamalar", siteSearch: "Site araması", videoEngagement: "Video etkileşimi", fileDownloads: "Dosya indirmeleri", formInteractions: "Form etkileşimleri" },
|
|
723
733
|
severity: { broken: "Bozuk", risk: "Risk", warning: "Uyarı", info: "Bilgi", ok: "Sorunsuz" },
|
|
724
734
|
ui: {
|
|
725
735
|
title: (host) => `ai tag debugger raporu: ${host}`,
|
|
@@ -774,6 +784,8 @@ const tr = {
|
|
|
774
784
|
customItemParams: "Özel ürün parametreleri",
|
|
775
785
|
noItems: "ürün yok",
|
|
776
786
|
serverSideTag: "sunucu tarafı",
|
|
787
|
+
lateHit: (ms) => `${(ms / 1000).toFixed(1).replace(".", ",")} sn gecikmeli geldi`,
|
|
788
|
+
lateHitTip: (step) => `Bu adım bittikten sonra, "${step}" sırasında gönderildi. Bu adımın sayfasına ait.`,
|
|
777
789
|
source: "Kaynak",
|
|
778
790
|
request: "İstek",
|
|
779
791
|
noPushes: "Bu adımda dataLayer gönderimi yok.",
|
|
@@ -938,6 +950,8 @@ const tr = {
|
|
|
938
950
|
journeyIncomplete: (note) => `Yolculuk hedefine ulaşmadı${note ? `: ${note}` : "."} Bu noktadan sonraki olaylar kontrol edilemedi.`,
|
|
939
951
|
missingEvent: (platform, name) => `${label(platform)} ${q(name)} bu yolculukta gönderilmedi; oysa yolculuk hedefine ulaştı. ${label(platform)} bu olay için hiçbir veri almadı.`,
|
|
940
952
|
missingEventEarly: (name) => `${q(name)} görülmedi; ancak yolculuk erken durduğu için bu olay eksik değil, doğrulanmamış sayılır.`,
|
|
953
|
+
missingEnhanced: (name, setting) => `GA4 ${q(name)} bu yolculukta gönderilmedi; oysa yolculuk hedefine ulaştı. GA4 bu olayı geliştirilmiş ölçüm açıkken etiket gerekmeden kendisi gönderir: GA4'te Yönetici > Veri akışları > web akışı > Geliştirilmiş ölçüm altında "${tr.ga4Settings[setting]}" ayarını açın. Olayı gönderen bir etiket de aynı işi görür.`,
|
|
954
|
+
missingAutomatic: (name) => `GA4 ${q(name)} görülmedi. GA4 bu olayı etiket gerekmeden kendisi oluşturur (first_visit ve session_start diğer isteklerindeki işaretlerden, user_engagement sayfa gizlendiğinde ya da kapatıldığında), bu yüzden kurulum gerektirmez ve eksik sayılmaz.`,
|
|
941
955
|
missingParams: (name, params) => `${q(name)} olayı ${andList(sortParams(params).map(q), "ve")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
|
|
942
956
|
duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi. Her gerçek olay ${n} kez sayılıyor.`,
|
|
943
957
|
purchaseValueZero: (platform, name, v) => `${label(platform)} ${q(name)} ${q(v)} değeriyle gönderildi. 0 veya daha düşük değerli bir satın alma gelire eklenmez; reklam platformları teklifleri buna göre optimize edemez.`,
|
|
@@ -1024,6 +1038,7 @@ const tr = {
|
|
|
1024
1038
|
pii_datalayer: "dataLayer karma yapılmamış kişisel veri tutuyor; sayfadaki her betik bunu okuyabilir, yalnızca ihtiyaç duyan etiket değil.",
|
|
1025
1039
|
journey_incomplete: "Tarayıcı bu yolculuğu bitiremedi; o noktadan sonraki her olay, dönüşüm dâhil, doğrulanmamış durumda.",
|
|
1026
1040
|
unplanned_events: "Planda olmayan olaylar gönderiliyor: ya plan güncel değil ya da etiketler kimsenin istemediği şeyler gönderiyor.",
|
|
1041
|
+
ga4_automatic_event: "Kayıp yok: GA4 bu olayı kendisi oluşturur ve kısa bir test ziyareti o olay gönderilmeden bitebilir.",
|
|
1027
1042
|
planned_out_of_scope: "Plandaki bazı olaylar bu çalıştırmanın kontrol etmediği platformlarda; durumları bilinmiyor.",
|
|
1028
1043
|
matches_plan: "Plandaki bu olaylar, planın istediği her şeyle birlikte gönderildi.",
|
|
1029
1044
|
},
|
|
@@ -1058,6 +1073,7 @@ const tr = {
|
|
|
1058
1073
|
pii_datalayer: "Kullanıcı verisini dataLayer'a karma yapılmış olarak gönderin ya da yalnızca ihtiyaç duyan etiketle sınırlayıp ardından temizleyin.",
|
|
1059
1074
|
journey_incomplete: "Tarayıcının neden durduğunu nottan okuyun (engellenen bir tıklama, bulunamayan bir öğe, bir hata), sayfayı ya da adımı düzeltin ve yolculuğu yeniden çalıştırın.",
|
|
1060
1075
|
unplanned_events: "İstenen olayları ölçüm planına ekleyin ya da onları gönderen etiketleri kaldırın.",
|
|
1076
|
+
ga4_automatic_event: "Düzeltme gerekmez. Olayı başvuru için planda tutabilir ya da kaldırabilirsiniz; hiçbir etiketin göndermesi gerekmez.",
|
|
1061
1077
|
planned_out_of_scope: "Kontrol edilmeleri için platformlarını bir sonraki çalıştırmanın sağlayıcılarına ekleyin.",
|
|
1062
1078
|
matches_plan: "Düzeltilecek bir şey yok.",
|
|
1063
1079
|
},
|
|
@@ -1093,6 +1109,7 @@ const tr = {
|
|
|
1093
1109
|
pii_datalayer: "legal",
|
|
1094
1110
|
journey_incomplete: "hygiene",
|
|
1095
1111
|
unplanned_events: "hygiene",
|
|
1112
|
+
ga4_automatic_event: "hygiene",
|
|
1096
1113
|
planned_out_of_scope: "hygiene",
|
|
1097
1114
|
matches_plan: "hygiene",
|
|
1098
1115
|
},
|
package/src/mcp.js
CHANGED
|
@@ -17,7 +17,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
17
17
|
import { AuditSession } from "./browser.js";
|
|
18
18
|
import { navigationError, privateHostChecker } from "./guard.js";
|
|
19
19
|
import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
|
|
20
|
-
import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
20
|
+
import { checkRun, groupFindings, isConsentCommand, summarize } from "./checks.js";
|
|
21
21
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
22
22
|
import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
23
23
|
import { validatePlan } from "./schema.js";
|
|
@@ -29,7 +29,7 @@ import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
|
|
|
29
29
|
import { createProvider } from "./providers.js";
|
|
30
30
|
import { savePlan } from "./plan.js";
|
|
31
31
|
import { changesLines, sinceLastRun } from "./changes.js";
|
|
32
|
-
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
32
|
+
import { DEFAULT_PROVIDERS, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
33
33
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
34
34
|
|
|
35
35
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -64,20 +64,68 @@ const audits = new Map();
|
|
|
64
64
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
65
65
|
const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
// Kept whole in the step result, wherever they appear: the end of a page address is where a UTM
|
|
68
|
+
// parameter or a gclid gets lost, so cutting it hides exactly what a campaign check needs.
|
|
69
|
+
const WHOLE_FIELDS = new Set(["page_location", "page_referrer"]);
|
|
70
|
+
const VALUE_LIMIT = 120;
|
|
71
|
+
const PUSH_LIMIT = 200;
|
|
72
|
+
const cut = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
73
|
+
const show = (v) => (typeof v === "string" ? v : JSON.stringify(v));
|
|
74
|
+
|
|
75
|
+
/** The page address fields anywhere in a pushed object, a few levels deep, as [name, value]. */
|
|
76
|
+
function wholeFieldsIn(value, depth = 0, out = []) {
|
|
77
|
+
if (!value || typeof value !== "object" || depth > 3) return out;
|
|
78
|
+
for (const [k, v] of Object.entries(value)) {
|
|
79
|
+
if (WHOLE_FIELDS.has(k) && typeof v === "string") out.push([k, v]);
|
|
80
|
+
else wholeFieldsIn(v, depth + 1, out);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function describeHit(h, steps) {
|
|
86
|
+
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));
|
|
87
|
+
const params = [
|
|
88
|
+
...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
|
|
89
|
+
...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
|
|
90
|
+
].join(", ");
|
|
91
|
+
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}`}")` : "";
|
|
92
|
+
return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${Object.entries(h.consent).map(([k, v]) => `${k}=${v}`).join(" ")}]` : ""}${params ? `: ${params}` : ""}${late}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
|
|
96
|
+
function describePush(p) {
|
|
97
|
+
if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
|
|
98
|
+
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]);
|
|
99
|
+
return `- consent ${p.value[1]}: ${signals}`;
|
|
100
|
+
}
|
|
101
|
+
const json = JSON.stringify(p.value) ?? String(p.value);
|
|
102
|
+
const whole = json.length > PUSH_LIMIT ? wholeFieldsIn(p.value) : [];
|
|
103
|
+
return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent parameters. */
|
|
107
|
+
function describeCall(c) {
|
|
108
|
+
let u;
|
|
109
|
+
try {
|
|
110
|
+
u = new URL(c.url);
|
|
111
|
+
} catch {
|
|
112
|
+
return `- ${c.vendor ?? c.provider} ${cut(c.url, VALUE_LIMIT)}`;
|
|
113
|
+
}
|
|
114
|
+
const signals = ["gcs", "gcd", "dma", "npa"].filter((k) => u.searchParams.has(k)).map((k) => `${k}=${u.searchParams.get(k)}`).join(" ");
|
|
115
|
+
return `- ${c.vendor ?? c.provider}: ${u.hostname}${u.pathname}${signals ? ` [${signals}]` : ""}`;
|
|
74
116
|
}
|
|
75
117
|
|
|
76
|
-
function stepCapture(audit, step) {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
118
|
+
export function stepCapture(audit, step) {
|
|
119
|
+
const { session } = audit;
|
|
120
|
+
const hits = session.hits.filter((h) => h.step === step);
|
|
121
|
+
const pushes = session.dataLayerLog.filter((e) => e.step === step);
|
|
122
|
+
// Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
|
|
123
|
+
const lateHere = session.hits.filter((h) => h.late?.during === step);
|
|
124
|
+
const pings = session.network.filter((c) => c.step === step && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
|
|
125
|
+
const lines = [`Tracking hits sent by this step: ${hits.length}`, ...hits.map((h) => describeHit(h, session.steps))];
|
|
126
|
+
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(", ")}`);
|
|
127
|
+
if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
|
|
128
|
+
if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
|
|
81
129
|
return lines.join("\n");
|
|
82
130
|
}
|
|
83
131
|
|
package/src/parsers.js
CHANGED
|
@@ -64,6 +64,8 @@ export function parseGa4(url, postData) {
|
|
|
64
64
|
for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
|
|
65
65
|
const items = [...merged].filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v);
|
|
66
66
|
const user = [...merged].filter(([k]) => isUserDataKey(k));
|
|
67
|
+
// GA4 makes first_visit and session_start from these flags on another hit; they are never sent by name.
|
|
68
|
+
const autoEvents = [["_fv", "first_visit"], ["_ss", "session_start"]].filter(([flag]) => merged.has(flag)).map(([, name]) => name);
|
|
67
69
|
return {
|
|
68
70
|
platform: "ga4",
|
|
69
71
|
delivery: ga4Delivery(u),
|
|
@@ -72,6 +74,7 @@ export function parseGa4(url, postData) {
|
|
|
72
74
|
params: ga4ParamsFrom(merged),
|
|
73
75
|
consent: ga4Consent(merged),
|
|
74
76
|
...(items.length ? { items } : {}),
|
|
77
|
+
...(autoEvents.length ? { autoEvents } : {}),
|
|
75
78
|
...(user.length ? { userFields: Object.fromEntries(user) } : {}),
|
|
76
79
|
url,
|
|
77
80
|
};
|
package/src/plan-html.js
CHANGED
|
@@ -18,12 +18,12 @@ const PLAN_CSS = `
|
|
|
18
18
|
.top-end{margin-left:auto;display:flex;align-items:center;gap:var(--s2)}
|
|
19
19
|
.byline{display:inline-flex;align-items:center;gap:var(--s2);font-size:12px;color:var(--muted)}
|
|
20
20
|
.logo{display:inline-flex}
|
|
21
|
-
svg.logo{display:inline-block;flex:none;color:var(--fg)}
|
|
21
|
+
svg.logo{display:inline-block;flex:none;color:var(--fg-strong)}
|
|
22
22
|
.logo .l-dark{display:none}
|
|
23
23
|
html[data-theme="dark"] .logo .l-light{display:none}
|
|
24
24
|
html[data-theme="dark"] .logo .l-dark{display:inline}
|
|
25
25
|
@media (prefers-color-scheme:dark){html:not([data-theme="light"]) .logo .l-light{display:none}html:not([data-theme="light"]) .logo .l-dark{display:inline}}
|
|
26
|
-
.eyebrow{font:
|
|
26
|
+
.eyebrow{font:500 12px/1.4 var(--font-sans);color:var(--muted)}
|
|
27
27
|
.plan{max-width:1100px;margin:0 auto;padding:var(--s5) var(--s6) var(--s7)}
|
|
28
28
|
.plan-title{display:grid;gap:var(--s2);margin-bottom:var(--s5)}
|
|
29
29
|
.plan-title .chips{margin-top:var(--s1)}
|
|
@@ -39,8 +39,8 @@ html[data-theme="dark"] .logo .l-dark{display:inline}
|
|
|
39
39
|
.pl-trigger{font-size:12px;color:var(--muted);margin-bottom:10px}
|
|
40
40
|
.pl-trigger b{font-weight:500;color:var(--fg)}
|
|
41
41
|
.chip .plat{display:inline-flex;align-items:center;gap:5px}
|
|
42
|
-
.chip.on{--k:var(--accent);--k-fg:var(--accent-fg)
|
|
43
|
-
.chip.req{--k:var(--sev-warning);--k-fg:var(--sev-warning-fg)
|
|
42
|
+
.chip.on{--k:var(--accent);--k-fg:var(--accent-fg)}
|
|
43
|
+
.chip.req{--k:var(--sev-warning);--k-fg:var(--sev-warning-fg)}
|
|
44
44
|
.pl-card-body{padding:var(--s4)}
|
|
45
45
|
.tbl td,.tbl th{vertical-align:top}
|
|
46
46
|
/* a narrow table scrolls inside its .tscroll instead of breaking names mid-word */
|
package/src/verify.js
CHANGED
|
@@ -14,6 +14,7 @@ const OWNER_BY_CHECK = {
|
|
|
14
14
|
server_side: "developer",
|
|
15
15
|
unplanned_events: "marketing",
|
|
16
16
|
planned_out_of_scope: "marketing",
|
|
17
|
+
ga4_automatic_event: "marketing",
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
/** Who most likely fixes this finding: the site's developer, whoever runs Tag Manager, or marketing (the plan itself). Tag and consent setup is the default. */
|
package/src/whitelabel.js
CHANGED
|
@@ -53,33 +53,66 @@ export const contrast = (a, b) => {
|
|
|
53
53
|
return (h + 0.05) / (l + 0.05);
|
|
54
54
|
};
|
|
55
55
|
const MIN_TEXT_CONTRAST = 4.5;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const MIN_MARK_CONTRAST = 3;
|
|
57
|
+
// The grounds of visual-system.md §1 the accent is drawn on, in each theme: card (Tally's surface), page (bg), card-2
|
|
58
|
+
// (surface-2: hovered and selected rows, hovered controls) and well. Links sit on all four; the tint is added per colour.
|
|
59
|
+
const GROUNDS = {
|
|
60
|
+
light: ["#ffffff", "#f4f6fa", "#ecf1f8", "#e9eef5"],
|
|
61
|
+
dark: ["#0e1e34", "#071529", "#15263f", "#040e1f"],
|
|
62
|
+
};
|
|
63
|
+
const BLACK = [0, 0, 0];
|
|
64
|
+
const WHITE = [255, 255, 255];
|
|
65
|
+
// The dark tint is the brand colour laid on the navy card, as the default --accent-bg is.
|
|
66
|
+
const DARK_CARD_RGB = rgb(GROUNDS.dark[0]);
|
|
59
67
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The colour mixed step by step towards `toward` until it reads at `min`:1 on every ground; the input when it already
|
|
70
|
+
* does. Mixing towards black or white keeps the hue, so a brand colour only gets darker or lighter, never another colour.
|
|
71
|
+
*/
|
|
72
|
+
function readable(hex, toward, grounds, min) {
|
|
62
73
|
let c = hex;
|
|
63
|
-
|
|
74
|
+
while (grounds.some((g) => contrast(c, g) < min)) {
|
|
75
|
+
const next = mix(c, toward, 0.1);
|
|
76
|
+
if (next === c) break; // as far as rounding goes: black and white pass every ground long before this
|
|
77
|
+
c = next;
|
|
78
|
+
}
|
|
64
79
|
return c;
|
|
65
80
|
}
|
|
66
81
|
|
|
67
82
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* reads on the
|
|
71
|
-
*
|
|
83
|
+
* The three accent tokens for a brand colour, per theme. The tint (--accent-bg) is the brand colour on the card: 10% on
|
|
84
|
+
* white in light, 25% on the navy in dark. The link colour (--accent-fg) is darkened (light) or lightened (dark) until it
|
|
85
|
+
* reads at 4.5:1 on the card, the page, card-2, the well and that tint; the colour for marks (--accent: the active tab
|
|
86
|
+
* and rail rule, focus rings, pressed filter edges) until it reaches 3:1 on the same grounds. A colour that already
|
|
87
|
+
* passes is left as it is.
|
|
88
|
+
* @returns {{ light: { accent: string, fg: string, bg: string }, dark: { accent: string, fg: string, bg: string } }}
|
|
89
|
+
*/
|
|
90
|
+
export function brandTokens(color) {
|
|
91
|
+
const lightBg = mix(color, WHITE, 0.9);
|
|
92
|
+
const darkBg = mix(color, DARK_CARD_RGB, 0.75);
|
|
93
|
+
const light = [...GROUNDS.light, lightBg];
|
|
94
|
+
const dark = [...GROUNDS.dark, darkBg];
|
|
95
|
+
return {
|
|
96
|
+
light: { accent: readable(color, BLACK, light, MIN_MARK_CONTRAST), fg: readable(mix(color, BLACK, 0.2), BLACK, light, MIN_TEXT_CONTRAST), bg: lightBg },
|
|
97
|
+
dark: { accent: readable(mix(color, WHITE, 0.2), WHITE, dark, MIN_MARK_CONTRAST), fg: readable(mix(color, WHITE, 0.5), WHITE, dark, MIN_TEXT_CONTRAST), bg: darkBg },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* CSS that recolors the report's accent: the active tab rule, focus rings, links and the step plates (visual-system.md §9),
|
|
103
|
+
* from brandTokens(), so a pale or a very dark brand colour never gives unreadable links or invisible focus rings in either
|
|
104
|
+
* theme. The dark set is written under both dark selectors the report uses, so a forced light theme on a dark OS keeps
|
|
105
|
+
* the light accent.
|
|
72
106
|
*/
|
|
73
107
|
export function brandCss(color) {
|
|
74
108
|
if (!color) return "";
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
return `:root{${light}}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${dark}}}:root[data-theme="dark"]{${dark}}`;
|
|
109
|
+
const { light, dark } = brandTokens(color);
|
|
110
|
+
const set = (t) => `--accent:${t.accent};--accent-fg:${t.fg};--accent-bg:${t.bg}`;
|
|
111
|
+
return `:root{${set(light)}}@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${set(dark)}}}:root[data-theme="dark"]{${set(dark)}}`;
|
|
79
112
|
}
|
|
80
113
|
|
|
81
114
|
// A forwarded PDF loses its file name, so every page carries the document title and the page number (Chromium fills the spans).
|
|
82
|
-
const PDF_FOOTER = '<div style="width:100%;font:9px system-ui,sans-serif;color:#
|
|
115
|
+
const PDF_FOOTER = '<div style="width:100%;font:9px \'Google Sans\',system-ui,sans-serif;color:#566479;padding:0 10mm;display:flex;justify-content:space-between"><span class="title"></span><span><span class="pageNumber"></span> / <span class="totalPages"></span></span></div>';
|
|
83
116
|
|
|
84
117
|
/** Prints an HTML report to a PDF with Chromium. The print styles stack every tab and open every details, so the PDF holds the whole report. */
|
|
85
118
|
export async function writePdf(htmlPath, pdfPath) {
|