@analitikisler/ai-tag-debugger 0.11.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/checks.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { messages } from "./i18n.js";
5
5
  import { isOwnAddress } from "./redact.js";
6
6
 
7
+ const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
7
8
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
8
9
  const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
9
10
 
@@ -136,6 +137,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
136
137
  // Platforms are compared in lower case ("GA4" in a plan is ga4). Planned events for
137
138
  // providers the user chose not to check are left out, and listed in one info line.
138
139
  plan = { ...plan, events: plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() })) };
140
+ const hasPlan = plan.events.length > 0;
139
141
  if (run.providers) {
140
142
  const skipped = plan.events.filter((e) => !run.providers.includes(e.platform) && expectedIn(e, run.journeyId));
141
143
  plan = { ...plan, events: plan.events.filter((e) => run.providers.includes(e.platform)) };
@@ -279,7 +281,9 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
279
281
  const own = run.hits.filter((h) => h.platform === platform);
280
282
  return { platform, server: names(own.filter((h) => h.delivery === "server")), browser: names(own.filter((h) => h.delivery !== "server")) };
281
283
  }).filter((p) => p.server.length);
282
- for (const p of byPlatform) findings.push({ ...base, severity: "info", check: "server_side", platform: p.platform, detail: d.serverSide(p.platform, p.server, p.browser) });
284
+ // Only name the platforms whose server-side copies this run would have checked.
285
+ const unseenServer = ["meta", "tiktok"].filter((p) => !run.providers || run.providers.includes(p)).map(platformName);
286
+ for (const p of byPlatform) findings.push({ ...base, severity: "info", check: "server_side", platform: p.platform, detail: d.serverSide(p.platform, p.server, p.browser, unseenServer) });
283
287
  }
284
288
  const serverVendors = [...new Set((run.network ?? []).map((c) => c.provider).filter((p) => p === "stape" || p === "elevar"))];
285
289
  if (serverVendors.length || serverHits.length) {
@@ -408,9 +412,9 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
408
412
  findings.push({ ...base, severity: "warning", check: "pii_datalayer", step: stepLabel(plainPush.step), detail: d.piiDataLayer(plainPush.userData.plain) });
409
413
  }
410
414
 
411
- // Events that are not in the plan.
415
+ // Events that are not in the plan. Without a plan every event would be listed, which says nothing.
412
416
  const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h)).map((h) => key(h.platform, h.name)))];
413
- if (unplanned.length) {
417
+ if (hasPlan && unplanned.length) {
414
418
  findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
415
419
  }
416
420
 
package/src/i18n.js CHANGED
@@ -53,6 +53,12 @@ const en = {
53
53
  whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, step "${step}"` : ""}`,
54
54
  allGood: "All planned events fired correctly on every journey tested.",
55
55
  tabEvents: "Events",
56
+ tabTimeline: "Timeline",
57
+ timelineGtmEvent: "GTM event",
58
+ timelineConsent: "Consent command",
59
+ timelineNoEvent: "No GTM event (data only)",
60
+ timelineNoHits: "No platform hit followed this push.",
61
+ timelineOther: "Hits without a dataLayer push",
56
62
  bodyDropped: "Request body not stored: this audit reached its 2 MB limit for stored request bodies.",
57
63
  tabNetwork: "Network requests",
58
64
  filterBy: "Filter by source",
@@ -75,6 +81,9 @@ const en = {
75
81
  impact: "Impact",
76
82
  evidence: "Evidence",
77
83
  fixSteps: "Fix",
84
+ verify: "How to verify",
85
+ owners: { developer: "Developer", gtm: "Tag Manager", marketing: "Marketing" },
86
+ ownerHint: { developer: "Site code and dataLayer", gtm: "Tags, triggers and consent setup", marketing: "The measurement plan" },
78
87
  places: (n) => `${n} ${n === 1 ? "place" : "places"}`,
79
88
  alsoCheck: "Not in the findings list, but worth checking",
80
89
  providersChecked: "Providers checked:",
@@ -92,6 +101,7 @@ const en = {
92
101
  changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? (ch.fixed.length === 1 ? "event" : "events") : (ch.fixed.length === 1 ? "problem" : "problems")} fixed` : "", ch.new.length ? `${ch.new.length} new ${ch.new.length === 1 ? "problem" : "problems"}` : ""].filter(Boolean).join(", ") : "No changes since the last run",
93
102
  changeFixed: "Fixed",
94
103
  changeNew: "New",
104
+ routeChanged: (list) => `The AI took a different route through the site than in the last run: ${list}`,
95
105
  noPreviousRun: "This is the first saved run for this site, so there is nothing to compare yet.",
96
106
  planVsSiteLead: "Each planned event, and what the site actually sent",
97
107
  coverageLine: (c) => `Coverage ${c.implemented} of ${c.checkable}${c.percent === null ? "" : ` (${c.percent}%)`}${c.not_checked ? `, ${c.not_checked} ${c.not_checked === 1 ? "event" : "events"} not checked` : ""}`,
@@ -104,6 +114,23 @@ const en = {
104
114
  stepsCol: "Steps",
105
115
  problemsCol: "Problems",
106
116
  scopeCard: "Scope",
117
+ site: "Site",
118
+ runCard: "This run",
119
+ runPlatforms: "Platforms",
120
+ runAi: "AI",
121
+ runAiOn: (name, model) => `On · ${name}${model ? ` · ${model}` : ""}`,
122
+ runAiOff: "Off",
123
+ runPlan: "Plan",
124
+ runPlanEvents: (n) => `${n} ${n === 1 ? "event" : "events"}`,
125
+ runNoPlan: "None",
126
+ runJourneys: "Journeys",
127
+ runViewports: "Viewports",
128
+ runLanguage: "Language",
129
+ runVersion: "Version",
130
+ consentDefault: "Default",
131
+ consentUpdate: "Update",
132
+ consentLead: "Consent Mode commands the site sent, in order.",
133
+ consentNone: "The site sent no Consent Mode commands on this journey.",
107
134
  skippedLine: (list, n) => `${n} out-of-scope ${n === 1 ? "request was" : "requests were"} skipped: ${list}.`,
108
135
  findingHint: "Open a finding to see its likely cause, evidence and fix steps.",
109
136
  spread: (j, s) => `${j} ${j === 1 ? "journey" : "journeys"}${s ? ` · ${s} ${s === 1 ? "step" : "steps"}` : ""}`,
@@ -138,7 +165,7 @@ const en = {
138
165
  purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} was sent with currency ${q(c)}, which is not a 3-letter ISO 4217 code in capitals (such as USD, EUR or TRY). Platforms may drop the revenue or convert it wrongly.`,
139
166
  purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
140
167
  purchaseMismatch: (id, diffs) => `GA4 and Meta reported ${id ? `order ${q(id)}` : "the purchase"} differently: ${diffs.map(([f, g, m]) => `${f} ${q(g)} in GA4 and ${q(m)} in Meta`).join("; ")}. One of the tags reads the wrong data.`,
141
- serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}. Server-side copies of Meta and TikTok events never pass through the browser, so they can't be seen here.`,
168
+ serverSide: (platform, server, browser, unseen = []) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}.${unseen.length ? ` Server-side copies of ${unseen.join(" and ")} events never pass through the browser, so they can't be seen here.` : ""}`,
142
169
  dedupIdMissing: (platform, name) => `${platform} ${q(name)} was sent from the browser without an event ID, while a server-side setup was seen on this site. If the server also sends this event, ${platform} can't match the two and counts it twice.`,
143
170
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
144
171
  consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
@@ -212,6 +239,12 @@ const tr = {
212
239
  whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, "${step}" adımı` : ""}`,
213
240
  allGood: "Test edilen tüm yolculuklarda plandaki tüm olaylar doğru çalıştı.",
214
241
  tabEvents: "Olaylar",
242
+ tabTimeline: "Zaman çizelgesi",
243
+ timelineGtmEvent: "GTM olayı",
244
+ timelineConsent: "İzin komutu",
245
+ timelineNoEvent: "GTM olayı yok (yalnızca veri)",
246
+ timelineNoHits: "Bu gönderimin ardından platform isteği gelmedi.",
247
+ timelineOther: "dataLayer gönderimi olmayan istekler",
215
248
  bodyDropped: "İstek gövdesi saklanmadı: bu denetim, saklanan istek gövdeleri için 2 MB sınırına ulaştı.",
216
249
  tabNetwork: "Ağ istekleri",
217
250
  filterBy: "Kaynağa göre filtrele",
@@ -234,6 +267,9 @@ const tr = {
234
267
  impact: "Etkisi",
235
268
  evidence: "Kanıt",
236
269
  fixSteps: "Çözüm",
270
+ verify: "Nasıl doğrulanır",
271
+ owners: { developer: "Yazılımcı", gtm: "Tag Manager", marketing: "Pazarlama" },
272
+ ownerHint: { developer: "Site kodu ve dataLayer", gtm: "Etiketler, tetikleyiciler ve onay ayarları", marketing: "Ölçüm planı" },
237
273
  places: (n) => `${n} yer`,
238
274
  alsoCheck: "Bulgu listesinde olmayan, ama kontrol edilmesi gerekenler",
239
275
  providersChecked: "Kontrol edilen sağlayıcılar:",
@@ -251,6 +287,7 @@ const tr = {
251
287
  changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? "olay" : "sorun"} düzeldi` : "", ch.new.length ? `${ch.new.length} yeni sorun` : ""].filter(Boolean).join(", ") : "Son çalıştırmadan bu yana değişiklik yok",
252
288
  changeFixed: "Düzeldi",
253
289
  changeNew: "Yeni",
290
+ routeChanged: (list) => `Yapay zeka siteyi son çalıştırmadan farklı bir yoldan gezdi: ${list}`,
254
291
  noPreviousRun: "Bu site için kayıtlı ilk çalıştırma bu, karşılaştırılacak bir şey henüz yok.",
255
292
  planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
256
293
  coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
@@ -263,6 +300,23 @@ const tr = {
263
300
  stepsCol: "Adım",
264
301
  problemsCol: "Sorun",
265
302
  scopeCard: "Kapsam",
303
+ site: "Site",
304
+ runCard: "Bu çalıştırma",
305
+ runPlatforms: "Platformlar",
306
+ runAi: "Yapay zekâ",
307
+ runAiOn: (name, model) => `Açık · ${name}${model ? ` · ${model}` : ""}`,
308
+ runAiOff: "Kapalı",
309
+ runPlan: "Plan",
310
+ runPlanEvents: (n) => `${n} olay`,
311
+ runNoPlan: "Yok",
312
+ runJourneys: "Yolculuklar",
313
+ runViewports: "Ekranlar",
314
+ runLanguage: "Dil",
315
+ runVersion: "Sürüm",
316
+ consentDefault: "Varsayılan",
317
+ consentUpdate: "Güncelleme",
318
+ consentLead: "Sitenin gönderdiği Consent Mode komutları, sırasıyla.",
319
+ consentNone: "Site bu yolculukta Consent Mode komutu göndermedi.",
266
320
  skippedLine: (list, n) => `Kapsam dışı ${n} istek atlandı: ${list}.`,
267
321
  findingHint: "Bir bulguyu açınca olası neden, kanıt ve çözüm adımları görünür.",
268
322
  spread: (j, s) => `${j} yolculuk${s ? ` · ${s} adım` : ""}`,
@@ -297,7 +351,7 @@ const tr = {
297
351
  purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} ${q(c)} para birimiyle gönderildi; bu, büyük harfli 3 harfli bir ISO 4217 kodu değil (USD, EUR veya TRY gibi). Platformlar geliri atabilir veya yanlış çevirebilir.`,
298
352
  purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
299
353
  purchaseMismatch: (id, diffs) => `GA4 ve Meta ${id ? `${q(id)} siparişini` : "satın almayı"} farklı bildirdi: ${diffs.map(([f, g, m]) => `${f === "value" ? "değer" : "para birimi"} GA4'te ${q(g)}, Meta'da ${q(m)}`).join("; ")}. Etiketlerden biri yanlış veriyi okuyor.`,
300
- serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}. Meta ve TikTok olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.`,
354
+ serverSide: (platform, server, browser, unseen = []) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}.${unseen.length ? ` ${unseen.join(" ve ")} olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.` : ""}`,
301
355
  dedupIdMissing: (platform, name) => `${platform} ${q(name)} tarayıcıdan olay kimliği olmadan gönderildi; sitede sunucu tarafı bir kurulum görüldü. Sunucu da bu olayı gönderiyorsa ${platform} ikisini eşleştiremez ve iki kez sayar.`,
302
356
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
303
357
  consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
package/src/mcp.js CHANGED
@@ -37,7 +37,7 @@ const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracki
37
37
 
38
38
  Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the site actually sends. If the user wants to know what to track, which events a site needs, or asks for a tracking or measurement plan, call make_plan (with topic when they only care about one kind of event: ${Object.keys(TOPICS).join(", ")}). If they want to know whether tracking works, what a site sends, or what changed, run an audit. To audit against the plan afterwards, pass against to finish_audit.
39
39
 
40
- Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
40
+ Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}, plus any other platform the site turns out to use); pass their choice as providers, or leave it out for the default.
41
41
 
42
42
  To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
43
43
 
@@ -153,7 +153,7 @@ export function buildServer(opts) {
153
153
  providers: z
154
154
  .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
155
155
  .optional()
156
- .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
156
+ .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
157
157
  },
158
158
  },
159
159
  async ({ site, journey, viewport, allowed_hosts, providers }) => {
@@ -367,7 +367,7 @@ export function buildServer(opts) {
367
367
  providers: z
368
368
  .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
369
369
  .optional()
370
- .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
370
+ .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
371
371
  },
372
372
  },
373
373
  async ({ config: configFile, against, scripted, language, branding, providers }) => {
package/src/parsers.js CHANGED
@@ -196,16 +196,162 @@ export function parseKlaviyo(url, postData) {
196
196
  }];
197
197
  }
198
198
 
199
- /** Hotjar: calls to its in.hotjar.com / .hotjar.io API carry the site id in the path. Only the id is read. @returns {Hit[] | null} */
199
+ /**
200
+ * LinkedIn Insight Tag: px.ads.linkedin.com/collect with the partner id (pid), and conversionId
201
+ * for a conversion; without one the hit is the page view. eventId pairs it with a Conversions API copy.
202
+ * @returns {Hit[] | null}
203
+ */
204
+ export function parseLinkedIn(url) {
205
+ const u = new URL(url);
206
+ const q = u.searchParams;
207
+ if (u.hostname !== "px.ads.linkedin.com" || !/^\/(collect|wa)\/?$/.test(u.pathname) || !q.has("pid")) return null;
208
+ const params = {};
209
+ for (const [from, to] of [["value", "value"], ["currency", "currency"], ["url", "page_location"]]) if (q.has(from)) params[to] = q.get(from);
210
+ const eventId = q.get("eventId") || undefined;
211
+ if (eventId) params.event_id = eventId;
212
+ const conversion = q.get("conversionId");
213
+ return [{ platform: "linkedin", name: conversion ? `conversion:${conversion}` : "pageview", id: q.get("pid"), ...(eventId ? { eventId } : {}), params, url }];
214
+ }
215
+
216
+ /**
217
+ * Microsoft Advertising UET: bat.bing.com/action/0 with the tag id (ti) and evt: "pageLoad" for page
218
+ * views, "custom" for events, named by their action (ea). gv and gc are the revenue and currency.
219
+ * @returns {Hit[] | null}
220
+ */
221
+ export function parseBing(url) {
222
+ const u = new URL(url);
223
+ const q = u.searchParams;
224
+ if (u.hostname !== "bat.bing.com" || !/^\/action\/\d+$/.test(u.pathname) || !q.has("ti")) return null;
225
+ const params = {};
226
+ for (const [from, to] of [["ec", "event_category"], ["el", "event_label"], ["ev", "event_value"], ["gv", "value"], ["gc", "currency"], ["p", "page_location"]]) {
227
+ if (q.has(from)) params[to] = q.get(from);
228
+ }
229
+ const evt = q.get("evt") || "";
230
+ const name = evt === "custom" ? q.get("ea") || "custom" : evt || "(unnamed)";
231
+ return [{ platform: "microsoft_ads", name, id: q.get("ti"), params, url }];
232
+ }
233
+
234
+ // Criteo OneTag event codes. Setup entries (site type, email, …) ride along in the same request.
235
+ const CRITEO_EVENTS = { vh: "viewHome", vl: "viewList", vp: "viewItem", vb: "viewBasket", vc: "trackTransaction", vs: "viewSearch" };
236
+
237
+ /**
238
+ * Criteo OneTag: sslwidget.criteo.com/event with the account (a) and one entry per pushed event
239
+ * in p0, p1, … (each itself a query string with e = the event code). Hashed emails are user data.
240
+ * @returns {Hit[] | null}
241
+ */
242
+ export function parseCriteo(url, postData) {
243
+ const u = new URL(url);
244
+ if (!/(^|\.)criteo\.(com|net)$/.test(u.hostname) || !/^\/(event|m\/event)\/?$/.test(u.pathname)) return null;
245
+ const q = new URLSearchParams(u.search);
246
+ if (postData && !postData.trim().startsWith("{")) for (const [k, v] of new URLSearchParams(postData)) q.set(k, v);
247
+ const entries = [...q].filter(([k]) => /^p\d+$/.test(k)).map(([, v]) => new URLSearchParams(v));
248
+ if (!q.has("a") || !entries.length) return null;
249
+ const params = {};
250
+ const user = {};
251
+ let name = "";
252
+ for (const e of entries) {
253
+ const code = e.get("e");
254
+ if (CRITEO_EVENTS[code] && !name) name = CRITEO_EVENTS[code];
255
+ for (const [k, v] of e) {
256
+ if (k === "e") continue;
257
+ if (/^m(\[\d*\])?$/.test(k) || isUserDataKey(k)) user[`${code}.${k}`] = v;
258
+ else if (k === "p") params.content_ids = v;
259
+ else if (k === "id" && code === "vc") params.transaction_id = v;
260
+ else if (k === "site_type") params.site_type = v;
261
+ else if (k === "c") params.currency = v;
262
+ else if (k === "item" || k === "i") params.items = v;
263
+ }
264
+ }
265
+ return [{ platform: "criteo", name: name || "(unnamed)", id: q.get("a"), params, ...(Object.keys(user).length ? { userFields: user } : {}), url }];
266
+ }
267
+
268
+ /**
269
+ * Adobe Analytics image request: /b/ss/<report suite>/<version>/<code>/s<random> on omtrdc.net,
270
+ * 2o7.net or a first-party host. A link (pe: lnk_o, lnk_d, lnk_e) is named by its link name (pev2),
271
+ * anything else is the page view. events, products, purchase id, currency and the page are kept.
272
+ * @returns {Hit[] | null}
273
+ */
274
+ export function parseAdobe(url) {
275
+ const u = new URL(url);
276
+ const m = u.pathname.match(/^\/b\/ss\/([^/]+)\/[^/]+\//);
277
+ if (!m) return null;
278
+ const q = u.searchParams;
279
+ const params = {};
280
+ for (const [from, to] of [["pageName", "page_title"], ["events", "events"], ["products", "items"], ["purchaseID", "transaction_id"], ["xact", "transaction_id"], ["cc", "currency"], ["g", "page_location"]]) {
281
+ if (q.has(from)) params[to] = q.get(from);
282
+ }
283
+ const pe = q.get("pe");
284
+ return [{ platform: "adobe", name: pe ? `link:${q.get("pev2") || pe}` : "pageView", id: decodeURIComponent(m[1]), params, url }];
285
+ }
286
+
287
+ // Matomo and Piwik PRO share one tracking protocol: an idsite and either an event (e_c, e_a, e_n),
288
+ // a goal (idgoal) or an ecommerce order (idgoal=0 with ec_id), otherwise a page view.
289
+ function matomoHit(platform, q, url) {
290
+ const params = {};
291
+ for (const [from, to] of [["revenue", "value"], ["ec_id", "transaction_id"], ["ec_items", "items"], ["ec_tx", "tax"], ["ec_sh", "shipping"], ["e_v", "event_value"], ["action_name", "page_title"], ["url", "page_location"], ["search", "search_term"]]) {
292
+ if (q.has(from)) params[to] = q.get(from);
293
+ }
294
+ const goal = q.get("idgoal");
295
+ let name = "pageview";
296
+ if (q.has("e_c")) name = q.get("e_a") ? `${q.get("e_c")}:${q.get("e_a")}` : q.get("e_c");
297
+ else if (goal === "0") name = q.has("ec_id") ? "ecommerce_order" : "ecommerce_cart_update";
298
+ else if (goal) name = `goal:${goal}`;
299
+ else if (q.has("search")) name = "site_search";
300
+ else if (q.has("link")) name = "outlink";
301
+ else if (q.has("download")) name = "download";
302
+ const user = [...q].filter(([k, v]) => isUserDataKey(k) && v);
303
+ return { platform, name, id: q.get("idsite") || undefined, params, ...(user.length ? { userFields: Object.fromEntries(user) } : {}), url };
304
+ }
305
+
306
+ // Matomo's tracker can batch hits: a JSON POST of { requests: ["?idsite=1&…", …] }.
307
+ function matomoHits(platform, u, postData) {
308
+ const requests = jsonOr(postData ?? "{}", {})?.requests;
309
+ const batched = Array.isArray(requests) ? requests.filter((r) => typeof r === "string").map((r) => new URLSearchParams(r.replace(/^\?/, ""))) : [];
310
+ const queries = batched.length ? batched.map((b) => { for (const [k, v] of u.searchParams) if (!b.has(k)) b.set(k, v); return b; }) : [u.searchParams];
311
+ return queries.filter((q) => q.has("idsite")).map((q) => matomoHit(platform, q, u.href));
312
+ }
313
+
314
+ /** Matomo (self-hosted or cloud): /matomo.php or /piwik.php with idsite, on any host. @returns {Hit[] | null} */
315
+ export function parseMatomo(url, postData) {
316
+ const u = new URL(url);
317
+ if (!/(^|\/)(matomo|piwik)\.php$/.test(u.pathname)) return null;
318
+ const hits = matomoHits("matomo", u, postData);
319
+ return hits.length ? hits : null;
320
+ }
321
+
322
+ /** Piwik PRO: /ppms.php with idsite (a UUID), on its cloud or a first-party host. @returns {Hit[] | null} */
323
+ export function parsePiwikPro(url, postData) {
324
+ const u = new URL(url);
325
+ if (!/(^|\/)ppms\.php$/.test(u.pathname)) return null;
326
+ const hits = matomoHits("piwik_pro", u, postData);
327
+ return hits.length ? hits : null;
328
+ }
329
+
330
+ /**
331
+ * Microsoft Clarity: the tag load (www.clarity.ms/tag/<project>) names the project; the data
332
+ * itself goes to <letter>.clarity.ms/collect as a compressed payload, so only the call is shown.
333
+ * @returns {Hit[] | null}
334
+ */
335
+ export function parseClarity(url) {
336
+ const u = new URL(url);
337
+ if (!/(^|\.)clarity\.ms$/.test(u.hostname)) return null;
338
+ const tag = u.pathname.match(/^\/tag\/([a-z0-9]+)\/?$/i);
339
+ if (tag) return [{ platform: "clarity", name: "script_load", id: tag[1], params: {}, url }];
340
+ if (/^\/collect\/?$/.test(u.pathname)) return [{ platform: "clarity", name: "collect", params: {}, url }];
341
+ return null;
342
+ }
343
+
344
+ /** Hotjar: the tag load (static.hotjar.com/c/hotjar-<id>.js) and calls to its API carry the site id in the path. Only the id is read. @returns {Hit[] | null} */
200
345
  export function parseHotjar(url) {
201
346
  const u = new URL(url);
202
347
  if (!/(^|\.)hotjar\.(com|io)$/.test(u.hostname)) return null;
348
+ const script = u.pathname.match(/^\/c\/hotjar-(\d+)\.js$/);
349
+ if (script) return [{ platform: "hotjar", name: "script_load", id: script[1], params: {}, url }];
203
350
  const m = u.pathname.match(/^\/api\/v\d+\/client\/sites\/(\d+)\/([a-z-]+)/i);
204
351
  if (!m) return null;
205
352
  return [{ platform: "hotjar", name: m[2], id: m[1], params: {}, url }];
206
353
  }
207
354
 
208
- const PARSERS = [parseGa4, parseMeta, parseGoogleAds, parseTikTok, parseX, parsePinterest, parseSnapchat, parseKlaviyo, parseHotjar];
209
355
 
210
356
  /** Returns normalized hits for a request, or null when it is not a tracking request. */
211
357
  export function parseTrackingRequest(url, postData) {
@@ -231,7 +377,7 @@ const OTHER_BEACONS = [
231
377
  [/./, /\/(ccm|ccm\/s|g\/s)\/collect$/],
232
378
  [/^td\.doubleclick\.net$/, /^\/td\//],
233
379
  [/(^|\.)facebook\.com$/, /^\/privacy_sandbox\/pixel\//],
234
- [/^bat\.bing\.com$/, /^\/action\//],
380
+ [/^bat\.bing\.com$/, /^\/action\//], // UET requests without a tag id
235
381
  [/^px\.ads\.linkedin\.com$/, /^\/(collect|wa)/],
236
382
  [/^ct\.pinterest\.com$/, /^\/(v3|user)/],
237
383
  [/^tr\.snapchat\.com$/, /^\//],
@@ -249,24 +395,39 @@ export function isCollectionEndpoint(url) {
249
395
  return OTHER_BEACONS.some(([host, pathname]) => host.test(u.hostname) && pathname.test(u.pathname));
250
396
  }
251
397
 
252
- // Providers the user can choose to check. Requests to the ones not chosen are still
398
+ /**
399
+ * Every tracking vendor: its key, display name, the hosts its requests go to (path: how its
400
+ * tracker is told apart on a first-party host) and, when its events are read, a parser. Adding a vendor is one entry here plus its icon in report.js.
401
+ */
402
+ export const VENDORS = [
403
+ { key: "ga4", label: "GA4", hosts: /(^|\.)(google-analytics|analytics\.google)\.com$/, parse: parseGa4 },
404
+ { key: "google_ads", label: "Google Ads", hosts: /(^|\.)googleadservices\.com$/, parse: parseGoogleAds },
405
+ { key: "meta", label: "Meta", hosts: /(^|\.)(facebook\.com|facebook\.net)$/, parse: parseMeta },
406
+ { key: "tiktok", label: "TikTok", hosts: /(^|\.)tiktok\.com$/, parse: parseTikTok },
407
+ { key: "x", label: "X", hosts: /(^|\.)(x\.com|twitter\.com|t\.co|ads-twitter\.com)$/, parse: parseX },
408
+ { key: "linkedin", label: "LinkedIn", hosts: /(^|\.)(licdn\.com|ads\.linkedin\.com)$/, parse: parseLinkedIn },
409
+ { key: "microsoft_ads", label: "Microsoft Ads", hosts: /(^|\.)bing\.com$/, parse: parseBing },
410
+ { key: "pinterest", label: "Pinterest", hosts: /(^|\.)(pinterest\.com|pinimg\.com)$/, parse: parsePinterest },
411
+ { key: "snapchat", label: "Snapchat", hosts: /(^|\.)(snapchat\.com|sc-static\.net)$/, parse: parseSnapchat },
412
+ { key: "criteo", label: "Criteo", hosts: /(^|\.)(criteo\.com|criteo\.net)$/, parse: parseCriteo },
413
+ { key: "klaviyo", label: "Klaviyo", hosts: /(^|\.)klaviyo\.com$/, parse: parseKlaviyo },
414
+ { key: "adobe", label: "Adobe Analytics", hosts: /(^|\.)(omtrdc|2o7)\.net$/, path: /^\/b\/ss\//, parse: parseAdobe },
415
+ { key: "matomo", label: "Matomo", hosts: /(^|\.)(matomo\.cloud|innocraft\.cloud)$/, path: /(^|\/)(matomo|piwik)\.php$/, parse: parseMatomo },
416
+ // No host: piwik.pro also serves consent management, so its tracker is recognized by path.
417
+ { key: "piwik_pro", label: "Piwik PRO", path: /(^|\/)ppms\.php$/, parse: parsePiwikPro },
418
+ { key: "clarity", label: "Microsoft Clarity", hosts: /(^|\.)clarity\.ms$/, parse: parseClarity },
419
+ { key: "hotjar", label: "Hotjar", hosts: /(^|\.)hotjar\.(com|io)$/, parse: parseHotjar },
420
+ { key: "doubleclick", label: "DoubleClick" },
421
+ { key: "adsense", label: "Google AdSense", hosts: /(^|\.)googlesyndication\.com$/ },
422
+ ];
423
+
424
+ const PARSERS = VENDORS.filter((v) => v.parse).map((v) => v.parse);
425
+
426
+ // Providers the user can choose to check. Requests to the ones not checked are still
253
427
  // answered locally, but they are left out of the timeline and only counted.
254
- export const PROVIDERS = {
255
- ga4: "GA4",
256
- google_ads: "Google Ads",
257
- meta: "Meta",
258
- tiktok: "TikTok",
259
- x: "X",
260
- linkedin: "LinkedIn",
261
- microsoft_ads: "Microsoft Ads",
262
- pinterest: "Pinterest",
263
- snapchat: "Snapchat",
264
- klaviyo: "Klaviyo",
265
- clarity: "Microsoft Clarity",
266
- hotjar: "Hotjar",
267
- doubleclick: "DoubleClick",
268
- adsense: "Google AdSense",
269
- };
428
+ export const PROVIDERS = Object.fromEntries(VENDORS.map((v) => [v.key, v.label]));
429
+ // Always checked. Any other vendor the site sends to is added on its first request,
430
+ // unless the platforms to check were chosen.
270
431
  export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
271
432
  // Tag managers and consent platforms are always shown: they explain how the tags loaded.
272
433
  // Server-side tagging providers are too: their requests show where events are sent from.
@@ -275,19 +436,7 @@ const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform", s
275
436
  const HOSTS = [
276
437
  [/(^|\.)stape\.(io|jp)$/, "stape"],
277
438
  [/(^|\.)getelevar\.com$/, "elevar"],
278
- [/(^|\.)(google-analytics|analytics\.google)\.com$/, "ga4"],
279
- [/(^|\.)googleadservices\.com$/, "google_ads"],
280
- [/(^|\.)googlesyndication\.com$/, "adsense"],
281
- [/(^|\.)(facebook\.com|facebook\.net)$/, "meta"],
282
- [/(^|\.)tiktok\.com$/, "tiktok"],
283
- [/(^|\.)(x\.com|twitter\.com|t\.co|ads-twitter\.com)$/, "x"],
284
- [/(^|\.)(licdn\.com|ads\.linkedin\.com)$/, "linkedin"],
285
- [/(^|\.)bing\.com$/, "microsoft_ads"],
286
- [/(^|\.)clarity\.ms$/, "clarity"],
287
- [/(^|\.)hotjar\.(com|io)$/, "hotjar"],
288
- [/(^|\.)(pinterest\.com|pinimg\.com)$/, "pinterest"],
289
- [/(^|\.)(snapchat\.com|sc-static\.net)$/, "snapchat"],
290
- [/(^|\.)klaviyo\.com$/, "klaviyo"],
439
+ ...VENDORS.filter((v) => v.hosts).map((v) => [v.hosts, v.key]),
291
440
  [/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.(eu|com)|cookieyes\.com|didomi\.io|privacy-center\.org|trustarc\.com|truste\.com|consensu\.org|quantcast\.com|osano\.com|iubenda\.com|termly\.io|cookiefirst\.com|axeptio\.eu|sourcepoint\.com|sp-prod\.net|consentmanager\.net|cookiescript\.com|cookie-script\.com|cookieinformation\.com|civiccomputing\.com|piwik\.pro|complianz\.io|cdn\.privacy-mgmt\.com)$/, "consent"],
292
441
  ];
293
442
 
@@ -310,6 +459,8 @@ export function providerOf(url) {
310
459
  if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
311
460
  // Before the vendor hosts: a GA4 hit to a Stape or Elevar endpoint is still a GA4 hit.
312
461
  if (isGa4Path(u) && !GOOGLE_COMPANION_HOST.test(host)) return of("ga4");
462
+ // Before the hosts too: trackers on a first-party or consent vendor host are told apart by path.
463
+ for (const v of VENDORS) if (v.path?.test(u.pathname)) return of(v.key);
313
464
  for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
314
465
  if (isGa4Path(u)) return of("ga4");
315
466
  if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
package/src/redact.js CHANGED
@@ -88,12 +88,29 @@ function note(found, key, value) {
88
88
  }
89
89
 
90
90
  /** Redacts form-encoded parameters (a URL query or a GA4 body line). Returns null when nothing needed it. */
91
+ // Criteo OneTag nests each event as a query string in p0, p1, …; its setEmail entry (e=ce) carries the email hash in m.
92
+ const isNestedEvent = (k, v) => /^p\d+$/.test(k) && /^e=/.test(v);
93
+ const nestedUserKey = (k) => /^m(\[\d*\])?$/.test(k) || isUserDataKey(k);
94
+
91
95
  function redactParamString(text, found, site) {
92
96
  const params = new URLSearchParams(text);
93
- if (![...params].some(([k, v]) => isUserDataKey(k) || isEmail(v, site, k))) return null;
97
+ let changed = false;
94
98
  const out = new URLSearchParams();
95
- for (const [k, v] of params) out.append(k, isUserDataKey(k) || isEmail(v, site, k) ? note(found, k, v) : v);
96
- return out.toString();
99
+ for (const [k, v] of params) {
100
+ if (isNestedEvent(k, v)) {
101
+ const inner = new URLSearchParams();
102
+ for (const [ik, iv] of new URLSearchParams(v)) {
103
+ const hide = nestedUserKey(ik) || isEmail(iv, site, ik);
104
+ if (hide) changed = true;
105
+ inner.append(ik, hide ? note(found, ik, iv) : iv);
106
+ }
107
+ out.append(k, inner.toString());
108
+ } else if (isUserDataKey(k) || isEmail(v, site, k)) {
109
+ changed = true;
110
+ out.append(k, note(found, k, v));
111
+ } else out.append(k, v);
112
+ }
113
+ return changed ? out.toString() : null;
97
114
  }
98
115
 
99
116
  /** Redacts user data in a URL's query. Returns the URL unchanged when there is none. site: see isOwnAddress. */