@analitikisler/ai-tag-debugger 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/browser.js +58 -2
- package/src/checks.js +12 -2
- package/src/mcp.js +1 -1
- package/src/parsers.js +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.2 (2026-10-12)
|
|
4
|
+
|
|
5
|
+
Fixes from Bahadır's audit of analitikisler.com.
|
|
6
|
+
|
|
7
|
+
- GA4 resends (`gaf=1`, which gtag.js sends when it thinks the first send was lost) are not counted as hits on any host. Copies to `www.google.com` are labelled "Google Analytics (resend)" instead of "server-side".
|
|
8
|
+
- The duplicate check counts per page load (GA4's `_p`, or the page address). A step that opens a new page no longer adds the previous page's last `page_view` to the new one.
|
|
9
|
+
- A `page_view` sent while its page unloads is filed under the step that loaded that page, not the next step. A `page_view` for the page that is still open (for example GTM resending it after consent) stays in the step that sent it.
|
|
10
|
+
- dataLayer entries holding DOM elements or objects that refer to themselves (such as GTM click events) are kept, with the element shown as `<tag#id>` and only true cycles cut, instead of becoming "[object Object]".
|
|
11
|
+
|
|
3
12
|
## 0.6.1 (2026-10-12)
|
|
4
13
|
|
|
5
14
|
- GA4 events were reported as "sent 2 times" on sites with Google signals or ads features. gtag.js also sends a copy of those events to `www.google.com/g/collect` (or `doubleclick.net`), which GA4 doesn't count. Those copies are now still answered locally and listed under network requests, but no longer counted as hits. Requests to `analytics.google.com` still count: it is a primary GA4 endpoint.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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/browser.js
CHANGED
|
@@ -20,13 +20,43 @@ export const VIEWPORTS = {
|
|
|
20
20
|
// Runs in the page before any site script: reports every dataLayer push
|
|
21
21
|
// (including gtag consent commands) to Node through an exposed binding, so the
|
|
22
22
|
// log survives page navigations.
|
|
23
|
+
/** A page address without its hash, for matching page_location to navigations. */
|
|
24
|
+
const pageKey = (url) => String(url ?? "").split("#")[0];
|
|
25
|
+
|
|
23
26
|
const INIT_SCRIPT = `(() => {
|
|
24
27
|
const record = (entry) => {
|
|
25
28
|
let value = entry;
|
|
26
29
|
try {
|
|
27
30
|
// gtag() pushes an arguments object; store it as a plain array.
|
|
28
31
|
if (entry && typeof entry === "object" && typeof entry.length === "number" && !Array.isArray(entry)) value = Array.from(entry);
|
|
29
|
-
|
|
32
|
+
// GTM click and form events carry DOM elements and objects that refer to themselves.
|
|
33
|
+
// Copied by hand so only true cycles (an object inside itself) are cut, not shared objects.
|
|
34
|
+
const copy = (v, parents) => {
|
|
35
|
+
if (typeof v === "bigint") return String(v);
|
|
36
|
+
if (typeof v === "function" || typeof v === "symbol") return undefined;
|
|
37
|
+
if (!v || typeof v !== "object") return v;
|
|
38
|
+
if (typeof Element !== "undefined" && v instanceof Element) return "<" + v.tagName.toLowerCase() + (v.id ? "#" + v.id : "") + ">";
|
|
39
|
+
if (typeof Node !== "undefined" && v instanceof Node) return "[node]";
|
|
40
|
+
if (v === window) return "[window]";
|
|
41
|
+
if (parents.includes(v)) return "[circular]";
|
|
42
|
+
if (parents.length > 20) return "[too deep]";
|
|
43
|
+
if (typeof v.toJSON === "function") { try { return copy(v.toJSON(), parents); } catch { return String(v); } }
|
|
44
|
+
parents.push(v);
|
|
45
|
+
let out;
|
|
46
|
+
if (Array.isArray(v)) out = v.map((x) => { const c = copy(x, parents); return c === undefined ? null : c; });
|
|
47
|
+
else {
|
|
48
|
+
out = {};
|
|
49
|
+
for (const k of Object.keys(v)) {
|
|
50
|
+
let x;
|
|
51
|
+
try { x = v[k]; } catch { continue; }
|
|
52
|
+
const c = copy(x, parents);
|
|
53
|
+
if (c !== undefined) out[k] = c;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
parents.pop();
|
|
57
|
+
return out;
|
|
58
|
+
};
|
|
59
|
+
value = JSON.parse(JSON.stringify(copy(value, [])));
|
|
30
60
|
} catch { value = String(entry); }
|
|
31
61
|
if (window.__atdRecord) window.__atdRecord({ url: location.href, value });
|
|
32
62
|
};
|
|
@@ -183,6 +213,8 @@ export class AuditSession {
|
|
|
183
213
|
this.network = [];
|
|
184
214
|
this.providers = resolveProviders(this.opts.providers);
|
|
185
215
|
this.skipped = {}; // step index -> { vendor label: requests }
|
|
216
|
+
this.loadedIn = new Map(); // page address -> the last step whose main-frame navigation reached it
|
|
217
|
+
this.leaving = null; // the page address being navigated away from, until the next page commits
|
|
186
218
|
this.bodyBytes = 0;
|
|
187
219
|
this.bodyBudgetStep = null; // the step at which POST bodies stopped being stored
|
|
188
220
|
this.startedAt = Date.now();
|
|
@@ -207,6 +239,15 @@ export class AuditSession {
|
|
|
207
239
|
await this.context.addInitScript(INIT_SCRIPT);
|
|
208
240
|
this.page = await this.context.newPage();
|
|
209
241
|
this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
|
|
242
|
+
this.page.on("framenavigated", (frame) => {
|
|
243
|
+
if (frame !== this.page.mainFrame()) return;
|
|
244
|
+
this.loadedIn.set(pageKey(frame.url()), this.currentStep);
|
|
245
|
+
this.leaving = null;
|
|
246
|
+
});
|
|
247
|
+
// From the moment the main frame starts loading another document, the open page is leaving.
|
|
248
|
+
this.page.on("request", (request) => {
|
|
249
|
+
if (request.isNavigationRequest() && request.frame() === this.page.mainFrame()) this.leaving = pageKey(this.page.url());
|
|
250
|
+
});
|
|
210
251
|
|
|
211
252
|
const onRequest = (request) => {
|
|
212
253
|
const hits = parseTrackingRequest(request.url(), request.postData());
|
|
@@ -214,7 +255,7 @@ export class AuditSession {
|
|
|
214
255
|
const recorded = [];
|
|
215
256
|
for (const hit of hits) {
|
|
216
257
|
if (!this.providers.includes(hit.platform)) continue;
|
|
217
|
-
const entry = { ...this.redactHit(hit), step: this.
|
|
258
|
+
const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now() };
|
|
218
259
|
if (this.record("hits", entry)) recorded.push(entry);
|
|
219
260
|
}
|
|
220
261
|
if (recorded.length) hitsOf.set(request, recorded);
|
|
@@ -296,6 +337,20 @@ export class AuditSession {
|
|
|
296
337
|
this.beginStep("start");
|
|
297
338
|
}
|
|
298
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The step a hit belongs to. A page_view sent as the page unloads arrives after the next
|
|
342
|
+
* step has started, so it goes to the step that loaded its page, when that was earlier.
|
|
343
|
+
*/
|
|
344
|
+
stepForHit(hit) {
|
|
345
|
+
if (hit.name !== "page_view" || !hit.params?.page_location) return this.currentStep;
|
|
346
|
+
// Only when its page is going or gone: a page_view for the page still open (GTM resending
|
|
347
|
+
// it after consent, a virtual page view) belongs to the current step.
|
|
348
|
+
const page = pageKey(hit.params.page_location);
|
|
349
|
+
if (page === pageKey(this.page.url()) && page !== this.leaving) return this.currentStep;
|
|
350
|
+
const loaded = this.loadedIn.get(page);
|
|
351
|
+
return loaded !== undefined && loaded < this.currentStep ? loaded : this.currentStep;
|
|
352
|
+
}
|
|
353
|
+
|
|
299
354
|
/** The hit with user data for ad matching redacted, and userData: { fields, plain } when there was any. */
|
|
300
355
|
redactHit({ userFields, ...hit }) {
|
|
301
356
|
const found = { fields: [], plain: [] };
|
|
@@ -366,6 +421,7 @@ export class AuditSession {
|
|
|
366
421
|
}
|
|
367
422
|
|
|
368
423
|
beginStep(label, extra = {}) {
|
|
424
|
+
this.leaving = null; // a navigation that never committed (a download, a 204) ends with its step
|
|
369
425
|
this.steps.push({ index: this.steps.length, label, t: Date.now(), ...extra });
|
|
370
426
|
}
|
|
371
427
|
|
package/src/checks.js
CHANGED
|
@@ -15,6 +15,15 @@ const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
|
|
|
15
15
|
|
|
16
16
|
const key = (platform, name) => `${platform}:${name}`;
|
|
17
17
|
|
|
18
|
+
/** The page load a hit came from: GA4's page load id (_p) when it has one, else the page address. */
|
|
19
|
+
function pageOf(hit) {
|
|
20
|
+
try {
|
|
21
|
+
const p = new URL(hit.url).searchParams.get("_p");
|
|
22
|
+
if (p) return p;
|
|
23
|
+
} catch { /* no URL */ }
|
|
24
|
+
return hit.params?.page_location ?? hit.pageUrl ?? "";
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
function expectedIn(planEvent, journeyId) {
|
|
19
28
|
return !planEvent.journeys || planEvent.journeys.includes(journeyId);
|
|
20
29
|
}
|
|
@@ -95,10 +104,11 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
106
|
|
|
98
|
-
// The same event sent more than once by a single action.
|
|
107
|
+
// The same event sent more than once by a single action on one page. A step that loads a
|
|
108
|
+
// new page can also carry the previous page's last hits, so the page is part of the key.
|
|
99
109
|
const perStep = new Map();
|
|
100
110
|
for (const hit of run.hits) {
|
|
101
|
-
const k = `${hit.step}|${key(hit.platform, hit.name)}`;
|
|
111
|
+
const k = `${hit.step}|${key(hit.platform, hit.name)}|${pageOf(hit)}`;
|
|
102
112
|
perStep.set(k, [...(perStep.get(k) ?? []), hit]);
|
|
103
113
|
}
|
|
104
114
|
for (const [, hits] of perStep) {
|
package/src/mcp.js
CHANGED
|
@@ -22,7 +22,7 @@ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
|
22
22
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
23
23
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
24
24
|
|
|
25
|
-
const VERSION = "0.6.
|
|
25
|
+
const VERSION = "0.6.2";
|
|
26
26
|
const MAX_OPEN_AUDITS = 4;
|
|
27
27
|
const IDLE_MS = 15 * 60 * 1000; // an audit untouched this long is closed
|
|
28
28
|
const MAX_BODY_BYTES = 1024 * 1024;
|
package/src/parsers.js
CHANGED
|
@@ -35,9 +35,9 @@ export function parseGa4(url, postData) {
|
|
|
35
35
|
// Matched on the GA4 protocol rather than the host, so server-side GTM
|
|
36
36
|
// endpoints on first-party domains are captured too.
|
|
37
37
|
if (!u.pathname.endsWith("/g/collect") || u.searchParams.get("v") !== "2") return null;
|
|
38
|
-
// gtag.js
|
|
39
|
-
//
|
|
40
|
-
if (GOOGLE_COMPANION_HOST.test(u.hostname)) return null;
|
|
38
|
+
// gtag.js resends a hit to www.google.com/g/collect (marked gaf=1) when the first send
|
|
39
|
+
// seems lost, and copies some to doubleclick.net. GA4 doesn't count these twice, so they aren't hits.
|
|
40
|
+
if (GOOGLE_COMPANION_HOST.test(u.hostname) || u.searchParams.has("gaf")) return null;
|
|
41
41
|
const base = new URLSearchParams(u.search);
|
|
42
42
|
const lines = postData ? postData.split("\n").filter(Boolean) : [""];
|
|
43
43
|
return lines.map((line) => {
|
|
@@ -236,6 +236,10 @@ export function resolveProviders(list, plan) {
|
|
|
236
236
|
export function vendorOf(url) {
|
|
237
237
|
const p = providerOf(url);
|
|
238
238
|
if (!p) return null;
|
|
239
|
-
if (p.key === "ga4")
|
|
239
|
+
if (p.key === "ga4") {
|
|
240
|
+
const host = new URL(url).hostname;
|
|
241
|
+
if (/(^|\.)(google-analytics|analytics\.google|googletagmanager)\.com$/.test(host)) return "Google Analytics";
|
|
242
|
+
return GOOGLE_COMPANION_HOST.test(host) ? "Google Analytics (resend)" : "Google Analytics (server-side)";
|
|
243
|
+
}
|
|
240
244
|
return p.label;
|
|
241
245
|
}
|