@analitikisler/ai-tag-debugger 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-10-15)
4
+
5
+ - `aitd run ... --save-steps <file>` writes the steps each journey took into `journeys[].steps` of that config file (creating it if needed, and adding the run's plan when the config has none). A later `aitd run --config <file> --scripted --no-ai` repeats them with no AI model or API key. A journey run on several viewports keeps the steps of its first viewport.
6
+ - New purchase checks: a purchase sent with a value that is 0, negative or not a number (`purchase_value_invalid`), a currency that isn't a 3-letter ISO code such as USD (`purchase_currency_invalid`), or no items (`purchase_items_empty`). GA4 and Meta reporting the same order with a different value or currency is flagged as `purchase_mismatch`; purchases are paired by order ID, or taken as one order when the journey has a single purchase on each.
7
+
8
+ ## 0.8.2 (2026-10-15)
9
+
10
+ - A journey that fails with an error, such as the AI provider's API refusing a request or the browser crashing, no longer ends the whole audit. That journey is marked unfinished with the error as its note, what it captured until then is kept, the other journeys still run, and the report is written. This applies to the CLI and to `run_audit` over MCP.
11
+ - A Consent Mode default pushed after the first Google hit is now reported as late (`consent_default_late`) when both happen on the same step, such as the start page. The check used to compare only step numbers, so it missed this. Only Google hits count, since Consent Mode only governs Google tags.
12
+ - An unknown viewport, given with `--viewport` or in the config's `viewports`, now stops the audit at once with a message listing the allowed values (desktop, mobile). Before, it failed only when the browser started for that journey, after the model was set up and earlier journeys had run.
13
+ - `ai-tag-debugger --version` (or `aitd -v`) prints the installed version.
14
+ - New short command `aitd`, an alias for `ai-tag-debugger` installed alongside it.
15
+ - For contributors: `npm run setup` checks the Node version, installs the dependencies and Chromium, and says whether an AI provider key is set. A dev container (`.devcontainer/devcontainer.json`) runs it, so VS Code and Codespaces open the repository ready to run.
16
+
3
17
  ## 0.8.1 (2026-10-15)
4
18
 
5
19
  - A run with no AI provider key set now stops at once with a message naming the keys to set and the `--scripted --no-ai` option, instead of starting the browser and failing at the first step with the SDK's "Could not resolve authentication method".
package/README.md CHANGED
@@ -40,6 +40,10 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
40
40
  |---|---|
41
41
  | Missing events | `purchase` never fired on the checkout journey |
42
42
  | Missing parameters | `purchase` sent without `value` and `currency` on mobile |
43
+ | Invalid purchase value | `purchase` sent with `value` `0`, a negative number or text such as `"free"` |
44
+ | Invalid purchase currency | `purchase` sent with currency `usd` or `TL` instead of a 3-letter ISO code such as `USD` or `TRY` |
45
+ | Empty purchase items | `purchase` sent with an empty `items` list |
46
+ | Purchase mismatch | GA4 sends `value` 120 and Meta sends 115 for the same order ID |
43
47
  | Duplicates | `add_to_cart` sent twice by one click |
44
48
  | 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
49
  | Personal data | An email address sent to an ad platform in plain text |
@@ -110,6 +114,8 @@ export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, or GEMINI_API_K
110
114
  ai-tag-debugger run --site https://staging.yourshop.com --journey add_to_cart --headed
111
115
  ```
112
116
 
117
+ The global install also adds `aitd`, a short alias: `aitd run --site … --journey add_to_cart` does the same.
118
+
113
119
  Or run it without installing: `npx -y @analitikisler/ai-tag-debugger run --site … --journey add_to_cart`.
114
120
 
115
121
  Run `ai-tag-debugger --help` to see every command and option, such as `--providers`, `--scripted` and `--no-ai`.
@@ -168,7 +174,7 @@ Each browsing step is one model call, so usage grows with **journeys × viewport
168
174
  - **Cap the steps.** `--max-steps 15` stops a journey that wanders.
169
175
  - **Expect bigger pages to cost more.** Each step sends the page view (up to 250 elements and 3,000 characters of text) to the model, so busy pages use more tokens per step than simple ones.
170
176
  - **Use `--events`** to keep the report focused. It doesn't shorten browsing, so it barely changes usage.
171
- - **Replay without AI.** After one AI run, copy the steps into `journeys[].steps` and use `--scripted` (with `--no-ai` for zero model calls) on every deploy.
177
+ - **Replay without AI.** After one AI run, add `--save-steps <file>` to save its steps, then run that config with `--scripted --no-ai` on every deploy. See [Save the steps and replay them without AI](#save-the-steps-and-replay-them-without-ai).
172
178
  - **Use a chat app.** Over MCP, the chat app's own model does the browsing, so no API key is used at all.
173
179
 
174
180
  ### Check a new version against saved sites
@@ -184,6 +190,17 @@ npx @analitikisler/ai-tag-debugger replay saved/*
184
190
 
185
191
  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.
186
192
 
193
+ ### Save the steps and replay them without AI
194
+
195
+ `--save-steps <file>` writes the steps each journey took into `journeys[].steps` of a config file. It creates the file if it's missing, and it also saves the run's plan when the config has none. The same config then repeats the journey with no AI model and no API key:
196
+
197
+ ```bash
198
+ ai-tag-debugger run --site https://www.yourshop.com --journey purchase --save-steps ai-tag-debugger.config.json
199
+ ai-tag-debugger run --config ai-tag-debugger.config.json --scripted --no-ai
200
+ ```
201
+
202
+ When a journey ran on both viewports, only the first viewport's steps are kept. Keep the config in your repository and run the second command on every deploy to check the same journey without any model calls.
203
+
187
204
  ## Configure your site
188
205
 
189
206
  `ai-tag-debugger.config.json`:
@@ -246,12 +263,14 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
246
263
  [--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
247
264
  [--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
248
265
  [--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
249
- [--save-site <dir>]
266
+ [--save-site <dir>] [--save-steps <file>]
250
267
  ai-tag-debugger replay <dir>... [--update] [--out <dir>]
251
268
  ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
252
269
  [--allow-private-network]
253
270
  ```
254
271
 
272
+ `aitd` is a short alias for `ai-tag-debugger` and takes the same commands and options.
273
+
255
274
  The exit code is `1` when findings reach `--fail-on` (default `broken`), so it can gate a deploy. See [`examples/github-actions/ai-tag-debugger.yml`](examples/github-actions/ai-tag-debugger.yml) for running it after every deployment.
256
275
 
257
276
  Output:
@@ -358,9 +377,12 @@ A hosted server, so you don't need a tunnel, is part of the [Analitik İşler](h
358
377
  ## Development
359
378
 
360
379
  ```bash
361
- npm test # parsers, checks, each AI provider and the MCP server, end to end against the demo shop
380
+ npm run setup # checks Node, installs the dependencies and Chromium, says whether an AI provider key is set
381
+ npm test # parsers, checks, each AI provider and the MCP server, end to end against the demo shop
362
382
  ```
363
383
 
384
+ The repository also has a dev container (`.devcontainer/devcontainer.json`). Open it in VS Code with the Dev Containers extension, or in GitHub Codespaces, and it runs `npm run setup` for you, Chromium's system libraries included. To use an AI model there, add your key as a Codespaces secret or export it in the container's terminal.
385
+
364
386
  The end-to-end tests use fake model clients, so they run without an API key.
365
387
 
366
388
  ## Hosted version
package/README.tr.md CHANGED
@@ -40,6 +40,10 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
40
40
  |---|---|
41
41
  | Eksik event'ler | `purchase`, ödeme senaryosunda hiç gönderilmedi |
42
42
  | Eksik parametreler | `purchase`, mobilde `value` ve `currency` olmadan gönderildi |
43
+ | Geçersiz satın alma değeri | `purchase`, `value` olarak `0`, negatif bir sayı ya da `"ücretsiz"` gibi bir metinle gönderildi |
44
+ | Geçersiz satın alma para birimi | `purchase`, para birimi `usd` ya da `TL` olarak gönderildi; `USD` ya da `TRY` gibi 3 harfli ISO kodu bekleniyor |
45
+ | Boş satın alma ürünleri | `purchase`, boş bir `items` listesiyle gönderildi |
46
+ | Satın alma uyuşmazlığı | Aynı sipariş numarası için GA4 `value` 120, Meta 115 gönderdi |
43
47
  | Tekrar eden event'ler | `add_to_cart` tek tıklamada iki kez gönderildi |
44
48
  | Ç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
49
  | Kişisel veri | Bir e-posta adresi reklam platformuna açık metin olarak gönderildi |
@@ -110,6 +114,8 @@ export ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, or GEMINI_API_K
110
114
  ai-tag-debugger run --site https://staging.yourshop.com --journey add_to_cart --headed
111
115
  ```
112
116
 
117
+ Global kurulum kısa bir takma ad olan `aitd` komutunu da ekler: `aitd run --site … --journey add_to_cart` aynı işi yapar.
118
+
113
119
  Ya da kurmadan çalıştırın: `npx -y @analitikisler/ai-tag-debugger run --site … --journey add_to_cart`.
114
120
 
115
121
  Tüm komutları ve seçenekleri (`--providers`, `--scripted`, `--no-ai` gibi) görmek için `ai-tag-debugger --help` çalıştırın.
@@ -170,7 +176,7 @@ Her gezinme adımı bir model çağrısıdır. Yani kullanım **senaryo × ekran
170
176
  - **Adım sayısını sınırlayın.** `--max-steps 15`, yolunu kaybeden bir senaryoyu durdurur.
171
177
  - **Büyük sayfaların daha pahalı olduğunu unutmayın.** Her adım modele sayfanın görünümünü (en fazla 250 öğe ve 3.000 karakter metin) gönderir. Bu yüzden kalabalık sayfalar adım başına daha fazla token kullanır.
172
178
  - **Raporu odaklı tutmak için `--events` kullanın.** Gezinmeyi kısaltmadığı için kullanımı pek değiştirmez.
173
- - **Yapay zekâ olmadan tekrar oynatın.** Bir yapay zekâ çalıştırmasından sonra adımları `journeys[].steps` içine kopyalayın ve her deploy'da `--scripted` (hiç model çağrısı olmaması için `--no-ai` ile birlikte) kullanın.
179
+ - **Yapay zekâ olmadan tekrar oynatın.** Bir yapay zekâ çalıştırmasından sonra `--save-steps <dosya>` ile adımları kaydedin, ardından her deploy'da o ayar dosyasıyla `--scripted --no-ai` kullanın. Ayrıntılar için [Adımları kaydedin ve yapay zekâ olmadan tekrar oynatın](#adımları-kaydedin-ve-yapay-zekâ-olmadan-tekrar-oynatın) bölümüne bakın.
174
180
  - **Bir yapay zekâ asistanı kullanın.** MCP üzerinden gezinmeyi asistanın kendi modeli yapar, hiç API anahtarı kullanılmaz.
175
181
 
176
182
  ### Yeni bir sürümü kaydedilmiş sitelerle karşılaştırın
@@ -186,6 +192,17 @@ npx @analitikisler/ai-tag-debugger replay saved/*
186
192
 
187
193
  İ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.
188
194
 
195
+ ### Adımları kaydedin ve yapay zekâ olmadan tekrar oynatın
196
+
197
+ `--save-steps <dosya>`, her senaryonun izlediği adımları bir ayar dosyasındaki `journeys[].steps` alanına yazar. Dosya yoksa oluşturulur; ayar dosyasında plan yoksa çalıştırmanın planı da kaydedilir. Aynı ayar dosyası, hiçbir yapay zekâ modeli ve API anahtarı olmadan senaryoyu tekrar çalıştırır:
198
+
199
+ ```bash
200
+ ai-tag-debugger run --site https://www.magazaniz.com --journey purchase --save-steps ai-tag-debugger.config.json
201
+ ai-tag-debugger run --config ai-tag-debugger.config.json --scripted --no-ai
202
+ ```
203
+
204
+ Bir senaryo iki ekran boyutunda çalıştıysa yalnızca ilk ekran boyutunun adımları tutulur. Ayar dosyasını depoda saklayın ve ikinci komutu her deploy'da çalıştırarak aynı senaryoyu hiçbir model çağrısı yapmadan kontrol edin.
205
+
189
206
  ## Sitenizi ayarlayın
190
207
 
191
208
  `ai-tag-debugger.config.json`:
@@ -246,12 +263,14 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
246
263
  [--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
247
264
  [--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
248
265
  [--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
249
- [--save-site <dir>]
266
+ [--save-site <dir>] [--save-steps <file>]
250
267
  ai-tag-debugger replay <dir>... [--update] [--out <dir>]
251
268
  ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
252
269
  [--allow-private-network]
253
270
  ```
254
271
 
272
+ `aitd`, `ai-tag-debugger` için kısa bir takma addır ve aynı komut ve seçenekleri alır.
273
+
255
274
  Bulgular `--fail-on` seviyesine (varsayılan `broken`) ulaştığında çıkış kodu `1` olur, böylece bir deploy'u durdurabilir. Her deploy sonrası çalıştırmak için [`examples/github-actions/ai-tag-debugger.yml`](examples/github-actions/ai-tag-debugger.yml) örneğine bakın.
256
275
 
257
276
  Çıktılar:
@@ -358,9 +377,12 @@ Tünele gerek kalmayan, hazır bir adresle barındırılan sunucu, [Analitik İ
358
377
  ## Geliştirme
359
378
 
360
379
  ```bash
361
- npm test # parsers, checks, each AI provider and the MCP server, end to end against the demo shop
380
+ npm run setup # checks Node, installs the dependencies and Chromium, says whether an AI provider key is set
381
+ npm test # parsers, checks, each AI provider and the MCP server, end to end against the demo shop
362
382
  ```
363
383
 
384
+ Depoda bir dev container (`.devcontainer/devcontainer.json`) da var. Depoyu VS Code'da Dev Containers eklentisiyle ya da GitHub Codespaces'te açtığınızda `npm run setup` sizin için çalışır ve Chromium'un sistem kütüphanelerini de kurar. Orada bir yapay zekâ modeli kullanmak için anahtarınızı Codespaces secret olarak ekleyin ya da container'ın terminalinde export edin.
385
+
364
386
  Uçtan uca testler sahte model istemcileri kullanır, bu yüzden API anahtarı olmadan çalışır.
365
387
 
366
388
  ## Barındırılan sürüm
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
3
  "mcpName": "io.github.analitikisler/ai-tag-debugger",
4
- "version": "0.8.1",
4
+ "version": "0.9.0",
5
5
  "description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
6
6
  "type": "module",
7
7
  "bin": {
8
- "ai-tag-debugger": "src/cli.js"
8
+ "ai-tag-debugger": "src/cli.js",
9
+ "aitd": "src/cli.js"
9
10
  },
10
11
  "files": [
11
12
  "src",
@@ -14,6 +15,7 @@
14
15
  "CHANGELOG.md"
15
16
  ],
16
17
  "scripts": {
18
+ "setup": "node scripts/setup.js",
17
19
  "test": "node --test test/*.test.js",
18
20
  "demo:serve": "node examples/serve.js",
19
21
  "demo": "node src/cli.js run --config examples/demo.config.json --scripted --no-ai --out reports/demo"
package/src/agent.js CHANGED
@@ -203,12 +203,27 @@ export async function runJourney(config, journey, viewport, opts = {}) {
203
203
  throw err;
204
204
  });
205
205
  return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
206
+ } catch (err) {
207
+ // The model's API, the browser or the site can fail in many ways. That ends this journey, not the run,
208
+ // and what was captured until then is kept.
209
+ return { ...collect(session, journey, viewport), completed: false, note: journeyError(err) };
206
210
  } finally {
207
211
  if (opts.saveSite) opts.log?.(" Writing the recording…");
208
212
  await session.close();
209
213
  }
210
214
  }
211
215
 
216
+ /** The note of a journey that ended with an error. */
217
+ export const journeyError = (err) => `The journey stopped with an error: ${err?.message ?? err}`;
218
+
219
+ /** A journey that failed before anything was captured, so the report can still list it. */
220
+ export function failedRun(journey, viewport, err, providers) {
221
+ return {
222
+ journeyId: journey.id, viewport, completed: false, note: journeyError(err), hits: [], steps: [], consentStep: null, rejectStep: null,
223
+ cookieLog: [], dataLayerLog: [], network: [], providers, skipped: {}, bodyBudgetStep: null, startedAt: Date.now(), pageErrors: [],
224
+ };
225
+ }
226
+
212
227
  export function collect(session, journey, viewport) {
213
228
  return {
214
229
  journeyId: journey.id,
package/src/audit.js CHANGED
@@ -2,7 +2,8 @@
2
2
  // Shared by the CLI and the MCP server's run_audit tool.
3
3
  import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { runJourney } from "./agent.js";
5
+ import { failedRun, runJourney } from "./agent.js";
6
+ import { VIEWPORTS } from "./browser.js";
6
7
  import { checkRun, groupFindings, summarize } from "./checks.js";
7
8
  import { explainFindings, renderCsv, renderHtml, renderMarkdown } from "./report.js";
8
9
  import { LANGUAGES, messages } from "./i18n.js";
@@ -70,6 +71,10 @@ export async function runAudit(opts) {
70
71
  const { journeys, plan } = planRun({ config, plan: opts.plan, picks: opts.journeys, goals: opts.goals, events });
71
72
  config = { ...config, providers: resolveProviders(opts.providers ?? config.providers, plan) };
72
73
  const viewports = opts.viewports?.length ? opts.viewports : config.viewports ?? ["desktop"];
74
+ const allowed = Object.keys(VIEWPORTS).join(", ");
75
+ if (!Array.isArray(viewports)) throw new Error(`"viewports" must be a list, e.g. ["desktop", "mobile"]. Allowed values: ${allowed}.`);
76
+ const unknown = viewports.filter((v) => !Object.hasOwn(VIEWPORTS, v));
77
+ if (unknown.length) throw new Error(`Unknown viewport ${unknown.map((v) => `"${v}"`).join(", ")}. Use: ${allowed}.`);
73
78
  if (!journeys.length) throw new Error("No journeys to run. Pick one with --journey (e.g. purchase) or describe one with --goal.");
74
79
  if (opts.scripted) {
75
80
  const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
@@ -84,7 +89,9 @@ export async function runAudit(opts) {
84
89
  for (const journey of journeys) {
85
90
  for (const viewport of viewports) {
86
91
  log(`\n▶ ${journey.id} (${viewport})`);
87
- const run = await runJourney(config, journey, viewport, { scripted: opts.scripted, provider, maxSteps: opts.maxSteps, headed: opts.headed, lang, log, saveSite: opts.saveSite, replaySite: opts.replaySite });
92
+ // runJourney keeps its own errors in the run. This catches the rest, such as a browser that won't close,
93
+ // so one journey can't cost the report of the others.
94
+ const run = await runJourney(config, journey, viewport, { scripted: opts.scripted, provider, maxSteps: opts.maxSteps, headed: opts.headed, lang, log, saveSite: opts.saveSite, replaySite: opts.replaySite }).catch((err) => failedRun(journey, viewport, err, config.providers));
88
95
  log(` ${run.completed ? "✓" : "✗"} ${run.note} · ${run.hits.length} tracking hits captured`);
89
96
  runs.push(run);
90
97
  }
@@ -112,5 +119,5 @@ export async function runAudit(opts) {
112
119
  const outDir = path.resolve(opts.outDir ?? defaultOutDir());
113
120
  const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, branding: opts.branding ?? config.branding !== false });
114
121
  const counts = summarize(groupFindings(findings));
115
- return { runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
122
+ return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
116
123
  }
package/src/checks.js CHANGED
@@ -5,6 +5,11 @@ import { messages } from "./i18n.js";
5
5
  import { isOwnAddress } from "./redact.js";
6
6
 
7
7
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
8
+ const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
9
+
10
+ /** Whether a happened before b: an earlier step, or the same step and an earlier time when both have one. */
11
+ const sentBefore = (a, b) => a.step < b.step || (a.step === b.step && a.t != null && b.t != null && a.t < b.t);
12
+
8
13
  const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
9
14
  // Consent Mode v2 added two signals to ad_storage and analytics_storage. Google requires them for EEA/UK traffic.
10
15
  const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
@@ -63,6 +68,17 @@ export function hasParam(hit, name) {
63
68
  return [name, ...(PARAM_ALIASES[name] ?? [])].some((n) => hit.params?.[n] !== undefined && hit.params[n] !== "");
64
69
  }
65
70
 
71
+ /** A hit's parameter under its own name or the first common alias it carries. */
72
+ function paramOf(hit, name) {
73
+ for (const n of [name, ...(PARAM_ALIASES[name] ?? [])]) {
74
+ if (hit.params?.[n] !== undefined && hit.params[n] !== "") return hit.params[n];
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ const isPurchase = (hit) => canonicalEvent(hit.name) === "purchase";
80
+ const EMPTY_LIST = /^\s*(\[\s*\])?\s*$/;
81
+
66
82
  /** The page load a hit came from: GA4's page load id (_p) when it has one, else the page address. */
67
83
  function pageOf(hit) {
68
84
  try {
@@ -198,6 +214,62 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
198
214
  });
199
215
  }
200
216
 
217
+ // Purchase values: a value that is 0, negative or not a number, a currency that isn't an ISO
218
+ // code, and an empty items list (reported once per platform and event).
219
+ const purchaseReported = new Set();
220
+ const reportPurchase = (hit, check, severity, detail) => {
221
+ const k = `${check}|${key(hit.platform, hit.name)}`;
222
+ if (purchaseReported.has(k)) return;
223
+ purchaseReported.add(k);
224
+ findings.push({ ...base, severity, check, platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail, evidence: JSON.stringify(hit.params) });
225
+ };
226
+ const purchases = run.hits.filter(isPurchase);
227
+ for (const hit of purchases) {
228
+ const value = paramOf(hit, "value");
229
+ if (value !== undefined) {
230
+ const n = Number(value);
231
+ if (!Number.isFinite(n) || String(value).trim() === "") reportPurchase(hit, "purchase_value_invalid", "broken", d.purchaseValueNaN(hit.platform, hit.name, value));
232
+ else if (n <= 0) reportPurchase(hit, "purchase_value_invalid", "broken", d.purchaseValueZero(hit.platform, hit.name, value));
233
+ }
234
+ const currency = paramOf(hit, "currency");
235
+ if (currency !== undefined && !/^[A-Z]{3}$/.test(currency)) reportPurchase(hit, "purchase_currency_invalid", "broken", d.purchaseCurrency(hit.platform, hit.name, currency));
236
+ // GA4 sends items as pr1, pr2...; Meta as contents or content_ids. Items left out of a GA4
237
+ // purchase count as empty, unless the plan requires them and missing_params already says so.
238
+ const listed = ["items", "contents", "content_ids"].filter((n) => hit.params?.[n] !== undefined);
239
+ const requiresItems = planEventFor(hit)?.required_params?.includes("items");
240
+ const empty = listed.length
241
+ ? listed.every((n) => EMPTY_LIST.test(hit.params[n]))
242
+ : hit.platform === "ga4" && !requiresItems && !hasParam(hit, "items");
243
+ if (empty) reportPurchase(hit, "purchase_items_empty", "warning", d.purchaseItemsEmpty(hit.platform, hit.name));
244
+ }
245
+
246
+ // GA4 and Meta reporting the same order with a different value or currency. Purchases are paired
247
+ // by order ID; without IDs, a run with one purchase on each platform is taken as one order.
248
+ const ga4Buys = purchases.filter((h) => h.platform === "ga4");
249
+ const metaBuys = purchases.filter((h) => h.platform === "meta");
250
+ const orderId = (h) => paramOf(h, "transaction_id");
251
+ const pairs = [];
252
+ for (const g of ga4Buys) {
253
+ const m = orderId(g) !== undefined && metaBuys.find((h) => orderId(h) === orderId(g));
254
+ if (m) pairs.push([g, m]);
255
+ }
256
+ if (!pairs.length && ga4Buys.length === 1 && metaBuys.length === 1) {
257
+ const [g, m] = [ga4Buys[0], metaBuys[0]];
258
+ if (orderId(g) === undefined || orderId(m) === undefined) pairs.push([g, m]);
259
+ }
260
+ const mismatchSeen = new Set();
261
+ for (const [g, m] of pairs) {
262
+ const [gv, mv] = [paramOf(g, "value"), paramOf(m, "value")];
263
+ const [gc, mc] = [paramOf(g, "currency"), paramOf(m, "currency")];
264
+ const diffs = [];
265
+ if (gv !== undefined && mv !== undefined && Number.isFinite(Number(gv)) && Number.isFinite(Number(mv)) && Math.abs(Number(gv) - Number(mv)) > 0.005) diffs.push(["value", gv, mv]);
266
+ if (gc !== undefined && mc !== undefined && gc.toUpperCase() !== mc.toUpperCase()) diffs.push(["currency", gc, mc]);
267
+ const k = `${orderId(g) ?? ""}|${diffs.map((x) => x.join("=")).join(",")}`;
268
+ if (!diffs.length || mismatchSeen.has(k)) continue;
269
+ mismatchSeen.add(k);
270
+ findings.push({ ...base, severity: "warning", check: "purchase_mismatch", platform: "meta", event: m.name, step: stepLabel(m.step), detail: d.purchaseMismatch(orderId(g) ?? orderId(m), diffs), evidence: JSON.stringify({ ga4: g.params, meta: m.params }) });
271
+ }
272
+
201
273
  // Server-side tagging. Only what the browser can see: a GA4 hit to a server container, and requests
202
274
  // to Stape or Elevar. Meta's and TikTok's server-side copies never pass through the browser.
203
275
  const serverHits = run.hits.filter((h) => h.delivery === "server");
@@ -222,12 +294,13 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
222
294
 
223
295
  // Consent.
224
296
  const granted = consentGrantedStep(run);
225
- const firstHitStep = run.hits.length ? Math.min(...run.hits.map((h) => h.step)) : null;
297
+ // Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
298
+ const firstGoogleHit = run.hits.filter((h) => GOOGLE_PLATFORMS.has(h.platform)).reduce((a, h) => (!a || sentBefore(h, a) ? h : a), null);
226
299
  if (plan.consent_mode) {
227
300
  const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
228
301
  if (!defaultEntry) {
229
302
  findings.push({ ...base, severity: "risk", check: "consent_default_missing", detail: d.consentDefaultMissing() });
230
- } else if (firstHitStep !== null && defaultEntry.step > firstHitStep) {
303
+ } else if (firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) {
231
304
  findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
232
305
  }
233
306
  }
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { parseArgs } from "node:util";
4
5
  import { loadConfig, runAudit } from "./audit.js";
@@ -6,7 +7,9 @@ import { LANGUAGES, messages } from "./i18n.js";
6
7
  import { PROVIDERS } from "./providers.js";
7
8
  import { JOURNEY_PRESETS } from "./journeys.js";
8
9
  import { PROVIDERS as TRACKING_PROVIDERS, DEFAULT_PROVIDERS } from "./parsers.js";
9
- import { replaySavedSite } from "./replay.js";
10
+ import { replaySavedSite, writeSavedSteps } from "./replay.js";
11
+
12
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
13
 
11
14
  const USAGE = `ai-tag-debugger: an open-source AI agent that QA-tests your analytics and ad tracking.
12
15
 
@@ -46,6 +49,9 @@ Run options:
46
49
  --no-branding Leave the Analitik İşler logo and credit line out of the reports ("branding": false in the config).
47
50
  --save-site <dir> Also save the site's responses and the steps taken in this folder, so "replay" can audit
48
51
  the same pages again offline. Its findings become the expected ones.
52
+ --save-steps <file> Write the steps each journey took into "journeys[].steps" of this config file (created
53
+ if missing), so "run --config <file> --scripted --no-ai" repeats them with no AI model
54
+ or API key. A journey run on several viewports keeps the steps of the first.
49
55
 
50
56
  Replay options:
51
57
  --update Make this replay's findings the expected ones (after checking that the changes are right).
@@ -61,6 +67,9 @@ MCP options:
61
67
  --public-url <url> The HTTPS address your tunnel or host gives this server, so audits can link to their reports.
62
68
 
63
69
  -h, --help Show this help.
70
+ -v, --version Print the version.
71
+
72
+ Short alias: aitd (aitd run ... is the same as ai-tag-debugger run ...).
64
73
 
65
74
  API keys: set one of ${Object.values(PROVIDERS).map((p) => p.env).join(", ")}.
66
75
  Any OpenAI-compatible API: --provider openai-compatible --base-url <url> --model <id>, with the key in OPENAI_COMPATIBLE_API_KEY.`;
@@ -97,11 +106,17 @@ async function main() {
97
106
  reports: { type: "string" },
98
107
  "public-url": { type: "string" },
99
108
  "save-site": { type: "string" },
109
+ "save-steps": { type: "string" },
100
110
  update: { type: "boolean", default: false },
101
111
  help: { type: "boolean", short: "h", default: false },
112
+ version: { type: "boolean", short: "v", default: false },
102
113
  },
103
114
  });
104
115
  const command = positionals[0];
116
+ if (values.version) {
117
+ console.log(VERSION);
118
+ return;
119
+ }
105
120
 
106
121
  if (command === "mcp" && !values.help) {
107
122
  const { startMcp } = await import("./mcp.js");
@@ -182,6 +197,16 @@ async function main() {
182
197
  if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
183
198
  }
184
199
  if (result.saveError) process.exitCode = 1;
200
+ if (values["save-steps"]) {
201
+ try {
202
+ const { saved, skipped } = await writeSavedSteps(values["save-steps"], { site: loaded.config.site, plan: result.plan, journeys: result.journeys, runs: result.runs });
203
+ if (saved.length) console.log(`Saved the steps of ${saved.join(", ")} in ${values["save-steps"]}. Repeat them with: aitd run --config ${values["save-steps"]} --scripted --no-ai`);
204
+ if (skipped.length) console.log(`No steps to save for ${skipped.join(", ")}.`);
205
+ } catch (err) {
206
+ console.log(`Could not save the steps in ${values["save-steps"]}: ${err.message}`);
207
+ process.exitCode = 1;
208
+ }
209
+ }
185
210
  }
186
211
 
187
212
  main().catch((err) => {
package/src/i18n.js CHANGED
@@ -121,6 +121,11 @@ const en = {
121
121
  missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so this may not be a tracking bug.`,
122
122
  missingParams: (name, params) => `${q(name)} was sent without ${params.map(q).join(", ")}.`,
123
123
  duplicate: (name, n) => `${q(name)} was sent ${n} times by one action, which inflates counts and conversions.`,
124
+ purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}. A purchase worth 0 or less adds no revenue, and ad platforms can't optimize bids on it.`,
125
+ purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}, which is not a number, so the revenue is lost. Send a plain number such as 49.90, with no currency sign or thousands separator.`,
126
+ purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} was sent with currency ${q(c)}, which is not a 3-letter ISO 4217 code in capitals (such as USD, EUR or TRY). Platforms may drop the revenue or convert it wrongly.`,
127
+ purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
128
+ purchaseMismatch: (id, diffs) => `GA4 and Meta reported ${id ? `order ${q(id)}` : "the purchase"} differently: ${diffs.map(([f, g, m]) => `${f} ${q(g)} in GA4 and ${q(m)} in Meta`).join("; ")}. One of the tags reads the wrong data.`,
124
129
  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
130
  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.`,
126
131
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
@@ -263,6 +268,11 @@ const tr = {
263
268
  missingEventEarly: (name) => `${q(name)} görülmedi, ancak yolculuk erken durduğu için bu bir izleme hatası olmayabilir.`,
264
269
  missingParams: (name, params) => `${q(name)} olayı ${params.map(q).join(", ")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
265
270
  duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi; bu da sayıları ve dönüşümleri şişirir.`,
271
+ purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} ${q(v)} değeriyle gönderildi. 0 veya daha düşük değerli bir satın alma gelire eklenmez ve reklam platformları teklifleri buna göre optimize edemez.`,
272
+ purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} sayı olmayan ${q(v)} değeriyle gönderildi, bu yüzden gelir kayboluyor. Para birimi işareti veya binlik ayırıcı olmadan 49.90 gibi düz bir sayı gönderin.`,
273
+ purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} ${q(c)} para birimiyle gönderildi; bu, büyük harfli 3 harfli bir ISO 4217 kodu değil (USD, EUR veya TRY gibi). Platformlar geliri atabilir veya yanlış çevirebilir.`,
274
+ purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
275
+ purchaseMismatch: (id, diffs) => `GA4 ve Meta ${id ? `${q(id)} siparişini` : "satın almayı"} farklı bildirdi: ${diffs.map(([f, g, m]) => `${f === "value" ? "değer" : "para birimi"} GA4'te ${q(g)}, Meta'da ${q(m)}`).join("; ")}. Etiketlerden biri yanlış veriyi okuyor.`,
266
276
  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
277
  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.`,
268
278
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
package/src/replay.js CHANGED
@@ -24,6 +24,53 @@ export function replayableJourney(run) {
24
24
  };
25
25
  }
26
26
 
27
+ /**
28
+ * Writes the steps each journey took into journeys[].steps of a config file, so a later
29
+ * --scripted --no-ai run repeats them without an AI model. Creates the file when it doesn't exist.
30
+ * A journey run on several viewports keeps the steps of its first one. Journeys that took no
31
+ * repeatable steps are left as they were. A config with no plan gets the run's, so ready-made
32
+ * journeys keep their expected events.
33
+ * @returns {Promise<{ saved: string[], skipped: string[] }>}
34
+ */
35
+ export async function writeSavedSteps(file, { site, plan, journeys, runs }) {
36
+ let config = {};
37
+ const text = await readFile(file, "utf8").catch((err) => {
38
+ if (err.code === "ENOENT") return null;
39
+ throw new Error(`Could not read ${file}: ${err.message}`);
40
+ });
41
+ if (text !== null) {
42
+ try {
43
+ config = JSON.parse(text);
44
+ } catch (err) {
45
+ throw new Error(`Could not read ${file}: ${err.message}`);
46
+ }
47
+ }
48
+ config.site ??= site;
49
+ if (config.plan === undefined && plan?.events?.length) config.plan = plan;
50
+ const list = [...(config.journeys ?? [])];
51
+ const saved = [];
52
+ const skipped = [];
53
+ for (const journey of journeys) {
54
+ const run = runs.find((r) => r.journeyId === journey.id);
55
+ const { viewport: _v, ...steps } = run ? replayableJourney(run) : { steps: [] };
56
+ if (!steps.steps.length && !steps.start) {
57
+ skipped.push(journey.id);
58
+ continue;
59
+ }
60
+ const i = list.findIndex((j) => j.id === journey.id);
61
+ const { start: _s, steps: _st, incomplete: _in, ...kept } = i === -1 ? { id: journey.id, ...(journey.goal ? { goal: journey.goal } : {}) } : list[i];
62
+ const entry = { ...kept, ...steps, id: journey.id };
63
+ if (i === -1) list.push(entry);
64
+ else list[i] = entry;
65
+ saved.push(journey.id);
66
+ }
67
+ if (saved.length) {
68
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
69
+ await writeFile(file, `${JSON.stringify({ ...config, journeys: list }, null, 2)}\n`);
70
+ }
71
+ return { saved, skipped };
72
+ }
73
+
27
74
  /** One line per finding, sorted: what a replay is compared on. */
28
75
  export function findingKeys(findings) {
29
76
  return findings.map((f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ")).sort();