@analitikisler/ai-tag-debugger 0.7.1 → 0.7.3

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.3 (2026-10-13)
4
+
5
+ - `replay` no longer crashes with `NavigationAbortedError: net::ERR_BLOCKED_BY_CLIENT` when a saved page redirects to another site. The redirect is refused again, so the journey stays unfinished as it was saved, and the other sites are still replayed.
6
+ - Tracking hits sent while the browser closes are answered locally again on normal runs. In 0.7.2 the routes were removed before closing, so a hit sent at that moment could reach the real account. They are now removed only on `replay`, which never goes online.
7
+
8
+ ## 0.7.2 (2026-10-13)
9
+
10
+ - **GA4 and Google Ads hits sent through Google tag gateway are captured.** Sites using the gateway send GA4 to a short path on their own domain (`/<prefix>/ag/g/c`, `/<prefix>/ga/g/c`) and Google Ads to `/<prefix>/as/p/c/<id>/`. These were neither recognized nor answered locally, so their events were reported as missing and the audit's visits reached the site's real accounts. A `/g/c` path counts as GA4 only with a GA4 measurement id (`tid=G-…`).
11
+ - Google Tag Manager and gtag.js loaded from the site's own domain are labelled as such in the network timeline.
12
+ - Two more Google pings are answered locally instead of reaching Google: `www.google.com/measurement/conversion` and `google.<tld>/ads/ga-audiences`.
13
+ - Saved sites also leave out cookies named like a login or token (`auth_token`, `sid`, `csrftoken`, …) and cookies holding an email address, even when they aren't HttpOnly.
14
+ - Writing a saved site's recording can no longer stall a run: the page is left first, so what it is still loading is cancelled, and after 30 seconds the recording is skipped (the site is then not saved). The log says when the recording is being written. Reading the browser's cookies at the end of a journey has a time limit too.
15
+ - `replay` no longer crashes with "Target page, context or browser has been closed" when a request is still being answered as the browser closes.
16
+ - A saved journey whose start page could not be opened (a redirect to another site, a bot check) can be replayed; it stays unfinished with its saved note.
17
+ - When a site can't be saved, a `site.json` from an earlier save is removed so it can't be replayed against the new recordings, and `run` exits with code 1.
18
+
3
19
  ## 0.7.1 (2026-10-13)
4
20
 
5
21
  - A page whose script stops responding after a step can no longer stall a run. Reading the page now has a 1 minute limit too, and when it runs out the journey ends as incomplete with a note. Before, only the model call and the browser action itself had a limit.
package/README.md CHANGED
@@ -45,7 +45,7 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
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), 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), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
49
49
 
50
50
  ### Choose the providers to check
51
51
 
@@ -180,7 +180,7 @@ npx @analitikisler/ai-tag-debugger replay saved/*
180
180
  - warning duplicate purchase desktop ga4 page_view
181
181
  ```
182
182
 
183
- The first run's findings become the expected ones. When a change is right, for example a false alarm that is fixed, `replay --update` makes the new findings the expected ones. Use it to check a new version of the tool against sites you know, or a site change against its last good run. A saved site is usually a few MB. It leaves out tracking requests, images and video, session cookies and the cookies the browser sent, authorization headers, form data, and requests with an email or other user data in their address. It still holds the site's pages and scripts, so keep sites you don't own out of public repositories.
183
+ The first run's findings become the expected ones. When a change is right, for example a false alarm that is fixed, `replay --update` makes the new findings the expected ones. Use it to check a new version of the tool against sites you know, or a site change against its last good run. A saved site is usually a few MB. It leaves out tracking requests, images and video, HttpOnly and login cookies and the cookies the browser sent, authorization headers, form data, and requests with an email or other user data in their address. It still holds the site's pages and scripts, so keep sites you don't own out of public repositories.
184
184
 
185
185
  ## Configure your site
186
186
 
package/README.tr.md CHANGED
@@ -45,7 +45,7 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
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
 
48
- Platformlar: GA4 (server-side GTM adresleri dahil), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel ve temel X Pixel event'leri.
48
+ Platformlar: GA4 (server-side GTM adresleri ve sitenin kendi alan adındaki Google tag gateway yolları dahil), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel ve temel X Pixel event'leri.
49
49
 
50
50
  ### Kontrol edilecek platformları seçin
51
51
 
@@ -182,7 +182,7 @@ npx @analitikisler/ai-tag-debugger replay saved/*
182
182
  - warning duplicate purchase desktop ga4 page_view
183
183
  ```
184
184
 
185
- İlk çalıştırmanın bulguları beklenen bulgular olur. Bir değişiklik doğruysa (örneğin düzeltilmiş bir yanlış alarm), `replay --update` yeni bulguları beklenen bulgular yapar. Aracın yeni bir sürümünü bildiğiniz sitelerle ya da bir site değişikliğini son sağlıklı çalıştırmayla karşılaştırmak için kullanın. Kaydedilmiş bir site genellikle birkaç MB'tır. İzleme isteklerini, görselleri ve videoları, oturum çerezlerini ve tarayıcının gönderdiği çerezleri, yetkilendirme başlıklarını, form verilerini ve adresinde e-posta ya da başka kullanıcı verisi olan istekleri içermez. Yine de sitenin sayfalarını ve betiklerini içerdiği için size ait olmayan siteleri herkese açık depolara koymayın.
185
+ İlk çalıştırmanın bulguları beklenen bulgular olur. Bir değişiklik doğruysa (örneğin düzeltilmiş bir yanlış alarm), `replay --update` yeni bulguları beklenen bulgular yapar. Aracın yeni bir sürümünü bildiğiniz sitelerle ya da bir site değişikliğini son sağlıklı çalıştırmayla karşılaştırmak için kullanın. Kaydedilmiş bir site genellikle birkaç MB'tır. İzleme isteklerini, görselleri ve videoları, HttpOnly ve oturum açma çerezlerini ve tarayıcının gönderdiği çerezleri, yetkilendirme başlıklarını, form verilerini ve adresinde e-posta ya da başka kullanıcı verisi olan istekleri içermez. Yine de sitenin sayfalarını ve betiklerini içerdiği için size ait olmayan siteleri herkese açık depolara koymayın.
186
186
 
187
187
  ## Sitenizi ayarlayın
188
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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
@@ -164,15 +164,15 @@ export async function runAgentJourney(session, journey, opts = {}) {
164
164
  /** Replays fixed steps from the config, without calling an AI model. */
165
165
  export async function runScriptedJourney(session, journey, opts = {}) {
166
166
  const log = opts.log ?? (() => {});
167
- if (!journey.steps?.length) throw new Error(`Journey "${journey.id}" has no "steps" for --scripted mode.`);
168
- for (const step of journey.steps) {
167
+ if (!journey.steps?.length && !journey.incomplete) throw new Error(`Journey "${journey.id}" has no "steps" for --scripted mode.`);
168
+ for (const step of journey.steps ?? []) {
169
169
  log(` ${step.reason ?? `${step.action} ${step.selector ?? step.url ?? ""}`}`);
170
170
  const error = await session.act(step);
171
171
  if (error && !step.optional) return { completed: false, note: `Step "${step.reason ?? step.action}" failed: ${error}` };
172
172
  }
173
173
  // Tags often send a moment after the last click (GA4 batches events). An AI run gives them that time
174
174
  // while the model answers; a scripted run would otherwise close the browser first.
175
- await session.page.waitForTimeout(LAST_STEP_WAIT_MS);
175
+ await session.page.waitForTimeout(LAST_STEP_WAIT_MS).catch(() => {});
176
176
  if (journey.incomplete) return { completed: false, note: journey.incomplete };
177
177
  return { completed: true, note: "All scripted steps completed." };
178
178
  }
@@ -194,7 +194,8 @@ export async function runJourney(config, journey, viewport, opts = {}) {
194
194
  await session.start();
195
195
  const startUrl = new URL(journey.start ?? "/", config.site).href;
196
196
  const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${startUrl}` });
197
- if (startError) return { ...collect(session, journey, viewport), completed: false, note: `Could not open the start page: ${startError}` };
197
+ // A replayed journey that was unfinished when saved keeps its saved note, however it fails this time.
198
+ if (startError) return { ...collect(session, journey, viewport), completed: false, note: journey.incomplete ?? `Could not open the start page: ${startError}` };
198
199
 
199
200
  const outcome = await (opts.scripted ? runScriptedJourney(session, journey, opts) : runAgentJourney(session, journey, opts)).catch((err) => {
200
201
  if (err instanceof StepTimeout) return { completed: false, note: err.message };
@@ -202,6 +203,7 @@ export async function runJourney(config, journey, viewport, opts = {}) {
202
203
  });
203
204
  return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
204
205
  } finally {
206
+ if (opts.saveSite) opts.log?.(" Writing the recording…");
205
207
  await session.close();
206
208
  }
207
209
  }
package/src/audit.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // A full audit from a config file: run every journey, check, explain, write the reports.
2
2
  // Shared by the CLI and the MCP server's run_audit tool.
3
- import { readFile, mkdir, writeFile } from "node:fs/promises";
3
+ import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { runJourney } from "./agent.js";
6
6
  import { checkRun, groupFindings, summarize } from "./checks.js";
@@ -9,7 +9,7 @@ import { LANGUAGES, messages } from "./i18n.js";
9
9
  import { createProvider } from "./providers.js";
10
10
  import { filterFindings, planRun } from "./journeys.js";
11
11
  import { resolveProviders } from "./parsers.js";
12
- import { writeSavedSite } from "./replay.js";
12
+ import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
13
13
 
14
14
  export async function readJson(file, baseDir = ".") {
15
15
  const full = path.resolve(baseDir, file);
@@ -72,7 +72,7 @@ export async function runAudit(opts) {
72
72
  const viewports = opts.viewports?.length ? opts.viewports : config.viewports ?? ["desktop"];
73
73
  if (!journeys.length) throw new Error("No journeys to run. Pick one with --journey (e.g. purchase) or describe one with --goal.");
74
74
  if (opts.scripted) {
75
- const unscripted = journeys.filter((j) => !j.steps?.length).map((j) => j.id);
75
+ const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
76
76
  if (unscripted.length) throw new Error(`Journeys without fixed steps can't be replayed with --scripted: ${unscripted.join(", ")}.`);
77
77
  }
78
78
 
@@ -91,10 +91,17 @@ export async function runAudit(opts) {
91
91
  }
92
92
 
93
93
  const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
94
+ let saveError = null;
94
95
  if (opts.saveSite) {
95
- await writeSavedSite(opts.saveSite, { config, plan, runs, findings })
96
- .then(() => log(`\nSaved the site for offline replay in ${opts.saveSite}`))
97
- .catch((err) => log(`\nCould not save the site in ${opts.saveSite}: ${err.message}`));
96
+ try {
97
+ await writeSavedSite(opts.saveSite, { config, plan, runs, findings });
98
+ log(`\nSaved the site for offline replay in ${opts.saveSite}`);
99
+ } catch (err) {
100
+ // A site.json from an earlier save would no longer match the recordings next to it.
101
+ await rm(path.join(opts.saveSite, SAVED_SITE_FILE), { force: true });
102
+ saveError = err.message;
103
+ log(`\nCould not save the site in ${opts.saveSite}: ${err.message}`);
104
+ }
98
105
  }
99
106
  let explained = null;
100
107
  if (provider) {
@@ -105,5 +112,5 @@ export async function runAudit(opts) {
105
112
  const outDir = path.resolve(opts.outDir ?? defaultOutDir());
106
113
  const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, branding: opts.branding ?? config.branding !== false });
107
114
  const counts = summarize(groupFindings(findings));
108
- return { runs, findings, explained, counts, reportPath, outDir, lang };
115
+ return { runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
109
116
  }
package/src/browser.js CHANGED
@@ -232,14 +232,23 @@ const isDroppedMedia = (request) =>
232
232
  ["image", "font", "media"].includes(request.resourceType()) || MEDIA_FILE.test(new URL(request.url()).pathname);
233
233
 
234
234
  // Cookies the server sets for the page's scripts (a country or language, for example) are kept, since
235
- // sites often need them to render; HttpOnly ones, where sessions and logins live, are dropped. Replay
236
- // applies only one Set-Cookie header, so the kept ones are joined into one, one cookie per line.
235
+ // sites often need them to render. HttpOnly ones, where sessions and logins usually live, are dropped,
236
+ // and so are ones named or holding like a login. Replay applies only one Set-Cookie header, so the
237
+ // kept ones are joined into one, one cookie per line.
238
+ const LOGIN_COOKIE = /sess|sid|token|auth|jwt|login|csrf|xsrf|remember/i;
237
239
  function keptHeaders(headers) {
238
240
  const isSetCookie = (h) => /^set-cookie$/i.test(h.name);
239
- const cookies = headers.filter(isSetCookie).flatMap((h) => h.value.split("\n")).filter((c) => !/;\s*httponly\s*(;|$)/i.test(c));
241
+ const isPrivate = (cookie) => {
242
+ const [pair] = cookie.split(";");
243
+ const name = pair.slice(0, pair.indexOf("=")).trim();
244
+ return /;\s*httponly\s*(;|$)/i.test(cookie) || LOGIN_COOKIE.test(name) || /@|%40/.test(pair);
245
+ };
246
+ const cookies = headers.filter(isSetCookie).flatMap((h) => h.value.split("\n")).filter((c) => !isPrivate(c));
240
247
  return [...headers.filter((h) => !isSetCookie(h)), ...(cookies.length ? [{ name: "Set-Cookie", value: cookies.join("\n") }] : [])];
241
248
  }
242
249
 
250
+ const CLOSE_TIMEOUT_MS = 30_000;
251
+
243
252
  const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
244
253
 
245
254
  /**
@@ -276,6 +285,7 @@ export class AuditSession {
276
285
  * others are still answered locally, but only counted, per step, in this.skipped.
277
286
  * site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
278
287
  * recordHar: save every response the site sends into this .har file, so the journey can be replayed offline later.
288
+ * closeTimeoutMs: how long close() waits for the recording to be written (default 30 s) before skipping it.
279
289
  * replayHar: answer requests from a .har file saved with recordHar instead of the network; requests it lacks fail.
280
290
  * blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
281
291
  * All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
@@ -312,6 +322,11 @@ export class AuditSession {
312
322
  launch.proxy = { server: this.egress.url, bypass: "<-loopback>" };
313
323
  launch.args = ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"];
314
324
  }
325
+ if (this.opts.replayHar) {
326
+ // A replay never goes online: requests the routes below don't answer, including any still in
327
+ // flight once they are removed at the end, go to a proxy that isn't there.
328
+ launch.proxy = { server: "http://127.0.0.1:9", bypass: "<-loopback>" };
329
+ }
315
330
  this.browser = await chromium.launch(launch);
316
331
  const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
317
332
  this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
@@ -394,9 +409,15 @@ export class AuditSession {
394
409
  // Requests the recording lacks fail, except the media left out of it: those get an empty answer,
395
410
  // since a failed image or icon can stop a script (a consent banner waiting for its logo, for example).
396
411
  await this.context.route("**/*", (route) =>
397
- isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort(),
412
+ (isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
398
413
  );
399
414
  await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
415
+ // A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
416
+ // recording, the redirect would be followed outside any handler, and its failure would end the run.
417
+ const refusedRedirects = await this.refusedRedirects(this.opts.replayHar);
418
+ if (refusedRedirects.size) {
419
+ await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));
420
+ }
400
421
  }
401
422
  if (this.opts.blockHits) {
402
423
  await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
@@ -485,6 +506,21 @@ export class AuditSession {
485
506
  return true;
486
507
  }
487
508
 
509
+ /** Addresses whose saved answer redirects somewhere the browser may not go. */
510
+ async refusedRedirects(file) {
511
+ const refused = new Set();
512
+ const har = JSON.parse(await readFile(file, "utf8"));
513
+ for (const e of har.log.entries) {
514
+ const status = e.response?.status;
515
+ if (status < 300 || status >= 400) continue;
516
+ const location = e.response.redirectURL || e.response.headers?.find((h) => /^location$/i.test(h.name))?.value;
517
+ if (!location) continue;
518
+ const target = new URL(location, e.request.url).href;
519
+ if (await this.checkUrl(target)) refused.add(e.request.url);
520
+ }
521
+ return refused;
522
+ }
523
+
488
524
  /** Error message when the browser may not be on this URL, or null. */
489
525
  checkUrl(url) {
490
526
  if (url.startsWith("chrome-error:")) return null; // Chromium's own error page, e.g. after a blocked navigation
@@ -693,9 +729,9 @@ export class AuditSession {
693
729
  switch (a.action) {
694
730
  case "goto": {
695
731
  const target = new URL(a.url, this.page.url() === "about:blank" ? undefined : this.page.url()).href;
732
+ step.replay = { action: "goto", url: target };
696
733
  const refused = await this.checkUrl(target);
697
734
  if (refused) return refused;
698
- step.replay = { action: "goto", url: target };
699
735
  await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
700
736
  break;
701
737
  }
@@ -745,13 +781,43 @@ export class AuditSession {
745
781
  }
746
782
 
747
783
  async cookies() {
748
- return (await this.context.cookies()).map((c) => ({ name: c.name, domain: c.domain }));
784
+ let timer;
785
+ const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve([]), 10_000)));
786
+ const cookies = await Promise.race([this.context.cookies().catch(() => []), tooLong]);
787
+ clearTimeout(timer);
788
+ return cookies.map((c) => ({ name: c.name, domain: c.domain }));
749
789
  }
750
790
 
751
791
  async close() {
792
+ // Leaving the page first cancels what it is still loading (the recording would otherwise wait for it),
793
+ // while tracking requests sent on the way out are still answered locally.
794
+ if (this.opts.recordHar) await this.page?.goto("about:blank", { timeout: 5000 }).catch(() => {});
795
+ if (this.opts.replayHar) {
796
+ // The recording's own route callbacks would otherwise throw for requests still in flight once the
797
+ // browser is gone. Without routes, those go nowhere (see start). Elsewhere the routes stay, so a
798
+ // hit sent while the browser closes is still answered locally instead of reaching the real account.
799
+ // A page stuck in a script never answers, so this waits at most 5 seconds.
800
+ let unrouteTimer;
801
+ await Promise.race([
802
+ this.context?.unrouteAll({ behavior: "ignoreErrors" }).catch(() => {}),
803
+ new Promise((resolve) => (unrouteTimer = setTimeout(resolve, 5000))),
804
+ ]);
805
+ clearTimeout(unrouteTimer);
806
+ }
752
807
  if (this.opts.recordHar && this.context) {
753
- // Writes the recording. If the browser already went away, there is none to clean.
754
- const written = await this.context.close().then(() => true, () => false);
808
+ // Writes the recording. If the browser already went away, or writing it takes too long, there is
809
+ // none to clean: closing the browser below then ends the run's wait.
810
+ let timer;
811
+ const limit = this.opts.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
812
+ const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve("timeout"), limit)));
813
+ const closed = await Promise.race([this.context.close().then(() => "closed", () => "gone"), tooLong]);
814
+ clearTimeout(timer);
815
+ if (closed === "timeout") {
816
+ console.warn(`Writing the recording ${this.opts.recordHar} took over ${Math.round(limit / 1000)} seconds, so it was skipped.`);
817
+ await this.browser?.close().catch(() => {});
818
+ await rm(this.opts.recordHar, { force: true }); // a partly written file
819
+ }
820
+ const written = closed === "closed";
755
821
  // A recording that could not be cleaned may hold cookies or form data, so it is deleted.
756
822
  if (written) {
757
823
  await cleanRecording(this.opts.recordHar).catch(async (err) => {
package/src/cli.js CHANGED
@@ -180,6 +180,7 @@ async function main() {
180
180
  const threshold = levels.indexOf(failOn);
181
181
  if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
182
182
  }
183
+ if (result.saveError) process.exitCode = 1;
183
184
  }
184
185
 
185
186
  main().catch((err) => {
package/src/parsers.js CHANGED
@@ -29,12 +29,19 @@ function ga4Consent(searchParams) {
29
29
  // Only www.google.<tld> and doubleclick.net: analytics.google.com is a primary GA4 endpoint.
30
30
  const GOOGLE_COMPANION_HOST = /^(www\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$|(^|\.)doubleclick\.net$/;
31
31
 
32
+ /**
33
+ * Google tag gateway serves Google's endpoints from a path on the site's own domain, with
34
+ * short names: /<prefix>/ag/g/c or /<prefix>/ga/g/c for GA4's /g/collect. Only accepted with a
35
+ * GA4 measurement id, since "/g/c" alone is too common a path.
36
+ */
37
+ const isGa4Path = (u) => u.pathname.endsWith("/g/collect") || (/\/g\/c$/.test(u.pathname) && /^G-[A-Z0-9]+$/.test(u.searchParams.get("tid") ?? ""));
38
+
32
39
  /** @returns {Hit[] | null} */
33
40
  export function parseGa4(url, postData) {
34
41
  const u = new URL(url);
35
42
  // Matched on the GA4 protocol rather than the host, so server-side GTM
36
43
  // endpoints on first-party domains are captured too.
37
- if (!u.pathname.endsWith("/g/collect") || u.searchParams.get("v") !== "2") return null;
44
+ if (!isGa4Path(u) || u.searchParams.get("v") !== "2") return null;
38
45
  // gtag.js resends a hit to www.google.com/g/collect when the first send seems lost, and
39
46
  // copies some to doubleclick.net. GA4 doesn't count these twice, so they aren't hits.
40
47
  // (Primary hits can carry gaf too, e.g. gaf=2, so the host decides, not the parameter.)
@@ -74,11 +81,14 @@ export function parseMeta(url) {
74
81
  /** @returns {Hit[] | null} */
75
82
  export function parseGoogleAds(url) {
76
83
  const u = new URL(url);
84
+ // Google tag gateway serves /pagead/conversion/<id>/ as /<prefix>/as/p/c/<id>/ on the site's own domain.
85
+ const gateway = u.pathname.match(/\/as\/p\/c\/(\d+)\/?$/);
77
86
  const isAds =
78
87
  (u.hostname === "www.googleadservices.com" && u.pathname.startsWith("/pagead/conversion/")) ||
79
- (/(^|\.)google\.[a-z.]+$/.test(u.hostname) && u.pathname.startsWith("/pagead/1p-conversion/"));
88
+ (/(^|\.)google\.[a-z.]+$/.test(u.hostname) && u.pathname.startsWith("/pagead/1p-conversion/")) ||
89
+ (gateway && u.searchParams.has("cv"));
80
90
  if (!isAds) return null;
81
- const conversionId = u.pathname.split("/").filter(Boolean)[2];
91
+ const conversionId = gateway ? gateway[1] : u.pathname.split("/").filter(Boolean)[2];
82
92
  const params = {};
83
93
  for (const key of ["label", "value", "currency_code", "oid"]) {
84
94
  if (u.searchParams.has(key)) params[key === "currency_code" ? "currency" : key === "oid" ? "transaction_id" : key] = u.searchParams.get(key);
@@ -136,7 +146,7 @@ export function parseTrackingRequest(url, postData) {
136
146
  // hits: Google Ads remarketing and consent pings, and the other common ad pixels.
137
147
  const OTHER_BEACONS = [
138
148
  [/(^|\.)(doubleclick\.net|googleadservices\.com|google\.[a-z.]+)$/, /^\/pagead\/(viewthroughconversion|1p-user-list|landing)\//],
139
- [/(^|\.)google\.[a-z.]+$/, /^\/rmkt\/collect\//],
149
+ [/(^|\.)google\.[a-z.]+$/, /^\/(rmkt\/collect\/|measurement\/conversion|ads\/ga-audiences)/],
140
150
  [GOOGLE_COMPANION_HOST, /\/g\/collect$/],
141
151
  [/(^|\.)merchant-center-analytics\.goog$/, /^\/mc\/collect/],
142
152
  // Google tag pings on any host: consent mode (ccm), the Google tag's own /g/s/collect, and
@@ -217,8 +227,12 @@ export function providerOf(url) {
217
227
  if (/(^|\.)(doubleclick\.net|google\.[a-z.]+)$/.test(host) && /^\/(pagead|ccm)\//.test(u.pathname)) return of("google_ads");
218
228
  if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
219
229
  for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
220
- if (u.pathname.endsWith("/g/collect")) return of("ga4");
221
- if (u.pathname.startsWith("/pagead/")) return of("google_ads");
230
+ if (isGa4Path(u)) return of("ga4");
231
+ if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
232
+ // Google Tag Manager and gtag.js served from the site's own domain (server-side GTM, Google tag gateway).
233
+ const id = u.searchParams.get("id") ?? "";
234
+ if (/^GTM-[A-Z0-9]+$/.test(id)) return of("gtm");
235
+ if (/\/gtag\/js$/.test(u.pathname) && id.startsWith("G-")) return of("ga4");
222
236
  return null;
223
237
  }
224
238