@analitikisler/ai-tag-debugger 0.7.8 → 0.8.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/CHANGELOG.md +23 -0
- package/README.md +14 -3
- package/README.tr.md +11 -2
- package/package.json +2 -1
- package/src/agent.js +4 -1
- package/src/browser.js +54 -6
- package/src/checks.js +139 -10
- package/src/i18n.js +22 -0
- package/src/journeys.js +5 -1
- package/src/mcp.js +4 -2
- package/src/parsers.js +91 -6
- package/src/redact.js +1 -1
- package/src/replay.js +1 -1
- package/src/report.js +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
- The browser installs itself the first time an audit starts, so connecting the MCP server no longer needs a separate `playwright install` step. Progress goes to stderr.
|
|
6
|
+
- Listed in the official MCP Registry as `io.github.analitikisler/ai-tag-debugger` (`mcpName` in package.json, `server.json`).
|
|
7
|
+
- The README has one-line and one-click setup for Claude Code, Cursor and VS Code.
|
|
8
|
+
- The MCP server reports its real version instead of a fixed 0.6.3.
|
|
9
|
+
|
|
10
|
+
## 0.7.9 (2026-10-14)
|
|
11
|
+
|
|
12
|
+
- A planned event is found under the name the site uses: `dl_add_to_cart` (Elevar), `addToCart` and `Add To Cart` all count as `add_to_cart`, and similar variants of the other ecommerce events too. Sites like these no longer get a false "missing add_to_cart". A plan event can list more names in `aliases`.
|
|
13
|
+
- A required parameter is also found under its usual other name (`products` for `items`, `revenue` for `value`, `order_id` for `transaction_id`).
|
|
14
|
+
- The plan coverage table in the report uses the same matching.
|
|
15
|
+
- Server-side tagging is recognised where the browser can see it. A GA4 hit to a server container (server-side GTM, Stape, Elevar) is marked server-side, a hit through Google tag gateway (`/g/c`) is not, and requests to Stape and Elevar hosts show in the timeline. An info finding lists, per platform, which events went server-side and which went from the browser. The report tags server-side events.
|
|
16
|
+
- Meta (`eid`) and TikTok (`event_id`) event IDs are read from the browser hit. The duplicate check no longer flags hits that repeat one event ID, since the platform counts those once.
|
|
17
|
+
- New info finding when a server-side setup is seen and a Meta or TikTok browser event has no event ID, so the platform can't match it to its server copy. Meta Conversions API and TikTok Events API requests themselves are sent server to server and can't be seen from the browser.
|
|
18
|
+
- New parsers for the Pinterest tag, Snap Pixel, Klaviyo onsite events and Hotjar API calls. Pinterest, Snap and Klaviyo hits show their event name, id, value fields and, when present, the hashed or plain user data they send (redacted like Meta's). Hotjar shows the site id.
|
|
19
|
+
- A Meta hit now shows `event_id` (`eid`), the value a Conversions API copy must repeat to be deduplicated, and its advanced matching fields (`ud[...]`).
|
|
20
|
+
- Klaviyo is a selectable provider.
|
|
21
|
+
- Consent Mode v2: the report flags a default that leaves out `ad_user_data` or `ad_personalization`, and an accept whose update does not grant them.
|
|
22
|
+
- New `decline_cookies` journey (also `decline`, `reject`): the AI rejects all cookies, browses on, and the report checks that nothing is sent afterwards. It flags ad pixels and Google hits that claim consent after the reject, a Consent Mode update that grants consent after it, and a banner that is still showing after the reject click. Clicks carry `rejects_consent`, like `accepts_consent` for accepting; replays keep it.
|
|
23
|
+
- Tracking cookies (`_ga`, `_fbp`, `_gcl_*`, `_ttp` and similar) are noted with the step that set them, by name only. Ones set before the visitor chose, or after they rejected, are flagged.
|
|
24
|
+
- More consent platforms are recognized as banners that block a click (Osano, Iubenda, Complianz, CookieFirst, Axeptio, Sourcepoint, TrustArc, Borlabs, Klaro, Termly and others) and as consent requests in the timeline (Didomi, Quantcast, TrustArc, Sourcepoint, Iubenda, Termly and others).
|
|
25
|
+
|
|
3
26
|
## 0.7.8 (2026-10-14)
|
|
4
27
|
|
|
5
28
|
- Unnamed TikTok pings are left out of the duplicate check. TikTok sends them on its own timing, so their count changed between a save and its replay without the site changing.
|
package/README.md
CHANGED
|
@@ -41,11 +41,11 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
|
|
|
41
41
|
| Missing events | `purchase` never fired on the checkout journey |
|
|
42
42
|
| Missing parameters | `purchase` sent without `value` and `currency` on mobile |
|
|
43
43
|
| Duplicates | `add_to_cart` sent twice by one click |
|
|
44
|
-
| Consent | Meta `PageView` fired before the visitor accepted cookies, Consent Mode default missing or set too late |
|
|
44
|
+
| Consent | Meta `PageView` fired before the visitor accepted cookies, Consent Mode default missing or set too late, Consent Mode v2 signals (`ad_user_data`, `ad_personalization`) missing, tracking cookies set before consent, tags or cookies still active after the `decline_cookies` journey rejects the banner, a reject button that does nothing |
|
|
45
45
|
| Personal data | An email address sent to an ad platform in plain text |
|
|
46
46
|
| Unplanned events | Events firing that aren't in your measurement plan |
|
|
47
47
|
|
|
48
|
-
Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
|
|
48
|
+
Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain; hits to a server container are marked server-side, and Stape and Elevar requests are recognised), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
|
|
49
49
|
|
|
50
50
|
### Choose the providers to check
|
|
51
51
|
|
|
@@ -235,6 +235,8 @@ The first run's findings become the expected ones. When a change is right, for e
|
|
|
235
235
|
|
|
236
236
|
`platform` is `ga4`, `google_ads`, `meta` or `tiktok`. `journeys` limits where an event is expected (default: every journey). Set `allow_multiple: true` for events that may legitimately fire more than once per action.
|
|
237
237
|
|
|
238
|
+
Event names are matched loosely, so `dl_add_to_cart` (Elevar), `addToCart` and `Add To Cart` count as `add_to_cart`, and `transaction` or `order_completed` as `purchase`. Add your own with `"aliases": ["my_event"]` on a planned event. Required parameters are also found under their usual other names: `products` for `items`, `revenue` for `value`, `order_id` for `transaction_id`.
|
|
239
|
+
|
|
238
240
|
## CLI
|
|
239
241
|
|
|
240
242
|
```
|
|
@@ -301,7 +303,16 @@ Add the server to the app's MCP config. For Claude Desktop that is Settings →
|
|
|
301
303
|
}
|
|
302
304
|
```
|
|
303
305
|
|
|
304
|
-
|
|
306
|
+
The browser (Chromium) downloads itself the first time you run an audit, about a minute, one time. To get it ahead of time: `npx -y playwright@1.56.1 install chromium`.
|
|
307
|
+
|
|
308
|
+
One-click and one-line setup:
|
|
309
|
+
|
|
310
|
+
- **Claude Code:** `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`
|
|
311
|
+
- **Cursor:** [Add to Cursor](https://cursor.com/en/install-mcp?name=ai-tag-debugger&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhbmFsaXRpa2lzbGVyL2FpLXRhZy1kZWJ1Z2dlciIsIm1jcCJdfQ==)
|
|
312
|
+
- **VS Code:** [Install in VS Code](https://insiders.vscode.dev/redirect/mcp/install?name=ai-tag-debugger&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40analitikisler%2Fai-tag-debugger%22%2C%22mcp%22%5D%7D), or `code --add-mcp '{"name":"ai-tag-debugger","command":"npx","args":["-y","@analitikisler/ai-tag-debugger","mcp"]}'`
|
|
313
|
+
- **Claude Desktop, Gemini CLI and others:** the JSON block above. Gemini CLI uses the same `mcpServers` block in `~/.gemini/settings.json`.
|
|
314
|
+
|
|
315
|
+
Restart the app, and the ai-tag-debugger tools appear. If the app can't find `npx`, use its full path (`which npx`, for example `/opt/homebrew/bin/npx` on a Mac with Homebrew). Reports are written to `~/ai-tag-debugger-reports`.
|
|
305
316
|
|
|
306
317
|
Running from a clone instead? Use `"command": "node"` and `"args": ["/path/to/ai-tag-debugger/src/cli.js", "mcp"]`.
|
|
307
318
|
|
package/README.tr.md
CHANGED
|
@@ -41,7 +41,7 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
|
|
|
41
41
|
| Eksik event'ler | `purchase`, ödeme senaryosunda hiç gönderilmedi |
|
|
42
42
|
| Eksik parametreler | `purchase`, mobilde `value` ve `currency` olmadan gönderildi |
|
|
43
43
|
| Tekrar eden event'ler | `add_to_cart` tek tıklamada iki kez gönderildi |
|
|
44
|
-
| Çerez onayı | Meta `PageView`, ziyaretçi çerezleri kabul etmeden gönderildi; Consent Mode varsayılanı yok ya da çok geç
|
|
44
|
+
| Çerez onayı | Meta `PageView`, ziyaretçi çerezleri kabul etmeden gönderildi; Consent Mode varsayılanı yok ya da çok geç ayarlanmış, Consent Mode v2 sinyalleri (`ad_user_data`, `ad_personalization`) eksik, onaydan önce yazılan izleme çerezleri, `decline_cookies` yolculuğu bandı reddettikten sonra hâlâ çalışan etiketler ya da çerezler, işe yaramayan reddet düğmesi |
|
|
45
45
|
| Kişisel veri | Bir e-posta adresi reklam platformuna açık metin olarak gönderildi |
|
|
46
46
|
| Planda olmayan event'ler | Ölçümleme planınızda bulunmayan event'ler gönderiliyor |
|
|
47
47
|
|
|
@@ -303,7 +303,16 @@ Sunucuyu uygulamanın MCP ayarlarına ekleyin. Claude Desktop'ta bu Settings →
|
|
|
303
303
|
}
|
|
304
304
|
```
|
|
305
305
|
|
|
306
|
-
|
|
306
|
+
Tarayıcı (Chromium), ilk denetimi çalıştırdığınızda kendiliğinden iner (bir kez, yaklaşık bir dakika). Önceden kurmak için: `npx -y playwright@1.56.1 install chromium`.
|
|
307
|
+
|
|
308
|
+
Tek tıkla ve tek satırla kurulum:
|
|
309
|
+
|
|
310
|
+
- **Claude Code:** `claude mcp add ai-tag-debugger -- npx -y @analitikisler/ai-tag-debugger mcp`
|
|
311
|
+
- **Cursor:** [Cursor'a ekle](https://cursor.com/en/install-mcp?name=ai-tag-debugger&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBhbmFsaXRpa2lzbGVyL2FpLXRhZy1kZWJ1Z2dlciIsIm1jcCJdfQ==)
|
|
312
|
+
- **VS Code:** [VS Code'a kur](https://insiders.vscode.dev/redirect/mcp/install?name=ai-tag-debugger&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40analitikisler%2Fai-tag-debugger%22%2C%22mcp%22%5D%7D) veya `code --add-mcp '{"name":"ai-tag-debugger","command":"npx","args":["-y","@analitikisler/ai-tag-debugger","mcp"]}'`
|
|
313
|
+
- **Claude Desktop, Gemini CLI ve diğerleri:** yukarıdaki JSON bloğu. Gemini CLI, `~/.gemini/settings.json` içinde aynı `mcpServers` bloğunu kullanır.
|
|
314
|
+
|
|
315
|
+
Uygulamayı yeniden başlatın, ai-tag-debugger araçları görünür. Uygulama `npx`'i bulamazsa tam yolunu kullanın (`which npx`; örneğin Homebrew kurulu bir Mac'te `/opt/homebrew/bin/npx`). Raporlar `~/ai-tag-debugger-reports` klasörüne yazılır.
|
|
307
316
|
|
|
308
317
|
Kaynak koddan mı çalıştırıyorsunuz? `"command": "node"` ve `"args": ["/path/to/ai-tag-debugger/src/cli.js", "mcp"]` kullanın.
|
|
309
318
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
|
-
"
|
|
3
|
+
"mcpName": "io.github.analitikisler/ai-tag-debugger",
|
|
4
|
+
"version": "0.8.0",
|
|
4
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.",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
package/src/agent.js
CHANGED
|
@@ -14,7 +14,7 @@ How to work:
|
|
|
14
14
|
- Do one action per call and read the new page before the next action.
|
|
15
15
|
- Elements on screen are listed first. If what you need isn't listed, scroll, or use text instead of ref to act on an element by its visible text or label (e.g. text "Rezervasyon Yap").
|
|
16
16
|
- Date pickers and custom dropdowns usually need clicks rather than fill: click the field, then click the date or option.
|
|
17
|
-
- If a cookie or consent banner appears, accept all cookies (as most visitors do) and set accepts_consent to true on that click.
|
|
17
|
+
- If a cookie or consent banner appears, accept all cookies (as most visitors do) and set accepts_consent to true on that click. (A journey whose goal says to reject or decline cookies does that instead and sets rejects_consent to true on that click; if the banner has no reject-all button, use the one that declines non-essential cookies, and never accept.)
|
|
18
18
|
- Use obvious test data in forms: name "Test User", email "qa-test@example.com", phone "5550100", address "1 Test Street", city "Testville", postcode "00000".
|
|
19
19
|
- Never enter real payment card details. If the site offers a test or demo payment option, use it. If completing the journey would require a real payment or a real account you don't have, stop and call finish with success false and explain why.
|
|
20
20
|
- If something is broken (an error, a button that does nothing), try a reasonable alternative once, then finish with success false and describe what happened.
|
|
@@ -39,6 +39,7 @@ export const TOOLS = [
|
|
|
39
39
|
url: { type: "string", description: "Destination for goto." },
|
|
40
40
|
reason: { type: "string", description: "Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report." },
|
|
41
41
|
accepts_consent: { type: "boolean", description: "True only when this click accepts the cookie or consent banner." },
|
|
42
|
+
rejects_consent: { type: "boolean", description: "True only when this click rejects or declines all cookies on the consent banner." },
|
|
42
43
|
},
|
|
43
44
|
required: ["action", "reason"],
|
|
44
45
|
},
|
|
@@ -215,6 +216,8 @@ export function collect(session, journey, viewport) {
|
|
|
215
216
|
hits: session.hits,
|
|
216
217
|
steps: session.steps,
|
|
217
218
|
consentStep: session.consentStep,
|
|
219
|
+
rejectStep: session.rejectStep,
|
|
220
|
+
cookieLog: session.cookieLog,
|
|
218
221
|
dataLayerLog: session.dataLayerLog,
|
|
219
222
|
network: session.network,
|
|
220
223
|
providers: session.providers,
|
package/src/browser.js
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
1
3
|
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import { chromium, devices } from "playwright";
|
|
3
6
|
import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders } from "./parsers.js";
|
|
4
7
|
import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
|
|
5
8
|
import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
|
|
6
9
|
import { startEgressProxy } from "./egress.js";
|
|
7
10
|
|
|
11
|
+
// Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
|
|
12
|
+
// one of these always blocks the journey, and a reject click is checked against them afterwards.
|
|
13
|
+
let browserInstall = null;
|
|
14
|
+
/** Downloads Chromium with Playwright's own installer. Output goes to stderr, because stdout carries the MCP protocol. */
|
|
15
|
+
export function installBrowser() {
|
|
16
|
+
browserInstall ??= new Promise((resolve, reject) => {
|
|
17
|
+
const cli = path.join(path.dirname(createRequire(import.meta.url).resolve("playwright/package.json")), "cli.js");
|
|
18
|
+
console.error("ai-tag-debugger: installing the Chromium browser (one time, about a minute)...");
|
|
19
|
+
const child = spawn(process.execPath, [cli, "install", "chromium"], { stdio: ["ignore", 2, 2] });
|
|
20
|
+
child.on("error", reject);
|
|
21
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`Installing the browser failed (exit code ${code}). Run: npx -y playwright@1.56.1 install chromium`))));
|
|
22
|
+
}).finally(() => { browserInstall = null; });
|
|
23
|
+
return browserInstall;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const CMP_BANNER_SELECTORS = `#onetrust-banner-sdk, #onetrust-consent-sdk, #CybotCookiebotDialog, #usercentrics-root, #didomi-host, #cookiescript_injected, .cky-consent-container, .cc-window, #cmpbox, .qc-cmp2-container, \
|
|
27
|
+
#truste-consent-track, #consent_blackbar, .osano-cm-window, #iubenda-cs-banner, .iubenda-cs-container, #cmplz-cookiebanner-container, .cmplz-cookiebanner, #cookiefirst-root, .cookiefirst-root, #axeptio_overlay, \
|
|
28
|
+
#sp_message_container, [id^="sp_message_container_"], #cookie-law-info-bar, #BorlabsCookieBox, .klaro, #termly-code-snippet-support, #cookie-information-template-wrapper, #coiOverlay, \
|
|
29
|
+
#cmpwrapper, #cmpbox2, .fc-consent-root, #ppms_cm_popup_overlay, [aria-label*="cookie" i][role=dialog]`;
|
|
30
|
+
|
|
8
31
|
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
9
32
|
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
|
|
10
33
|
|
|
@@ -341,6 +364,8 @@ export class AuditSession {
|
|
|
341
364
|
this.hits = [];
|
|
342
365
|
this.steps = [];
|
|
343
366
|
this.consentStep = null;
|
|
367
|
+
this.rejectStep = null;
|
|
368
|
+
this.cookieLog = []; // each cookie name and domain with the step it was first seen at (never values)
|
|
344
369
|
this.errors = [];
|
|
345
370
|
this.dataLayerLog = [];
|
|
346
371
|
this.network = [];
|
|
@@ -369,7 +394,13 @@ export class AuditSession {
|
|
|
369
394
|
// flight once they are removed at the end, go to a proxy that isn't there.
|
|
370
395
|
launch.proxy = { server: "http://127.0.0.1:9", bypass: "<-loopback>" };
|
|
371
396
|
}
|
|
372
|
-
|
|
397
|
+
try {
|
|
398
|
+
this.browser = await chromium.launch(launch);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (!/Executable doesn't exist|playwright install/i.test(String(err?.message))) throw err;
|
|
401
|
+
await installBrowser();
|
|
402
|
+
this.browser = await chromium.launch(launch);
|
|
403
|
+
}
|
|
373
404
|
const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
|
|
374
405
|
this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
|
|
375
406
|
await this.context.exposeBinding("__atdRecord", (_source, entry) => {
|
|
@@ -717,7 +748,7 @@ export class AuditSession {
|
|
|
717
748
|
if (!/intercepts pointer events|not stable|outside of the viewport/.test(err.message)) throw err;
|
|
718
749
|
}
|
|
719
750
|
const cover = await locator
|
|
720
|
-
.evaluate((el) => {
|
|
751
|
+
.evaluate((el, selectors) => {
|
|
721
752
|
const r = el.getBoundingClientRect();
|
|
722
753
|
const x = Math.min(Math.max(r.left + r.width / 2, 0), window.innerWidth - 1);
|
|
723
754
|
const y = Math.min(Math.max(r.top + r.height / 2, 0), window.innerHeight - 1);
|
|
@@ -742,13 +773,13 @@ export class AuditSession {
|
|
|
742
773
|
const modal = n.matches("dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true]");
|
|
743
774
|
// Cookie and consent banners block even when they cover only part of the screen.
|
|
744
775
|
const consent =
|
|
745
|
-
n.matches(
|
|
776
|
+
n.matches(selectors) ||
|
|
746
777
|
(/(cookie|consent|gdpr|kvkk)/i.test(`${n.id} ${typeof n.className === "string" ? n.className : ""}`) && ["fixed", "sticky"].includes(getComputedStyle(n).position));
|
|
747
778
|
if (consent) return { blocking: true, what: describe(n) };
|
|
748
779
|
if (modal || b.width * b.height >= screen * 0.5) return { blocking: true, what: describe(n) };
|
|
749
780
|
}
|
|
750
781
|
return { blocking: false, what: describe(hit), free };
|
|
751
|
-
})
|
|
782
|
+
}, CMP_BANNER_SELECTORS)
|
|
752
783
|
.catch(() => null);
|
|
753
784
|
if (cover?.blocking) {
|
|
754
785
|
throw new Error(`The element is covered by ${cover.what}. Deal with that first (for example accept or close it), as a visitor would.`);
|
|
@@ -770,10 +801,11 @@ export class AuditSession {
|
|
|
770
801
|
/**
|
|
771
802
|
* Performs one action and records it as a step. Returns an error message
|
|
772
803
|
* instead of throwing, so the agent can recover.
|
|
773
|
-
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
|
|
804
|
+
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean, rejects_consent?: boolean }} a
|
|
774
805
|
*/
|
|
775
806
|
async act(a) {
|
|
776
807
|
const error = await this.perform(a);
|
|
808
|
+
await this.trackCookies();
|
|
777
809
|
// A scripted replay of this step may fail the same way, which must not end the journey there.
|
|
778
810
|
if (error) this.steps[this.currentStep].error = error;
|
|
779
811
|
return error;
|
|
@@ -781,7 +813,7 @@ export class AuditSession {
|
|
|
781
813
|
|
|
782
814
|
async perform(a) {
|
|
783
815
|
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
784
|
-
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
|
|
816
|
+
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent, rejectsConsent: !!a.rejects_consent });
|
|
785
817
|
const step = this.steps[this.currentStep];
|
|
786
818
|
try {
|
|
787
819
|
switch (a.action) {
|
|
@@ -843,12 +875,28 @@ export class AuditSession {
|
|
|
843
875
|
const blocked = await this.enforce();
|
|
844
876
|
if (blocked) return blocked;
|
|
845
877
|
if (a.accepts_consent && this.consentStep === null) this.consentStep = this.currentStep;
|
|
878
|
+
if (a.rejects_consent && this.rejectStep === null) {
|
|
879
|
+
this.rejectStep = this.currentStep;
|
|
880
|
+
// A rejected banner should close; one that is still showing means the button did nothing.
|
|
881
|
+
step.bannerOpenAfter = await this.page.evaluate((sel) => [...document.querySelectorAll(sel)].some((n) => {
|
|
882
|
+
const r = n.getBoundingClientRect();
|
|
883
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
|
|
884
|
+
}), CMP_BANNER_SELECTORS).catch(() => false);
|
|
885
|
+
}
|
|
846
886
|
return null;
|
|
847
887
|
} catch (err) {
|
|
848
888
|
return (await this.enforce().catch(() => null)) ?? err.message.split("\n")[0];
|
|
849
889
|
}
|
|
850
890
|
}
|
|
851
891
|
|
|
892
|
+
/** Notes which cookies appeared during the step just taken (names and domains only). */
|
|
893
|
+
async trackCookies() {
|
|
894
|
+
const seen = new Set(this.cookieLog.map((c) => `${c.domain}|${c.name}`));
|
|
895
|
+
for (const c of await this.cookies()) {
|
|
896
|
+
if (!seen.has(`${c.domain}|${c.name}`) && this.cookieLog.length < 500) this.cookieLog.push({ ...c, step: this.currentStep });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
852
900
|
async cookies() {
|
|
853
901
|
let timer;
|
|
854
902
|
const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve([]), 10_000)));
|
package/src/checks.js
CHANGED
|
@@ -6,16 +6,63 @@ import { isOwnAddress } from "./redact.js";
|
|
|
6
6
|
|
|
7
7
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
8
8
|
const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
9
|
+
// Consent Mode v2 added two signals to ad_storage and analytics_storage. Google requires them for EEA/UK traffic.
|
|
10
|
+
const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
|
|
11
|
+
const TRACKING_COOKIE = /^(_ga(_.+)?|_gid|_gat(_.+)?|_gcl_.+|_fbp|_fbc|_ttp|_tt_.+|_uet(sid|vid)|_clck|_clsk|_hj.+|_pin_unauth|_scid|IDE|test_cookie|fr|li_.+|_dc_gtm_.+)$/;
|
|
9
12
|
const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
|
-
* @typedef {{ platform: string, name: string, required_params?: string[], journeys?: string[], allow_multiple?: boolean }} PlanEvent
|
|
15
|
+
* @typedef {{ platform: string, name: string, aliases?: string[], required_params?: string[], journeys?: string[], allow_multiple?: boolean }} PlanEvent
|
|
13
16
|
* @typedef {{ consent_mode?: boolean, events: PlanEvent[] }} Plan
|
|
14
17
|
* @typedef {{ severity: "broken"|"risk"|"warning"|"info"|"ok", check: string, journey: string, viewport: string, platform?: string, event?: string, step?: string, detail: string, evidence?: string }} Finding
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
const key = (platform, name) => `${platform}:${name}`;
|
|
18
21
|
|
|
22
|
+
// Sites name events in their own way: Elevar's dl_add_to_cart, UA-style addToCart, Add To Cart.
|
|
23
|
+
// Names are compared without case, spaces, dashes, underscores and a leading "dl", then through
|
|
24
|
+
// SYNONYMS to one canonical name. A plan event can add its own names with "aliases".
|
|
25
|
+
const SYNONYMS = {
|
|
26
|
+
viewitem: ["productview", "productdetail", "productdetails", "detail", "viewproduct", "viewcontent"],
|
|
27
|
+
viewitemlist: ["productlist", "viewproductlist", "productimpressions", "viewcollection", "viewcategory"],
|
|
28
|
+
addtocart: ["add", "addtobasket", "addtobag", "cartadd"],
|
|
29
|
+
removefromcart: ["remove", "removefrombasket", "removefrombag", "cartremove"],
|
|
30
|
+
viewcart: ["cartview", "viewbasket", "viewbag"],
|
|
31
|
+
begincheckout: ["checkout", "initiatecheckout", "checkoutstart", "checkoutbegin"],
|
|
32
|
+
addshippinginfo: ["checkoutshipping", "shippinginfo"],
|
|
33
|
+
addpaymentinfo: ["checkoutpayment", "paymentinfo", "addpayment"],
|
|
34
|
+
purchase: ["transaction", "ordercomplete", "ordercompleted", "orderconfirmation", "checkoutcomplete", "checkoutcompleted"],
|
|
35
|
+
};
|
|
36
|
+
const CANONICAL = new Map(Object.entries(SYNONYMS).flatMap(([c, list]) => list.map((a) => [a, c])));
|
|
37
|
+
|
|
38
|
+
/** One comparable form of an event name. */
|
|
39
|
+
export function canonicalEvent(name) {
|
|
40
|
+
let n = String(name ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
41
|
+
if (n.startsWith("dl") && n.length > 2) n = n.slice(2);
|
|
42
|
+
return CANONICAL.get(n) ?? n;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether a captured hit is the planned event, by its exact name, an alias or a common variant. */
|
|
46
|
+
export function hitMatchesEvent(ev, hit) {
|
|
47
|
+
if (hit.platform !== ev.platform) return false;
|
|
48
|
+
if (hit.name === ev.name) return true;
|
|
49
|
+
const h = canonicalEvent(hit.name);
|
|
50
|
+
return h === canonicalEvent(ev.name) || (ev.aliases ?? []).some((a) => canonicalEvent(a) === h);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ecommerce parameters sites send under another name (UA-style products/revenue, order ids).
|
|
54
|
+
const PARAM_ALIASES = {
|
|
55
|
+
items: ["products", "item_list", "contents"],
|
|
56
|
+
value: ["revenue", "total"],
|
|
57
|
+
transaction_id: ["order_id", "orderid", "oid"],
|
|
58
|
+
currency: ["currency_code", "cu"],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Whether a hit carries a required parameter, under its own name or a common alias. */
|
|
62
|
+
export function hasParam(hit, name) {
|
|
63
|
+
return [name, ...(PARAM_ALIASES[name] ?? [])].some((n) => hit.params?.[n] !== undefined && hit.params[n] !== "");
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
/** The page load a hit came from: GA4's page load id (_p) when it has one, else the page address. */
|
|
20
67
|
function pageOf(hit) {
|
|
21
68
|
try {
|
|
@@ -79,7 +126,8 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
79
126
|
if (skipped.length) findings.push({ ...base, severity: "info", check: "planned_out_of_scope", detail: d.plannedOutOfScope(skipped.map((e) => key(e.platform, e.name))) });
|
|
80
127
|
}
|
|
81
128
|
const stepLabel = (i) => run.steps[i]?.label ?? `step ${i}`;
|
|
82
|
-
const
|
|
129
|
+
const planEventFor = (hit) => plan.events.find((e) => hitMatchesEvent(e, hit));
|
|
130
|
+
const planKeyOf = (hit) => { const e = planEventFor(hit); return e ? key(e.platform, e.name) : key(hit.platform, hit.name); };
|
|
83
131
|
|
|
84
132
|
if (!run.completed) {
|
|
85
133
|
findings.push({ ...base, severity: "warning", check: "journey_incomplete", detail: d.journeyIncomplete(run.note) });
|
|
@@ -88,7 +136,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
88
136
|
// Expected events that never fired.
|
|
89
137
|
for (const ev of plan.events) {
|
|
90
138
|
if (!expectedIn(ev, run.journeyId)) continue;
|
|
91
|
-
const fired = run.hits.some((h) =>
|
|
139
|
+
const fired = run.hits.some((h) => hitMatchesEvent(ev, h));
|
|
92
140
|
if (!fired) {
|
|
93
141
|
findings.push({
|
|
94
142
|
...base,
|
|
@@ -104,9 +152,9 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
104
152
|
// Required parameters missing on events that did fire (reported once per event).
|
|
105
153
|
const paramsReported = new Set();
|
|
106
154
|
for (const hit of run.hits) {
|
|
107
|
-
const ev =
|
|
155
|
+
const ev = planEventFor(hit);
|
|
108
156
|
if (!ev?.required_params?.length) continue;
|
|
109
|
-
const missing = ev.required_params.filter((p) => hit
|
|
157
|
+
const missing = ev.required_params.filter((p) => !hasParam(hit, p));
|
|
110
158
|
const reportKey = `${key(hit.platform, hit.name)}|${missing.join(",")}`;
|
|
111
159
|
if (missing.length && !paramsReported.has(reportKey)) {
|
|
112
160
|
paramsReported.add(reportKey);
|
|
@@ -127,14 +175,18 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
127
175
|
// new page can also carry the previous page's last hits, so the page is part of the key.
|
|
128
176
|
const perStep = new Map();
|
|
129
177
|
for (const hit of run.hits) {
|
|
130
|
-
const k = `${hit.step}|${
|
|
178
|
+
const k = `${hit.step}|${planKeyOf(hit)}|${pageOf(hit)}`;
|
|
131
179
|
perStep.set(k, [...(perStep.get(k) ?? []), hit]);
|
|
132
180
|
}
|
|
133
181
|
for (const [, hits] of perStep) {
|
|
134
|
-
const ev =
|
|
182
|
+
const ev = planEventFor(hits[0]);
|
|
135
183
|
if (hits.length < 2 || ev?.allow_multiple) continue;
|
|
136
184
|
// TikTok sends unnamed pings (no event name) on its own schedule, so their count varies between runs.
|
|
137
185
|
if (hits[0].platform === "tiktok" && hits[0].name === "(unnamed)") continue;
|
|
186
|
+
// Hits that repeat an event ID are one event to the platform (it keeps the first), so only
|
|
187
|
+
// distinct IDs, and hits without an ID, count as separate sends.
|
|
188
|
+
const sends = new Set(hits.map((h, i) => (h.eventId ? `id:${h.eventId}` : `n:${i}`))).size;
|
|
189
|
+
if (sends < 2) continue;
|
|
138
190
|
findings.push({
|
|
139
191
|
...base,
|
|
140
192
|
severity: "warning",
|
|
@@ -142,10 +194,32 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
142
194
|
platform: hits[0].platform,
|
|
143
195
|
event: hits[0].name,
|
|
144
196
|
step: stepLabel(hits[0].step),
|
|
145
|
-
detail: d.duplicate(hits[0].name,
|
|
197
|
+
detail: d.duplicate(hits[0].name, sends),
|
|
146
198
|
});
|
|
147
199
|
}
|
|
148
200
|
|
|
201
|
+
// Server-side tagging. Only what the browser can see: a GA4 hit to a server container, and requests
|
|
202
|
+
// to Stape or Elevar. Meta's and TikTok's server-side copies never pass through the browser.
|
|
203
|
+
const serverHits = run.hits.filter((h) => h.delivery === "server");
|
|
204
|
+
if (serverHits.length) {
|
|
205
|
+
const names = (list) => [...new Set(list.map((h) => h.name))];
|
|
206
|
+
const byPlatform = [...new Set(run.hits.map((h) => h.platform))].map((platform) => {
|
|
207
|
+
const own = run.hits.filter((h) => h.platform === platform);
|
|
208
|
+
return { platform, server: names(own.filter((h) => h.delivery === "server")), browser: names(own.filter((h) => h.delivery !== "server")) };
|
|
209
|
+
}).filter((p) => p.server.length);
|
|
210
|
+
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) });
|
|
211
|
+
}
|
|
212
|
+
const serverVendors = [...new Set((run.network ?? []).map((c) => c.provider).filter((p) => p === "stape" || p === "elevar"))];
|
|
213
|
+
if (serverVendors.length || serverHits.length) {
|
|
214
|
+
const noId = new Map();
|
|
215
|
+
for (const hit of run.hits) {
|
|
216
|
+
if ((hit.platform === "meta" || hit.platform === "tiktok") && !hit.eventId && !noId.has(key(hit.platform, hit.name))) noId.set(key(hit.platform, hit.name), hit);
|
|
217
|
+
}
|
|
218
|
+
for (const [, hit] of noId) {
|
|
219
|
+
findings.push({ ...base, severity: "info", check: "dedup_id_missing", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.dedupIdMissing(hit.platform, hit.name) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
149
223
|
// Consent.
|
|
150
224
|
const granted = consentGrantedStep(run);
|
|
151
225
|
const firstHitStep = run.hits.length ? Math.min(...run.hits.map((h) => h.step)) : null;
|
|
@@ -157,8 +231,63 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
157
231
|
findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
|
|
158
232
|
}
|
|
159
233
|
}
|
|
234
|
+
const defaults = run.dataLayerLog.filter((e) => isConsentCommand(e, "default"));
|
|
235
|
+
if (plan.consent_mode && defaults.length) {
|
|
236
|
+
const declared = new Set(defaults.flatMap((e) => Object.keys(e.value[2] ?? {})));
|
|
237
|
+
const missing = V2_SIGNALS.filter((k) => !declared.has(k));
|
|
238
|
+
if (missing.length) findings.push({ ...base, severity: "risk", check: "consent_v2_missing", detail: d.consentV2Missing(missing) });
|
|
239
|
+
}
|
|
240
|
+
if (granted !== null) {
|
|
241
|
+
const updates = run.dataLayerLog.filter((e) => isConsentCommand(e, "update"));
|
|
242
|
+
if (updates.length) {
|
|
243
|
+
const grantedKeys = new Set(updates.flatMap((e) => Object.entries(e.value[2] ?? {}).filter(([, v]) => v === "granted").map(([k]) => k)));
|
|
244
|
+
const missing = V2_SIGNALS.filter((k) => !grantedKeys.has(k));
|
|
245
|
+
if (missing.length) findings.push({ ...base, severity: "warning", check: "consent_v2_update", detail: d.consentV2Update(missing) });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Declining: nothing may be sent or stored for ads after the visitor said no.
|
|
250
|
+
const rejectStep = run.rejectStep ?? null;
|
|
251
|
+
if (rejectStep !== null) {
|
|
252
|
+
const reject = run.steps.find((s) => s.index === rejectStep);
|
|
253
|
+
const regranted = run.dataLayerLog.find(
|
|
254
|
+
(e) => e.step >= rejectStep && isConsentCommand(e, "update") && Object.values(e.value[2] ?? {}).includes("granted"),
|
|
255
|
+
);
|
|
256
|
+
if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
|
|
257
|
+
else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
|
|
258
|
+
const afterReject = new Map();
|
|
259
|
+
for (const hit of run.hits) {
|
|
260
|
+
if (hit.step < rejectStep) continue;
|
|
261
|
+
const claimsGranted = hit.consent?.ad_storage === "granted" || hit.consent?.analytics_storage === "granted";
|
|
262
|
+
if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted) {
|
|
263
|
+
const k = key(hit.platform, hit.name);
|
|
264
|
+
if (!afterReject.has(k)) afterReject.set(k, hit);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const [, hit] of afterReject) {
|
|
268
|
+
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) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Cookies set before the visitor chose (or after they said no).
|
|
273
|
+
const cookieCutoff = rejectStep ?? granted;
|
|
274
|
+
const earlyCookies = (run.cookieLog ?? []).filter((c) => TRACKING_COOKIE.test(c.name) && (cookieCutoff === null || c.step < cookieCutoff));
|
|
275
|
+
if (earlyCookies.length) {
|
|
276
|
+
const names = [...new Set(earlyCookies.map((c) => c.name))];
|
|
277
|
+
findings.push({ ...base, severity: "risk", check: "cookies_before_consent", step: stepLabel(Math.min(...earlyCookies.map((c) => c.step))), detail: granted === null && rejectStep === null ? d.cookiesNeverConsent(names) : d.cookiesBeforeConsent(names) });
|
|
278
|
+
}
|
|
279
|
+
if (rejectStep !== null) {
|
|
280
|
+
const lateCookies = (run.cookieLog ?? []).filter((c) => TRACKING_COOKIE.test(c.name) && c.step >= rejectStep);
|
|
281
|
+
if (lateCookies.length) {
|
|
282
|
+
const names = [...new Set(lateCookies.map((c) => c.name))];
|
|
283
|
+
findings.push({ ...base, severity: "broken", check: "cookies_after_reject", step: stepLabel(Math.min(...lateCookies.map((c) => c.step))), detail: d.cookiesAfterReject(names) });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
160
287
|
const beforeConsent = new Map();
|
|
161
288
|
for (const hit of run.hits) {
|
|
289
|
+
// Hits from the reject click on are covered by the after_reject check.
|
|
290
|
+
if (rejectStep !== null && hit.step >= rejectStep) continue;
|
|
162
291
|
const early = granted === null || hit.step < granted;
|
|
163
292
|
if (!early) continue;
|
|
164
293
|
const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
|
|
@@ -207,14 +336,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
207
336
|
}
|
|
208
337
|
|
|
209
338
|
// Events that are not in the plan.
|
|
210
|
-
const unplanned = [...new Set(run.hits.filter((h) => !
|
|
339
|
+
const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h)).map((h) => key(h.platform, h.name)))];
|
|
211
340
|
if (unplanned.length) {
|
|
212
341
|
findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
|
|
213
342
|
}
|
|
214
343
|
|
|
215
344
|
// Planned events that fired cleanly.
|
|
216
345
|
const problemKeys = new Set(findings.filter((f) => f.event).map((f) => key(f.platform, f.event)));
|
|
217
|
-
const okKeys = [...new Set(run.hits.map((h) => key(h.platform, h.name)))].filter((k) =>
|
|
346
|
+
const okKeys = [...new Set(run.hits.filter((h) => planEventFor(h)).map((h) => key(h.platform, h.name)))].filter((k) => !problemKeys.has(k));
|
|
218
347
|
if (okKeys.length) {
|
|
219
348
|
findings.push({ ...base, severity: "ok", check: "matches_plan", detail: d.matches(okKeys) });
|
|
220
349
|
}
|
package/src/i18n.js
CHANGED
|
@@ -61,6 +61,7 @@ const en = {
|
|
|
61
61
|
customItemParams: "Custom item parameters",
|
|
62
62
|
itemsCount: (n) => `${n} ${n === 1 ? "item" : "items"}`,
|
|
63
63
|
noItems: "no items",
|
|
64
|
+
serverSideTag: "server-side",
|
|
64
65
|
source: "Source",
|
|
65
66
|
request: "Request",
|
|
66
67
|
noRequest: "No matching request",
|
|
@@ -120,10 +121,20 @@ const en = {
|
|
|
120
121
|
missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so this may not be a tracking bug.`,
|
|
121
122
|
missingParams: (name, params) => `${q(name)} was sent without ${params.map(q).join(", ")}.`,
|
|
122
123
|
duplicate: (name, n) => `${q(name)} was sent ${n} times by one action, which inflates counts and conversions.`,
|
|
124
|
+
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.`,
|
|
125
|
+
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.`,
|
|
123
126
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
124
127
|
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
125
128
|
beforeConsentNever: (platform, name) => `${platform} ${q(name)} fired although consent was never given in this journey.`,
|
|
126
129
|
beforeConsent: (platform, name) => `${platform} ${q(name)} fired before the visitor accepted cookies.`,
|
|
130
|
+
consentV2Missing: (keys) => `The Consent Mode default does not set ${keys.map(q).join(" and ")}. Consent Mode v2 needs them for Google ads in the EEA and UK; without them ad personalization and conversion measurement can stop.`,
|
|
131
|
+
consentV2Update: (keys) => `After the visitor accepted, the Consent Mode update did not grant ${keys.map(q).join(" and ")}. Ad conversions and remarketing may stay off.`,
|
|
132
|
+
rejectIgnored: () => "A Consent Mode update granted consent after the visitor rejected cookies, so the reject button does not stick.",
|
|
133
|
+
rejectButtonFailed: () => "The cookie banner was still showing after the reject button was clicked.",
|
|
134
|
+
afterReject: (platform, name) => `${platform} ${q(name)} was still sent after the visitor rejected cookies.`,
|
|
135
|
+
cookiesBeforeConsent: (names) => `Tracking cookies were set before the visitor accepted: ${names.join(", ")}.`,
|
|
136
|
+
cookiesNeverConsent: (names) => `Tracking cookies were set although consent was never given in this journey: ${names.join(", ")}.`,
|
|
137
|
+
cookiesAfterReject: (names) => `Tracking cookies were set after the visitor rejected cookies: ${names.join(", ")}.`,
|
|
127
138
|
pii: (platform, name) => `An email address was sent in plain text with ${q(name)} to ${platform}.`,
|
|
128
139
|
unplanned: (list) => `Events sent that are not in the measurement plan: ${list.join(", ")}.`,
|
|
129
140
|
piiUnhashed: (platform, name, fields) => `${q(name)} sent user data to ${platform} without hashing it (${fields.join(", ")}). Ad platforms expect these values SHA-256 hashed.`,
|
|
@@ -192,6 +203,7 @@ const tr = {
|
|
|
192
203
|
customItemParams: "Özel ürün parametreleri",
|
|
193
204
|
itemsCount: (n) => `${n} ürün`,
|
|
194
205
|
noItems: "ürün yok",
|
|
206
|
+
serverSideTag: "sunucu tarafı",
|
|
195
207
|
source: "Kaynak",
|
|
196
208
|
request: "İstek",
|
|
197
209
|
noRequest: "Eşleşen istek yok",
|
|
@@ -251,10 +263,20 @@ const tr = {
|
|
|
251
263
|
missingEventEarly: (name) => `${q(name)} görülmedi, ancak yolculuk erken durduğu için bu bir izleme hatası olmayabilir.`,
|
|
252
264
|
missingParams: (name, params) => `${q(name)} olayı ${params.map(q).join(", ")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
|
|
253
265
|
duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi; bu da sayıları ve dönüşümleri şişirir.`,
|
|
266
|
+
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.`,
|
|
267
|
+
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.`,
|
|
254
268
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
255
269
|
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
|
256
270
|
beforeConsentNever: (platform, name) => `${platform} ${q(name)}, bu yolculukta hiç onay verilmemesine rağmen tetiklendi.`,
|
|
257
271
|
beforeConsent: (platform, name) => `${platform} ${q(name)}, ziyaretçi çerezleri kabul etmeden önce tetiklendi.`,
|
|
272
|
+
consentV2Missing: (keys) => `Consent Mode varsayılanı ${keys.map(q).join(" ve ")} değerlerini ayarlamıyor. Consent Mode v2, AEA ve Birleşik Krallık'taki Google reklamları için bunları ister; yoksa reklam kişiselleştirme ve dönüşüm ölçümü durabilir.`,
|
|
273
|
+
consentV2Update: (keys) => `Ziyaretçi kabul ettikten sonra Consent Mode güncellemesi ${keys.map(q).join(" ve ")} iznini vermedi. Reklam dönüşümleri ve yeniden pazarlama kapalı kalabilir.`,
|
|
274
|
+
rejectIgnored: () => "Ziyaretçi çerezleri reddettikten sonra bir Consent Mode güncellemesi izin verdi; reddet düğmesi işe yaramıyor.",
|
|
275
|
+
rejectButtonFailed: () => "Reddet düğmesine tıklandıktan sonra çerez bandı hâlâ görünüyordu.",
|
|
276
|
+
afterReject: (platform, name) => `${platform} ${q(name)}, ziyaretçi çerezleri reddettikten sonra da gönderildi.`,
|
|
277
|
+
cookiesBeforeConsent: (names) => `Ziyaretçi kabul etmeden önce izleme çerezleri yazıldı: ${names.join(", ")}.`,
|
|
278
|
+
cookiesNeverConsent: (names) => `Bu yolculukta hiç onay verilmemesine rağmen izleme çerezleri yazıldı: ${names.join(", ")}.`,
|
|
279
|
+
cookiesAfterReject: (names) => `Ziyaretçi çerezleri reddettikten sonra izleme çerezleri yazıldı: ${names.join(", ")}.`,
|
|
258
280
|
pii: (platform, name) => `${q(name)} ile birlikte ${platform} platformuna düz metin olarak bir e-posta adresi gönderildi.`,
|
|
259
281
|
unplanned: (list) => `Ölçüm planında olmayan olaylar gönderildi: ${list.join(", ")}.`,
|
|
260
282
|
piiUnhashed: (platform, name, fields) => `${q(name)}, ${platform} platformuna kullanıcı verisini karma (hash) yapmadan gönderdi (${fields.join(", ")}). Reklam platformları bu değerleri SHA-256 ile karma yapılmış bekler.`,
|
package/src/journeys.js
CHANGED
|
@@ -46,9 +46,13 @@ export const JOURNEY_PRESETS = {
|
|
|
46
46
|
{ platform: "meta", name: "Search" },
|
|
47
47
|
],
|
|
48
48
|
},
|
|
49
|
+
decline_cookies: {
|
|
50
|
+
goal: "Reject all cookies on the cookie banner (the reject-all or decline button, never accept) and set rejects_consent to true on that click. Then browse the site like a visitor who said no: scroll the home page, open a product or article page, and open a second page. Stop there.",
|
|
51
|
+
events: [],
|
|
52
|
+
},
|
|
49
53
|
};
|
|
50
54
|
|
|
51
|
-
const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
|
|
55
|
+
const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
|
|
52
56
|
|
|
53
57
|
/** The preset for a name like "purchase" or "signup", or null. */
|
|
54
58
|
export function presetFor(name) {
|
package/src/mcp.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createServer } from "node:http";
|
|
|
9
9
|
import { readFile } from "node:fs/promises";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
12
13
|
import { z } from "zod";
|
|
13
14
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
15
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -22,7 +23,7 @@ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
|
22
23
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
23
24
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
24
25
|
|
|
25
|
-
const VERSION = "
|
|
26
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
26
27
|
const MAX_OPEN_AUDITS = 4;
|
|
27
28
|
const IDLE_MS = 15 * 60 * 1000; // an audit untouched this long is closed
|
|
28
29
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
@@ -174,7 +175,7 @@ export function buildServer(opts) {
|
|
|
174
175
|
{
|
|
175
176
|
title: "Act in the audit browser",
|
|
176
177
|
description:
|
|
177
|
-
"Performs one action in the audit's browser and returns the updated page plus the tracking hits that action sent. Actions: goto (url), click (ref or text), fill (ref or text, value), select (ref or text, value), scroll (value = down or up), wait (value = milliseconds). Refs come from the latest page view, which lists on-screen elements first; if an element has no ref, scroll or pass its visible text in text. If a cookie banner appears, accept it and set accepts_consent to true on that click. Use test data in forms and never enter real payment details.",
|
|
178
|
+
"Performs one action in the audit's browser and returns the updated page plus the tracking hits that action sent. Actions: goto (url), click (ref or text), fill (ref or text, value), select (ref or text, value), scroll (value = down or up), wait (value = milliseconds). Refs come from the latest page view, which lists on-screen elements first; if an element has no ref, scroll or pass its visible text in text. If a cookie banner appears, accept it and set accepts_consent to true on that click (or, on a journey that is about declining cookies, reject it and set rejects_consent to true). Use test data in forms and never enter real payment details.",
|
|
178
179
|
inputSchema: {
|
|
179
180
|
audit_id: z.string(),
|
|
180
181
|
action: z.enum(["goto", "click", "fill", "select", "scroll", "wait"]),
|
|
@@ -184,6 +185,7 @@ export function buildServer(opts) {
|
|
|
184
185
|
url: z.string().optional().describe("Destination for goto, absolute or relative to the current page."),
|
|
185
186
|
reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
|
|
186
187
|
accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
|
|
188
|
+
rejects_consent: z.boolean().optional().describe("True only when this click rejects or declines all cookies on the consent banner."),
|
|
187
189
|
},
|
|
188
190
|
},
|
|
189
191
|
async (input) => {
|
package/src/parsers.js
CHANGED
|
@@ -36,6 +36,17 @@ const GOOGLE_COMPANION_HOST = /^(www\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$|(^|\.)d
|
|
|
36
36
|
*/
|
|
37
37
|
const isGa4Path = (u) => u.pathname.endsWith("/g/collect") || (/\/g\/c$/.test(u.pathname) && /^G-[A-Z0-9]+$/.test(u.searchParams.get("tid") ?? ""));
|
|
38
38
|
|
|
39
|
+
const GOOGLE_PRIMARY_HOST = /(^|\.)(google-analytics\.com|analytics\.google\.com|googletagmanager\.com)$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where a GA4 hit goes. "browser": Google's own hosts, or the short /g/c path of Google tag gateway
|
|
43
|
+
* (which only forwards to Google). "server": /g/collect on any other host, which is a server
|
|
44
|
+
* container (server-side GTM, Stape, Elevar) answering for the site.
|
|
45
|
+
*/
|
|
46
|
+
function ga4Delivery(u) {
|
|
47
|
+
return GOOGLE_PRIMARY_HOST.test(u.hostname) || /\/g\/c$/.test(u.pathname) ? "browser" : "server";
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
/** @returns {Hit[] | null} */
|
|
40
51
|
export function parseGa4(url, postData) {
|
|
41
52
|
const u = new URL(url);
|
|
@@ -55,6 +66,7 @@ export function parseGa4(url, postData) {
|
|
|
55
66
|
const user = [...merged].filter(([k]) => isUserDataKey(k));
|
|
56
67
|
return {
|
|
57
68
|
platform: "ga4",
|
|
69
|
+
delivery: ga4Delivery(u),
|
|
58
70
|
name: merged.get("en") || "(unnamed)",
|
|
59
71
|
id: merged.get("tid") || undefined,
|
|
60
72
|
params: ga4ParamsFrom(merged),
|
|
@@ -75,7 +87,11 @@ export function parseMeta(url) {
|
|
|
75
87
|
const m = k.match(/^cd\[(.+)\]$/);
|
|
76
88
|
if (m) params[m[1]] = v;
|
|
77
89
|
}
|
|
78
|
-
|
|
90
|
+
// eid is the event ID a server-side (Conversions API) copy of the event must repeat for Meta to count it once.
|
|
91
|
+
const eventId = u.searchParams.get("eid") || undefined;
|
|
92
|
+
if (eventId) params.event_id = eventId;
|
|
93
|
+
const user = [...u.searchParams].filter(([k]) => isUserDataKey(k));
|
|
94
|
+
return [{ platform: "meta", delivery: "browser", name: u.searchParams.get("ev") || "(unnamed)", id: u.searchParams.get("id") || undefined, ...(eventId ? { eventId } : {}), params, ...(user.length ? { userFields: Object.fromEntries(user) } : {}), url }];
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
/** @returns {Hit[] | null} */
|
|
@@ -105,7 +121,8 @@ export function parseTikTok(url, postData) {
|
|
|
105
121
|
const props = body.properties || body.context?.properties || {};
|
|
106
122
|
const params = Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]));
|
|
107
123
|
const user = Object.entries(body.context?.user ?? {}).filter(([k, v]) => isUserDataKey(`context.user.${k}`) && v);
|
|
108
|
-
|
|
124
|
+
const eventId = body.event_id ? String(body.event_id) : undefined;
|
|
125
|
+
return [{ platform: "tiktok", delivery: "browser", name: body.event || "(unnamed)", id: body.context?.pixel?.code, ...(eventId ? { eventId } : {}), params, ...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`context.user.${k}`, String(v)])) } : {}), url }];
|
|
109
126
|
}
|
|
110
127
|
|
|
111
128
|
/**
|
|
@@ -128,7 +145,67 @@ export function parseX(url) {
|
|
|
128
145
|
return [{ platform: "x", name: name || "(unnamed)", id: q.get("txn_id") || undefined, params, url }];
|
|
129
146
|
}
|
|
130
147
|
|
|
131
|
-
const
|
|
148
|
+
const jsonOr = (text, fallback) => { try { return JSON.parse(text); } catch { return fallback; } };
|
|
149
|
+
const scalars = (obj) => Object.fromEntries(Object.entries(obj && typeof obj === "object" ? obj : {}).filter(([, v]) => v !== null && typeof v !== "object").map(([k, v]) => [k, String(v)]));
|
|
150
|
+
|
|
151
|
+
/** Pinterest tag: ct.pinterest.com/v3/ with event, tid, ed (event data) and pd (hashed user data). @returns {Hit[] | null} */
|
|
152
|
+
export function parsePinterest(url) {
|
|
153
|
+
const u = new URL(url);
|
|
154
|
+
const q = u.searchParams;
|
|
155
|
+
if (u.hostname !== "ct.pinterest.com" || !/^\/v3\/?$/.test(u.pathname) || !q.has("event")) return null;
|
|
156
|
+
const params = scalars(jsonOr(q.get("ed") ?? "{}", {}));
|
|
157
|
+
if (q.has("event_id")) params.event_id = q.get("event_id");
|
|
158
|
+
const user = Object.entries(scalars(jsonOr(q.get("pd") ?? "{}", {}))).filter(([k, v]) => isUserDataKey(k) && v);
|
|
159
|
+
return [{ platform: "pinterest", name: q.get("event"), id: q.get("tid") || undefined, params, ...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`pd.${k}`, v])) } : {}), url }];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Snap Pixel: tr.snapchat.com/p (query or form body) with pid, ev, and price/currency/transaction fields. @returns {Hit[] | null} */
|
|
163
|
+
export function parseSnapchat(url, postData) {
|
|
164
|
+
const u = new URL(url);
|
|
165
|
+
if (u.hostname !== "tr.snapchat.com" || !/^\/(p|tr)\/?$/.test(u.pathname)) return null;
|
|
166
|
+
const q = new URLSearchParams(u.search);
|
|
167
|
+
if (postData && !postData.trim().startsWith("{")) for (const [k, v] of new URLSearchParams(postData)) q.set(k, v);
|
|
168
|
+
else if (postData) for (const [k, v] of Object.entries(scalars(jsonOr(postData, {})))) q.set(k, v);
|
|
169
|
+
if (!q.has("pid") && !q.has("ev")) return null;
|
|
170
|
+
const params = {};
|
|
171
|
+
for (const [from, to] of [["pr", "value"], ["cur", "currency"], ["tid", "transaction_id"], ["ic", "quantity"], ["ci", "content_ids"], ["u", "page_location"]]) {
|
|
172
|
+
if (q.has(from)) params[to] = q.get(from);
|
|
173
|
+
}
|
|
174
|
+
const user = [...q].filter(([k, v]) => isUserDataKey(k) && v);
|
|
175
|
+
return [{ platform: "snapchat", name: q.get("ev") || "(unnamed)", id: q.get("pid") || undefined, params, ...(user.length ? { userFields: Object.fromEntries(user) } : {}), url }];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Klaviyo onsite tracking: a JSON:API POST to a.klaviyo.com/client/events/ with the metric
|
|
180
|
+
* name, event properties and the profile it belongs to. Profile fields are user data.
|
|
181
|
+
* @returns {Hit[] | null}
|
|
182
|
+
*/
|
|
183
|
+
export function parseKlaviyo(url, postData) {
|
|
184
|
+
const u = new URL(url);
|
|
185
|
+
if (u.hostname !== "a.klaviyo.com" || !/^\/client\/events\/?$/.test(u.pathname)) return null;
|
|
186
|
+
const attrs = jsonOr(postData ?? "{}", {})?.data?.attributes ?? {};
|
|
187
|
+
const profile = scalars(attrs.profile?.data?.attributes ?? attrs.profile);
|
|
188
|
+
const user = Object.entries(profile).filter(([k, v]) => isUserDataKey(k) && v);
|
|
189
|
+
return [{
|
|
190
|
+
platform: "klaviyo",
|
|
191
|
+
name: attrs.metric?.data?.attributes?.name ?? attrs.metric?.name ?? "(unnamed)",
|
|
192
|
+
id: u.searchParams.get("company_id") || undefined,
|
|
193
|
+
params: { ...scalars(attrs.properties), ...(attrs.value !== undefined ? { value: String(attrs.value) } : {}) },
|
|
194
|
+
...(user.length ? { userFields: Object.fromEntries(user.map(([k, v]) => [`profile.${k}`, v])) } : {}),
|
|
195
|
+
url,
|
|
196
|
+
}];
|
|
197
|
+
}
|
|
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} */
|
|
200
|
+
export function parseHotjar(url) {
|
|
201
|
+
const u = new URL(url);
|
|
202
|
+
if (!/(^|\.)hotjar\.(com|io)$/.test(u.hostname)) return null;
|
|
203
|
+
const m = u.pathname.match(/^\/api\/v\d+\/client\/sites\/(\d+)\/([a-z-]+)/i);
|
|
204
|
+
if (!m) return null;
|
|
205
|
+
return [{ platform: "hotjar", name: m[2], id: m[1], params: {}, url }];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const PARSERS = [parseGa4, parseMeta, parseGoogleAds, parseTikTok, parseX, parsePinterest, parseSnapchat, parseKlaviyo, parseHotjar];
|
|
132
209
|
|
|
133
210
|
/** Returns normalized hits for a request, or null when it is not a tracking request. */
|
|
134
211
|
export function parseTrackingRequest(url, postData) {
|
|
@@ -184,6 +261,7 @@ export const PROVIDERS = {
|
|
|
184
261
|
microsoft_ads: "Microsoft Ads",
|
|
185
262
|
pinterest: "Pinterest",
|
|
186
263
|
snapchat: "Snapchat",
|
|
264
|
+
klaviyo: "Klaviyo",
|
|
187
265
|
clarity: "Microsoft Clarity",
|
|
188
266
|
hotjar: "Hotjar",
|
|
189
267
|
doubleclick: "DoubleClick",
|
|
@@ -191,9 +269,12 @@ export const PROVIDERS = {
|
|
|
191
269
|
};
|
|
192
270
|
export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
|
|
193
271
|
// Tag managers and consent platforms are always shown: they explain how the tags loaded.
|
|
194
|
-
|
|
272
|
+
// Server-side tagging providers are too: their requests show where events are sent from.
|
|
273
|
+
const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform", stape: "Stape", elevar: "Elevar" };
|
|
195
274
|
|
|
196
275
|
const HOSTS = [
|
|
276
|
+
[/(^|\.)stape\.(io|jp)$/, "stape"],
|
|
277
|
+
[/(^|\.)getelevar\.com$/, "elevar"],
|
|
197
278
|
[/(^|\.)(google-analytics|analytics\.google)\.com$/, "ga4"],
|
|
198
279
|
[/(^|\.)googleadservices\.com$/, "google_ads"],
|
|
199
280
|
[/(^|\.)googlesyndication\.com$/, "adsense"],
|
|
@@ -206,7 +287,8 @@ const HOSTS = [
|
|
|
206
287
|
[/(^|\.)hotjar\.(com|io)$/, "hotjar"],
|
|
207
288
|
[/(^|\.)(pinterest\.com|pinimg\.com)$/, "pinterest"],
|
|
208
289
|
[/(^|\.)(snapchat\.com|sc-static\.net)$/, "snapchat"],
|
|
209
|
-
[/(^|\.)
|
|
290
|
+
[/(^|\.)klaviyo\.com$/, "klaviyo"],
|
|
291
|
+
[/(^|\.)(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"],
|
|
210
292
|
];
|
|
211
293
|
|
|
212
294
|
/**
|
|
@@ -226,6 +308,8 @@ export function providerOf(url) {
|
|
|
226
308
|
}
|
|
227
309
|
if (/(^|\.)(doubleclick\.net|google\.[a-z.]+)$/.test(host) && /^\/(pagead|ccm)\//.test(u.pathname)) return of("google_ads");
|
|
228
310
|
if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
|
|
311
|
+
// Before the vendor hosts: a GA4 hit to a Stape or Elevar endpoint is still a GA4 hit.
|
|
312
|
+
if (isGa4Path(u) && !GOOGLE_COMPANION_HOST.test(host)) return of("ga4");
|
|
229
313
|
for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
|
|
230
314
|
if (isGa4Path(u)) return of("ga4");
|
|
231
315
|
if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
|
|
@@ -258,7 +342,8 @@ export function vendorOf(url) {
|
|
|
258
342
|
if (p.key === "ga4") {
|
|
259
343
|
const host = new URL(url).hostname;
|
|
260
344
|
if (/(^|\.)(google-analytics|analytics\.google|googletagmanager)\.com$/.test(host)) return "Google Analytics";
|
|
261
|
-
|
|
345
|
+
if (GOOGLE_COMPANION_HOST.test(host)) return "Google Analytics (resend)";
|
|
346
|
+
return ga4Delivery(new URL(url)) === "server" ? "Google Analytics (server-side)" : "Google Analytics (Google tag gateway)";
|
|
262
347
|
}
|
|
263
348
|
return p.label;
|
|
264
349
|
}
|
package/src/redact.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const USER_KEYS = new Set([
|
|
8
8
|
"em", "ph", "fn", "ln", "ge", "db", "ct", "st", "zp", "country", "external_id", "uid", "user_id", "_uid",
|
|
9
9
|
"email", "phone", "phone_number", "first_name", "last_name", "street", "city", "region", "postal_code", "zip",
|
|
10
|
-
"sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone", "email_address",
|
|
10
|
+
"sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone", "email_address", "u_hem", "u_hpn",
|
|
11
11
|
]);
|
|
12
12
|
// In dataLayer pushes, short names like "city" or "country" are often page data, so only
|
|
13
13
|
// these names, values under a user data object, and email-like values are redacted there.
|
package/src/replay.js
CHANGED
|
@@ -20,7 +20,7 @@ export function replayableJourney(run) {
|
|
|
20
20
|
...(run.completed ? {} : { incomplete: run.note }),
|
|
21
21
|
steps: (start ? steps.slice(1) : steps)
|
|
22
22
|
.filter((s) => s.replay)
|
|
23
|
-
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
|
|
23
|
+
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.rejectsConsent ? { rejects_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
package/src/report.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createProvider } from "./providers.js";
|
|
2
|
-
import { groupFindings, summarize } from "./checks.js";
|
|
2
|
+
import { groupFindings, hitMatchesEvent, summarize } from "./checks.js";
|
|
3
3
|
import { messages } from "./i18n.js";
|
|
4
4
|
import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
|
|
5
5
|
import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
|
|
@@ -192,7 +192,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
|
|
|
192
192
|
return lines.join("\n");
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x"];
|
|
195
|
+
const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "pinterest", "snapchat", "klaviyo", "hotjar"];
|
|
196
196
|
const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
197
197
|
const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
|
|
198
198
|
|
|
@@ -331,6 +331,7 @@ function eventRow(m, findings, run, step, hit) {
|
|
|
331
331
|
if (items.length) bits.push(m.ui.itemsCount(items.length));
|
|
332
332
|
if (hit.params?.value) bits.push(`${hit.params.value} ${hit.params.currency ?? ""}`.trim());
|
|
333
333
|
if (!items.length && hit.platform === "ga4" && ECOMMERCE_EVENTS.has(hit.name)) bits.push(m.ui.noItems);
|
|
334
|
+
if (hit.delivery === "server") bits.push(m.ui.serverSideTag);
|
|
334
335
|
const consent = hit.consent ? `<h5>${m.ui.consent}</h5><div class="consents">${Object.entries(hit.consent).map(([k, v]) => `<span class="consent ${v === "granted" ? "granted" : "denied"}">${escapeHtml(k)}: ${escapeHtml(v)}</span>`).join("")}</div>` : "";
|
|
335
336
|
const pushLine = pushes.length ? pushes.map((e) => `<span class="mono">${escapeHtml(pushEvent(e.value) ?? "")} · ${escapeHtml(seconds(run, e.t))}</span>`).join("") : `<span class="muted">${m.ui.noMatchingPush}</span>`;
|
|
336
337
|
const reqText = calls.length
|
|
@@ -618,7 +619,7 @@ function coverageCard(m, plan, runs) {
|
|
|
618
619
|
.map((e) => {
|
|
619
620
|
const cells = runs.map((r) => {
|
|
620
621
|
if (!checks(r, e) || (e.journeys && !e.journeys.includes(r.journeyId))) return '<td class="c o">–</td>';
|
|
621
|
-
return r.hits.some((h) =>
|
|
622
|
+
return r.hits.some((h) => hitMatchesEvent(e, h)) ? '<td class="c y">✓</td>' : '<td class="c x">✗</td>';
|
|
622
623
|
});
|
|
623
624
|
const unchecked = !runs.some((r) => checks(r, e));
|
|
624
625
|
return `<tr><td><span class="ptag p-${safeKey(e.platform)}">${escapeHtml(platformLabel(e.platform))}</span> <code>${escapeHtml(e.name)}</code>${unchecked ? ` ${badge("muted", m.ui.notChecked)}` : ""}</td>${cells.join("")}</tr>`;
|