@analitikisler/ai-tag-debugger 0.7.0 → 0.7.1
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 +15 -0
- package/README.md +1 -1
- package/README.tr.md +1 -1
- package/package.json +1 -1
- package/src/agent.js +18 -8
- package/src/audit.js +3 -2
- package/src/browser.js +39 -6
- package/src/cli.js +6 -1
- package/src/replay.js +14 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- When a saved site's recording can't be cleaned, a warning now says so before the file is deleted.
|
|
7
|
+
|
|
8
|
+
Replays of saved sites are closer to the saved run, after the first save of 18 real shops:
|
|
9
|
+
|
|
10
|
+
- Scripted runs, and so replays, wait 3 seconds after the last step, so tags that send a moment after the last click (GA4 batches events) are captured as in an AI run.
|
|
11
|
+
- A step that failed in the saved run is tried again on replay without ending the journey there.
|
|
12
|
+
- A journey the AI could not finish (no cart, a bot check) stays unfinished on replay, so its missing events are not reported as broken.
|
|
13
|
+
- Cookies the server sets for the page's scripts, such as a country, are kept in the recording; HttpOnly cookies, where sessions and logins live, are still left out. Some sites don't render without them, for example a cookie banner that never appears.
|
|
14
|
+
- Images, fonts and video left out of the recording get an empty answer on replay instead of failing, since a failed image can stop a script.
|
|
15
|
+
- Requests that had no answer yet when the browser closed are left out of the recording. On replay they made the page that asked for them again never finish loading.
|
|
16
|
+
- A site whose recording is missing (the browser closed early) is not saved, and `replay` reports a saved site it can't replay and goes on with the next one instead of stopping.
|
|
17
|
+
|
|
3
18
|
## 0.7.0 (2026-10-12)
|
|
4
19
|
|
|
5
20
|
Fixes from the first audit run across 18 real shops.
|
package/README.md
CHANGED
|
@@ -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, cookies, 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, 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.
|
|
184
184
|
|
|
185
185
|
## Configure your site
|
|
186
186
|
|
package/README.tr.md
CHANGED
|
@@ -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ı, ç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ı, 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.
|
|
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.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
@@ -88,9 +88,11 @@ export function validateAction(input) {
|
|
|
88
88
|
return null;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// One hung page or model request must not stall a whole run.
|
|
92
|
-
|
|
93
|
-
const
|
|
91
|
+
// One hung page or model request must not stall a whole run. A page whose script never
|
|
92
|
+
// stops (an endless loop after a click, for example) can't be read, so reading it has a limit too.
|
|
93
|
+
const TIMEOUTS = { model: 180_000, action: 120_000, page: 60_000 };
|
|
94
|
+
|
|
95
|
+
const LAST_STEP_WAIT_MS = 3000;
|
|
94
96
|
|
|
95
97
|
class StepTimeout extends Error {}
|
|
96
98
|
|
|
@@ -105,14 +107,18 @@ function withTimeout(promise, ms, message) {
|
|
|
105
107
|
* Lets the AI model drive the browser through one journey.
|
|
106
108
|
* @param {AuditSession} session
|
|
107
109
|
* @param {{ id: string, goal: string }} journey
|
|
108
|
-
* @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void }} opts
|
|
110
|
+
* @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void, timeouts?: { model?: number, action?: number, page?: number } }} opts
|
|
111
|
+
* timeouts: milliseconds for one model answer, one browser action and one read of the page.
|
|
109
112
|
*/
|
|
110
113
|
export async function runAgentJourney(session, journey, opts = {}) {
|
|
111
114
|
const provider = opts.provider ?? (await createProvider({ name: opts.providerName, model: opts.model, client: opts.client }));
|
|
112
115
|
const maxSteps = opts.maxSteps ?? 30;
|
|
113
116
|
const log = opts.log ?? (() => {});
|
|
117
|
+
const limit = { ...TIMEOUTS, ...opts.timeouts };
|
|
118
|
+
const seconds = (ms) => (ms % 60_000 ? `${Math.round(ms / 1000)} seconds` : ms === 60_000 ? "a minute" : `${ms / 60_000} minutes`);
|
|
119
|
+
const readPage = () => withTimeout(session.snapshot(), limit.page, `The page stopped responding: it could not be read for ${seconds(limit.page)}.`);
|
|
114
120
|
|
|
115
|
-
const first = await
|
|
121
|
+
const first = await readPage();
|
|
116
122
|
const chat = provider.conversation({
|
|
117
123
|
system: systemPrompt(opts.lang ?? "en"),
|
|
118
124
|
tools: TOOLS,
|
|
@@ -120,7 +126,7 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
120
126
|
});
|
|
121
127
|
|
|
122
128
|
for (let turn = 0; turn < maxSteps; turn++) {
|
|
123
|
-
const { toolCalls, stop } = await withTimeout(chat.next(),
|
|
129
|
+
const { toolCalls, stop } = await withTimeout(chat.next(), limit.model, `The model did not answer within ${seconds(limit.model)}.`);
|
|
124
130
|
if (stop === "refusal") return { completed: false, note: "The model declined to continue this journey." };
|
|
125
131
|
if (toolCalls.length === 0) {
|
|
126
132
|
if (stop === "max_tokens") return { completed: false, note: "The model response was cut off." };
|
|
@@ -146,8 +152,8 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
146
152
|
continue;
|
|
147
153
|
}
|
|
148
154
|
log(` ${call.input.reason}`);
|
|
149
|
-
const error = await withTimeout(session.act(call.input),
|
|
150
|
-
results.push({ id: call.id, content: pageView(await
|
|
155
|
+
const error = await withTimeout(session.act(call.input), limit.action, `The browser action did not finish within ${seconds(limit.action)}.`);
|
|
156
|
+
results.push({ id: call.id, content: pageView(await readPage(), error) });
|
|
151
157
|
}
|
|
152
158
|
if (finished) return finished;
|
|
153
159
|
chat.addToolResults(results);
|
|
@@ -164,6 +170,10 @@ export async function runScriptedJourney(session, journey, opts = {}) {
|
|
|
164
170
|
const error = await session.act(step);
|
|
165
171
|
if (error && !step.optional) return { completed: false, note: `Step "${step.reason ?? step.action}" failed: ${error}` };
|
|
166
172
|
}
|
|
173
|
+
// Tags often send a moment after the last click (GA4 batches events). An AI run gives them that time
|
|
174
|
+
// while the model answers; a scripted run would otherwise close the browser first.
|
|
175
|
+
await session.page.waitForTimeout(LAST_STEP_WAIT_MS);
|
|
176
|
+
if (journey.incomplete) return { completed: false, note: journey.incomplete };
|
|
167
177
|
return { completed: true, note: "All scripted steps completed." };
|
|
168
178
|
}
|
|
169
179
|
|
package/src/audit.js
CHANGED
|
@@ -92,8 +92,9 @@ export async function runAudit(opts) {
|
|
|
92
92
|
|
|
93
93
|
const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
|
|
94
94
|
if (opts.saveSite) {
|
|
95
|
-
await writeSavedSite(opts.saveSite, { config, plan, runs, findings })
|
|
96
|
-
|
|
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}`));
|
|
97
98
|
}
|
|
98
99
|
let explained = null;
|
|
99
100
|
if (provider) {
|
package/src/browser.js
CHANGED
|
@@ -227,18 +227,32 @@ async function replayTarget(a, locator) {
|
|
|
227
227
|
// Images, fonts and video don't change what tracking sends, and are most of a recording's size.
|
|
228
228
|
const MEDIA = /^(image|font|video|audio)\//;
|
|
229
229
|
|
|
230
|
+
const MEDIA_FILE = /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp|woff2?|ttf|otf|eot|mp4|webm|mov|mp3|m4a|ogg|wav)$/i;
|
|
231
|
+
const isDroppedMedia = (request) =>
|
|
232
|
+
["image", "font", "media"].includes(request.resourceType()) || MEDIA_FILE.test(new URL(request.url()).pathname);
|
|
233
|
+
|
|
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.
|
|
237
|
+
function keptHeaders(headers) {
|
|
238
|
+
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));
|
|
240
|
+
return [...headers.filter((h) => !isSetCookie(h)), ...(cookies.length ? [{ name: "Set-Cookie", value: cookies.join("\n") }] : [])];
|
|
241
|
+
}
|
|
242
|
+
|
|
230
243
|
const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
|
|
231
244
|
|
|
232
245
|
/**
|
|
233
246
|
* Drops images, fonts, video and tracking requests from a recording (they are answered locally on replay, and their
|
|
234
|
-
* URLs can hold the user data redacted everywhere else), requests with user data in their address, form bodies,
|
|
235
|
-
* cookies
|
|
247
|
+
* URLs can hold the user data redacted everywhere else), unanswered requests, requests with user data in their address, form bodies,
|
|
248
|
+
* the cookies the browser sent, HttpOnly cookies the server set, and authorization headers.
|
|
236
249
|
*/
|
|
237
|
-
|
|
238
250
|
async function cleanRecording(file) {
|
|
239
251
|
const har = JSON.parse(await readFile(file, "utf8"));
|
|
240
252
|
har.log.entries = har.log.entries
|
|
241
253
|
.filter((e) => !isCollectionEndpoint(e.request.url) && !MEDIA.test(e.response?.content?.mimeType ?? ""))
|
|
254
|
+
// A request that had no answer yet when the browser closed would never finish on replay either.
|
|
255
|
+
.filter((e) => e.response?.status > 0)
|
|
242
256
|
// A request whose address holds user data (an email in a form GET, for example) is left out whole.
|
|
243
257
|
.filter((e) => redactUrl(e.request.url).url === e.request.url)
|
|
244
258
|
.map((e) => {
|
|
@@ -247,7 +261,7 @@ async function cleanRecording(file) {
|
|
|
247
261
|
return {
|
|
248
262
|
...e,
|
|
249
263
|
request: { ...request, cookies: [], headers: request.headers.filter((h) => !SECRET_REQUEST_HEADER.test(h.name)) },
|
|
250
|
-
response: { ...e.response, cookies: [], headers: e.response.headers
|
|
264
|
+
response: { ...e.response, cookies: [], headers: keptHeaders(e.response.headers) },
|
|
251
265
|
};
|
|
252
266
|
});
|
|
253
267
|
await writeFile(file, JSON.stringify(har));
|
|
@@ -376,7 +390,14 @@ export class AuditSession {
|
|
|
376
390
|
// cancelled before its route handler runs, but its request event has already fired.
|
|
377
391
|
this.context.on("request", onRequest);
|
|
378
392
|
// Registered first, so the routes below run before it and tracking requests are still answered locally.
|
|
379
|
-
if (this.opts.replayHar)
|
|
393
|
+
if (this.opts.replayHar) {
|
|
394
|
+
// Requests the recording lacks fail, except the media left out of it: those get an empty answer,
|
|
395
|
+
// since a failed image or icon can stop a script (a consent banner waiting for its logo, for example).
|
|
396
|
+
await this.context.route("**/*", (route) =>
|
|
397
|
+
isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort(),
|
|
398
|
+
);
|
|
399
|
+
await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
|
|
400
|
+
}
|
|
380
401
|
if (this.opts.blockHits) {
|
|
381
402
|
await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
|
|
382
403
|
await route.fulfill({ status: 204, body: "" });
|
|
@@ -658,6 +679,13 @@ export class AuditSession {
|
|
|
658
679
|
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
|
|
659
680
|
*/
|
|
660
681
|
async act(a) {
|
|
682
|
+
const error = await this.perform(a);
|
|
683
|
+
// A scripted replay of this step may fail the same way, which must not end the journey there.
|
|
684
|
+
if (error) this.steps[this.currentStep].error = error;
|
|
685
|
+
return error;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async perform(a) {
|
|
661
689
|
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
662
690
|
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
|
|
663
691
|
const step = this.steps[this.currentStep];
|
|
@@ -725,7 +753,12 @@ export class AuditSession {
|
|
|
725
753
|
// Writes the recording. If the browser already went away, there is none to clean.
|
|
726
754
|
const written = await this.context.close().then(() => true, () => false);
|
|
727
755
|
// A recording that could not be cleaned may hold cookies or form data, so it is deleted.
|
|
728
|
-
if (written)
|
|
756
|
+
if (written) {
|
|
757
|
+
await cleanRecording(this.opts.recordHar).catch(async (err) => {
|
|
758
|
+
console.warn(`Could not clean the recording ${this.opts.recordHar}, so it was deleted: ${err.message}`);
|
|
759
|
+
await rm(this.opts.recordHar, { force: true });
|
|
760
|
+
});
|
|
761
|
+
}
|
|
729
762
|
}
|
|
730
763
|
await this.browser?.close();
|
|
731
764
|
await this.egress?.close();
|
package/src/cli.js
CHANGED
|
@@ -113,7 +113,12 @@ async function main() {
|
|
|
113
113
|
if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
|
|
114
114
|
let changed = false;
|
|
115
115
|
for (const dir of dirs) {
|
|
116
|
-
const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined })
|
|
116
|
+
const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined }).catch((err) => {
|
|
117
|
+
console.log(`✗ ${dir}: could not replay: ${err.message.split("\n")[0]}`);
|
|
118
|
+
changed = true;
|
|
119
|
+
return null;
|
|
120
|
+
});
|
|
121
|
+
if (!r) continue;
|
|
117
122
|
const same = !r.missing.length && !r.extra.length;
|
|
118
123
|
changed ||= !same;
|
|
119
124
|
console.log(`${same ? "✓" : values.update ? "↻" : "✗"} ${r.site} (${dir}): ${same ? "same findings" : `${r.extra.length} new, ${r.missing.length} gone${values.update ? ", saved as expected" : ""}`}`);
|
package/src/replay.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Saved sites: the responses a site sent during an audit and the steps taken, so a later
|
|
2
2
|
// version of the tool can audit the same pages offline and show what it now reports
|
|
3
3
|
// differently. Replays need no network and no AI model, so they cost nothing.
|
|
4
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { harName } from "./agent.js";
|
|
6
7
|
import { runAudit } from "./audit.js";
|
|
7
8
|
|
|
8
9
|
export const SAVED_SITE_FILE = "site.json";
|
|
@@ -15,9 +16,11 @@ export function replayableJourney(run) {
|
|
|
15
16
|
id: run.journeyId,
|
|
16
17
|
viewport: run.viewport,
|
|
17
18
|
...(start ? { start } : {}),
|
|
19
|
+
// A journey the AI could not finish (no cart, a bot check) is still incomplete when its steps replay.
|
|
20
|
+
...(run.completed ? {} : { incomplete: run.note }),
|
|
18
21
|
steps: (start ? steps.slice(1) : steps)
|
|
19
22
|
.filter((s) => s.replay)
|
|
20
|
-
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}) })),
|
|
23
|
+
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -41,6 +44,12 @@ export function compareKeys(expected, actual) {
|
|
|
41
44
|
/** Writes site.json next to the recorded responses. The findings of this run become the expected ones. */
|
|
42
45
|
export async function writeSavedSite(dir, { config, plan, runs, findings }) {
|
|
43
46
|
await mkdir(dir, { recursive: true });
|
|
47
|
+
for (const run of runs) {
|
|
48
|
+
const har = path.join(dir, harName(run.journeyId, run.viewport));
|
|
49
|
+
if (!(await stat(har).catch(() => null))) {
|
|
50
|
+
throw new Error(`The recording of ${run.journeyId} (${run.viewport}) is missing, probably because the browser closed early. Save the site again.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
44
53
|
const { journeys: _journeys, plan: _plan, gtm_container: _gtm, ...site } = config;
|
|
45
54
|
const saved = {
|
|
46
55
|
version: 1,
|
|
@@ -86,7 +95,9 @@ export async function replaySavedSite(dir, { update = false, outDir, log = () =>
|
|
|
86
95
|
});
|
|
87
96
|
findings.push(...result.findings);
|
|
88
97
|
reportPaths.push(result.reportPath);
|
|
89
|
-
|
|
98
|
+
// Journeys that were already unfinished when saved are compared through their findings only.
|
|
99
|
+
const wasIncomplete = (r) => journeys.find((j) => j.id === r.journeyId)?.incomplete === r.note;
|
|
100
|
+
incomplete.push(...result.runs.filter((r) => !r.completed && !wasIncomplete(r)).map((r) => `${r.journeyId} (${r.viewport}): ${r.note}`));
|
|
90
101
|
}
|
|
91
102
|
const actual = findingKeys(findings);
|
|
92
103
|
const { missing, extra } = compareKeys(saved.expected ?? [], actual);
|