@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 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.1",
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
- value = JSON.parse(JSON.stringify(value));
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.currentStep, pageUrl: this.page.url(), t: Date.now() };
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.1";
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 sends a copy of some events to www.google.com/g/collect (and doubleclick.net)
39
- // for Google signals and ads features. GA4 doesn't count those, so they aren't hits.
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") return /(^|\.)(google-analytics|analytics\.google|googletagmanager)\.com$/.test(new URL(url).hostname) ? "Google Analytics" : "Google Analytics (server-side)";
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
  }