@analitikisler/ai-tag-debugger 0.7.7 → 0.7.9
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 +21 -0
- package/README.md +4 -2
- package/README.tr.md +1 -1
- package/package.json +1 -1
- package/src/agent.js +4 -1
- package/src/browser.js +43 -16
- package/src/checks.js +141 -10
- package/src/i18n.js +22 -0
- package/src/journeys.js +5 -1
- package/src/mcp.js +2 -1
- 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,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.9 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
- 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`.
|
|
6
|
+
- A required parameter is also found under its usual other name (`products` for `items`, `revenue` for `value`, `order_id` for `transaction_id`).
|
|
7
|
+
- The plan coverage table in the report uses the same matching.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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[...]`).
|
|
13
|
+
- Klaviyo is a selectable provider.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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).
|
|
18
|
+
|
|
19
|
+
## 0.7.8 (2026-10-14)
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
- A replay skips a malformed address in a recording instead of stopping, and reads the recording once.
|
|
23
|
+
|
|
3
24
|
## 0.7.7 (2026-10-14)
|
|
4
25
|
|
|
5
26
|
- A replay answers a request from the recording when the address only differs by a timestamp (`_=1791977697130`, `ts`, `timestamp`, `cb`, `nocache`). Magento sites reload the basket this way after "add to basket", so the replay never got the answer that makes the page send `add_to_cart`. Exact matches still come from the recording as before.
|
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
|
```
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
4
4
|
"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
5
|
"type": "module",
|
|
6
6
|
"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
|
@@ -5,6 +5,13 @@ import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
|
|
|
5
5
|
import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
|
|
6
6
|
import { startEgressProxy } from "./egress.js";
|
|
7
7
|
|
|
8
|
+
// Consent banners of the common consent platforms (CMPs), by their container. A cover that matches
|
|
9
|
+
// one of these always blocks the journey, and a reject click is checked against them afterwards.
|
|
10
|
+
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, \
|
|
11
|
+
#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, \
|
|
12
|
+
#sp_message_container, [id^="sp_message_container_"], #cookie-law-info-bar, #BorlabsCookieBox, .klaro, #termly-code-snippet-support, #cookie-information-template-wrapper, #coiOverlay, \
|
|
13
|
+
#cmpwrapper, #cmpbox2, .fc-consent-root, #ppms_cm_popup_overlay, [aria-label*="cookie" i][role=dialog]`;
|
|
14
|
+
|
|
8
15
|
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
9
16
|
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
|
|
10
17
|
|
|
@@ -201,12 +208,12 @@ function trackingBody(request) {
|
|
|
201
208
|
* How a scripted run can find the element an action used again: the selector it was given,
|
|
202
209
|
* else a CSS path to the element, else its visible text. Refs only mean something in one run.
|
|
203
210
|
*/
|
|
204
|
-
// Text that looks like an email address or phone number is not saved with a click.
|
|
205
211
|
/** Query parameters that only carry the time or a cache-buster, so they differ between a recording and its replay. */
|
|
206
212
|
const STAMP_PARAMS = /^(_|_t|ts|timestamp|cb|cachebust|nocache)$/i;
|
|
207
213
|
/** The address without its timestamp parameters. */
|
|
208
214
|
function unstamped(href) {
|
|
209
|
-
|
|
215
|
+
let url;
|
|
216
|
+
try { url = new URL(href); } catch { return href; }
|
|
210
217
|
for (const name of [...url.searchParams.keys()]) if (STAMP_PARAMS.test(name)) url.searchParams.delete(name);
|
|
211
218
|
return url.href;
|
|
212
219
|
}
|
|
@@ -214,10 +221,9 @@ function unstamped(href) {
|
|
|
214
221
|
* The GET answers in a recording whose address carries a timestamp, by address without it.
|
|
215
222
|
* Entries the recording has no body for (still loading when the page was left) are not included.
|
|
216
223
|
*/
|
|
217
|
-
|
|
224
|
+
function restampedAnswers(har) {
|
|
218
225
|
const answers = new Map();
|
|
219
226
|
answers.exact = new Set();
|
|
220
|
-
const har = JSON.parse(await readFile(file, "utf8"));
|
|
221
227
|
for (const e of har.log.entries) {
|
|
222
228
|
const { request, response } = e;
|
|
223
229
|
answers.exact.add(request.url);
|
|
@@ -237,6 +243,7 @@ async function restampedAnswers(file) {
|
|
|
237
243
|
}
|
|
238
244
|
return answers;
|
|
239
245
|
}
|
|
246
|
+
// Text that looks like an email address or phone number is not saved with a click.
|
|
240
247
|
const savableText = (text) => (typeof text === "string" && text && !/@|\d[\d\s().+-]{6,}\d/.test(text) ? text : undefined);
|
|
241
248
|
|
|
242
249
|
async function replayTarget(a, locator) {
|
|
@@ -341,6 +348,8 @@ export class AuditSession {
|
|
|
341
348
|
this.hits = [];
|
|
342
349
|
this.steps = [];
|
|
343
350
|
this.consentStep = null;
|
|
351
|
+
this.rejectStep = null;
|
|
352
|
+
this.cookieLog = []; // each cookie name and domain with the step it was first seen at (never values)
|
|
344
353
|
this.errors = [];
|
|
345
354
|
this.dataLayerLog = [];
|
|
346
355
|
this.network = [];
|
|
@@ -454,11 +463,10 @@ export class AuditSession {
|
|
|
454
463
|
(isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
|
|
455
464
|
);
|
|
456
465
|
await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
|
|
457
|
-
|
|
458
|
-
// recording, the redirect would be followed outside any handler, and its failure would end the run.
|
|
466
|
+
const har = JSON.parse(await readFile(this.opts.replayHar, "utf8"));
|
|
459
467
|
// A page asks again for what it asked when saved, with a new timestamp on the address. Registered
|
|
460
468
|
// after the recording, so it runs first and leaves every exact match to the recording.
|
|
461
|
-
const restamped =
|
|
469
|
+
const restamped = restampedAnswers(har);
|
|
462
470
|
if (restamped.size) {
|
|
463
471
|
await this.context.route(
|
|
464
472
|
(url) => restamped.has(unstamped(url.href)),
|
|
@@ -471,7 +479,9 @@ export class AuditSession {
|
|
|
471
479
|
},
|
|
472
480
|
);
|
|
473
481
|
}
|
|
474
|
-
|
|
482
|
+
// A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
|
|
483
|
+
// recording, the redirect would be followed outside any handler, and its failure would end the run.
|
|
484
|
+
const refusedRedirects = await this.refusedRedirects(har);
|
|
475
485
|
if (refusedRedirects.size) {
|
|
476
486
|
await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));
|
|
477
487
|
}
|
|
@@ -564,15 +574,15 @@ export class AuditSession {
|
|
|
564
574
|
}
|
|
565
575
|
|
|
566
576
|
/** Addresses whose saved answer redirects somewhere the browser may not go. */
|
|
567
|
-
async refusedRedirects(
|
|
577
|
+
async refusedRedirects(har) {
|
|
568
578
|
const refused = new Set();
|
|
569
|
-
const har = JSON.parse(await readFile(file, "utf8"));
|
|
570
579
|
for (const e of har.log.entries) {
|
|
571
580
|
const status = e.response?.status;
|
|
572
581
|
if (status < 300 || status >= 400) continue;
|
|
573
582
|
const location = e.response.redirectURL || e.response.headers?.find((h) => /^location$/i.test(h.name))?.value;
|
|
574
583
|
if (!location) continue;
|
|
575
|
-
|
|
584
|
+
let target;
|
|
585
|
+
try { target = new URL(location, e.request.url).href; } catch { continue; }
|
|
576
586
|
if (await this.checkUrl(target)) refused.add(e.request.url);
|
|
577
587
|
}
|
|
578
588
|
return refused;
|
|
@@ -716,7 +726,7 @@ export class AuditSession {
|
|
|
716
726
|
if (!/intercepts pointer events|not stable|outside of the viewport/.test(err.message)) throw err;
|
|
717
727
|
}
|
|
718
728
|
const cover = await locator
|
|
719
|
-
.evaluate((el) => {
|
|
729
|
+
.evaluate((el, selectors) => {
|
|
720
730
|
const r = el.getBoundingClientRect();
|
|
721
731
|
const x = Math.min(Math.max(r.left + r.width / 2, 0), window.innerWidth - 1);
|
|
722
732
|
const y = Math.min(Math.max(r.top + r.height / 2, 0), window.innerHeight - 1);
|
|
@@ -741,13 +751,13 @@ export class AuditSession {
|
|
|
741
751
|
const modal = n.matches("dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true]");
|
|
742
752
|
// Cookie and consent banners block even when they cover only part of the screen.
|
|
743
753
|
const consent =
|
|
744
|
-
n.matches(
|
|
754
|
+
n.matches(selectors) ||
|
|
745
755
|
(/(cookie|consent|gdpr|kvkk)/i.test(`${n.id} ${typeof n.className === "string" ? n.className : ""}`) && ["fixed", "sticky"].includes(getComputedStyle(n).position));
|
|
746
756
|
if (consent) return { blocking: true, what: describe(n) };
|
|
747
757
|
if (modal || b.width * b.height >= screen * 0.5) return { blocking: true, what: describe(n) };
|
|
748
758
|
}
|
|
749
759
|
return { blocking: false, what: describe(hit), free };
|
|
750
|
-
})
|
|
760
|
+
}, CMP_BANNER_SELECTORS)
|
|
751
761
|
.catch(() => null);
|
|
752
762
|
if (cover?.blocking) {
|
|
753
763
|
throw new Error(`The element is covered by ${cover.what}. Deal with that first (for example accept or close it), as a visitor would.`);
|
|
@@ -769,10 +779,11 @@ export class AuditSession {
|
|
|
769
779
|
/**
|
|
770
780
|
* Performs one action and records it as a step. Returns an error message
|
|
771
781
|
* instead of throwing, so the agent can recover.
|
|
772
|
-
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
|
|
782
|
+
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean, rejects_consent?: boolean }} a
|
|
773
783
|
*/
|
|
774
784
|
async act(a) {
|
|
775
785
|
const error = await this.perform(a);
|
|
786
|
+
await this.trackCookies();
|
|
776
787
|
// A scripted replay of this step may fail the same way, which must not end the journey there.
|
|
777
788
|
if (error) this.steps[this.currentStep].error = error;
|
|
778
789
|
return error;
|
|
@@ -780,7 +791,7 @@ export class AuditSession {
|
|
|
780
791
|
|
|
781
792
|
async perform(a) {
|
|
782
793
|
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
783
|
-
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
|
|
794
|
+
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent, rejectsConsent: !!a.rejects_consent });
|
|
784
795
|
const step = this.steps[this.currentStep];
|
|
785
796
|
try {
|
|
786
797
|
switch (a.action) {
|
|
@@ -842,12 +853,28 @@ export class AuditSession {
|
|
|
842
853
|
const blocked = await this.enforce();
|
|
843
854
|
if (blocked) return blocked;
|
|
844
855
|
if (a.accepts_consent && this.consentStep === null) this.consentStep = this.currentStep;
|
|
856
|
+
if (a.rejects_consent && this.rejectStep === null) {
|
|
857
|
+
this.rejectStep = this.currentStep;
|
|
858
|
+
// A rejected banner should close; one that is still showing means the button did nothing.
|
|
859
|
+
step.bannerOpenAfter = await this.page.evaluate((sel) => [...document.querySelectorAll(sel)].some((n) => {
|
|
860
|
+
const r = n.getBoundingClientRect();
|
|
861
|
+
return r.width > 0 && r.height > 0 && getComputedStyle(n).visibility !== "hidden";
|
|
862
|
+
}), CMP_BANNER_SELECTORS).catch(() => false);
|
|
863
|
+
}
|
|
845
864
|
return null;
|
|
846
865
|
} catch (err) {
|
|
847
866
|
return (await this.enforce().catch(() => null)) ?? err.message.split("\n")[0];
|
|
848
867
|
}
|
|
849
868
|
}
|
|
850
869
|
|
|
870
|
+
/** Notes which cookies appeared during the step just taken (names and domains only). */
|
|
871
|
+
async trackCookies() {
|
|
872
|
+
const seen = new Set(this.cookieLog.map((c) => `${c.domain}|${c.name}`));
|
|
873
|
+
for (const c of await this.cookies()) {
|
|
874
|
+
if (!seen.has(`${c.domain}|${c.name}`) && this.cookieLog.length < 500) this.cookieLog.push({ ...c, step: this.currentStep });
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
851
878
|
async cookies() {
|
|
852
879
|
let timer;
|
|
853
880
|
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,12 +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;
|
|
184
|
+
// TikTok sends unnamed pings (no event name) on its own schedule, so their count varies between runs.
|
|
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;
|
|
136
190
|
findings.push({
|
|
137
191
|
...base,
|
|
138
192
|
severity: "warning",
|
|
@@ -140,10 +194,32 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
140
194
|
platform: hits[0].platform,
|
|
141
195
|
event: hits[0].name,
|
|
142
196
|
step: stepLabel(hits[0].step),
|
|
143
|
-
detail: d.duplicate(hits[0].name,
|
|
197
|
+
detail: d.duplicate(hits[0].name, sends),
|
|
144
198
|
});
|
|
145
199
|
}
|
|
146
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
|
+
|
|
147
223
|
// Consent.
|
|
148
224
|
const granted = consentGrantedStep(run);
|
|
149
225
|
const firstHitStep = run.hits.length ? Math.min(...run.hits.map((h) => h.step)) : null;
|
|
@@ -155,8 +231,63 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
155
231
|
findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
|
|
156
232
|
}
|
|
157
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
|
+
|
|
158
287
|
const beforeConsent = new Map();
|
|
159
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;
|
|
160
291
|
const early = granted === null || hit.step < granted;
|
|
161
292
|
if (!early) continue;
|
|
162
293
|
const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
|
|
@@ -205,14 +336,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
205
336
|
}
|
|
206
337
|
|
|
207
338
|
// Events that are not in the plan.
|
|
208
|
-
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)))];
|
|
209
340
|
if (unplanned.length) {
|
|
210
341
|
findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
|
|
211
342
|
}
|
|
212
343
|
|
|
213
344
|
// Planned events that fired cleanly.
|
|
214
345
|
const problemKeys = new Set(findings.filter((f) => f.event).map((f) => key(f.platform, f.event)));
|
|
215
|
-
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));
|
|
216
347
|
if (okKeys.length) {
|
|
217
348
|
findings.push({ ...base, severity: "ok", check: "matches_plan", detail: d.matches(okKeys) });
|
|
218
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
|
@@ -174,7 +174,7 @@ export function buildServer(opts) {
|
|
|
174
174
|
{
|
|
175
175
|
title: "Act in the audit browser",
|
|
176
176
|
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.",
|
|
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 (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
178
|
inputSchema: {
|
|
179
179
|
audit_id: z.string(),
|
|
180
180
|
action: z.enum(["goto", "click", "fill", "select", "scroll", "wait"]),
|
|
@@ -184,6 +184,7 @@ export function buildServer(opts) {
|
|
|
184
184
|
url: z.string().optional().describe("Destination for goto, absolute or relative to the current page."),
|
|
185
185
|
reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
|
|
186
186
|
accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
|
|
187
|
+
rejects_consent: z.boolean().optional().describe("True only when this click rejects or declines all cookies on the consent banner."),
|
|
187
188
|
},
|
|
188
189
|
},
|
|
189
190
|
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>`;
|