@analitikisler/ai-tag-debugger 0.20.0 → 0.21.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 +39 -0
- package/README.md +22 -11
- package/README.tr.md +22 -11
- package/package.json +1 -1
- package/src/agent.js +5 -1
- package/src/browser.js +211 -19
- package/src/campaign.js +7 -4
- package/src/checks.js +118 -32
- package/src/cli.js +2 -1
- package/src/cmp.js +166 -0
- package/src/consent.js +200 -26
- package/src/help.js +1 -0
- package/src/hit-params.js +70 -0
- package/src/html/consent.js +34 -9
- package/src/html/journeys.js +36 -71
- package/src/html/summary.js +1 -1
- package/src/i18n.js +169 -4
- package/src/mcp.js +335 -28
- package/src/metrics.js +13 -7
- package/src/parsers.js +65 -8
- package/src/report.js +9 -3
- package/src/verify.js +3 -0
package/src/mcp.js
CHANGED
|
@@ -17,7 +17,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
17
17
|
import { AuditSession } from "./browser.js";
|
|
18
18
|
import { navigationError, privateHostChecker } from "./guard.js";
|
|
19
19
|
import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
|
|
20
|
-
import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
20
|
+
import { checkRun, groupFindings, isConsentCommand, summarize } from "./checks.js";
|
|
21
21
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
22
22
|
import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
23
23
|
import { validatePlan } from "./schema.js";
|
|
@@ -29,7 +29,11 @@ import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
|
|
|
29
29
|
import { createProvider } from "./providers.js";
|
|
30
30
|
import { savePlan } from "./plan.js";
|
|
31
31
|
import { changesLines, sinceLastRun } from "./changes.js";
|
|
32
|
-
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
32
|
+
import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
33
|
+
import { hitParams } from "./hit-params.js";
|
|
34
|
+
import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
|
|
35
|
+
import { cmpFacts } from "./cmp.js";
|
|
36
|
+
import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
|
|
33
37
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
34
38
|
|
|
35
39
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -44,7 +48,7 @@ Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the sit
|
|
|
44
48
|
|
|
45
49
|
Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}, plus any other platform the site turns out to use); pass their choice as providers, or leave it out for the default.
|
|
46
50
|
|
|
47
|
-
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
|
|
51
|
+
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language. When a question needs a hit's raw parameters (client id, gcs, gcd decoded per consent signal, session flags), call get_hit with the step number and the hit's [h] number; get_step shows an earlier step again.
|
|
48
52
|
|
|
49
53
|
While browsing:
|
|
50
54
|
${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
|
|
@@ -64,23 +68,197 @@ const audits = new Map();
|
|
|
64
68
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
65
69
|
const fail = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
// Kept whole in the step result, wherever they appear: the end of a page address is where a UTM
|
|
72
|
+
// parameter or a gclid gets lost, so cutting it hides exactly what a campaign check needs.
|
|
73
|
+
const WHOLE_FIELDS = new Set(["page_location", "page_referrer"]);
|
|
74
|
+
const VALUE_LIMIT = 120;
|
|
75
|
+
const PUSH_LIMIT = 200;
|
|
76
|
+
const cut = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
77
|
+
const show = (v) => (typeof v === "string" ? v : JSON.stringify(v));
|
|
78
|
+
|
|
79
|
+
/** The page address fields anywhere in a pushed object, a few levels deep, as [name, value]. */
|
|
80
|
+
function wholeFieldsIn(value, depth = 0, out = []) {
|
|
81
|
+
if (!value || typeof value !== "object" || depth > 3) return out;
|
|
82
|
+
for (const [k, v] of Object.entries(value)) {
|
|
83
|
+
if (WHOLE_FIELDS.has(k) && typeof v === "string") out.push([k, v]);
|
|
84
|
+
else wholeFieldsIn(v, depth + 1, out);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const consentText = (c) => Object.entries(c).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
90
|
+
/** The Google protocol fields of a hit, short: values for the ids and consent strings, bare names for the 1-flags. */
|
|
91
|
+
const protocolText = (p) => Object.entries(p).map(([k, v]) => (["_fv", "_ss", "_nsi"].includes(k) && v === "1" ? k : `${k}=${v}`)).join(" ");
|
|
92
|
+
|
|
93
|
+
function describeHit(h, steps, index) {
|
|
94
|
+
const entries = Object.entries(h.params ?? {}).filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k));
|
|
95
|
+
const params = [
|
|
96
|
+
...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
|
|
97
|
+
...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
|
|
98
|
+
].join(", ");
|
|
99
|
+
const late = h.late ? ` (arrived ${(h.late.ms / 1000).toFixed(1)} s late, after the step that loaded its page: "${steps[h.step]?.label ?? `step ${h.step}`}")` : "";
|
|
100
|
+
return `- [h${index}] ${h.platform} ${h.name}${h.consent ? ` [consent ${consentText(h.consent)}]` : ""}${h.protocol ? ` {${protocolText(h.protocol)}}` : ""}${params ? `: ${params}` : ""}${late}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
|
|
104
|
+
function describePush(p) {
|
|
105
|
+
if (isConsentCommand(p, "default") || isConsentCommand(p, "update")) {
|
|
106
|
+
const signals = p.value[2] && typeof p.value[2] === "object" ? Object.entries(p.value[2]).map(([k, v]) => `${k}=${show(v)}`).join(" ") : show(p.value[2]);
|
|
107
|
+
return `- consent ${p.value[1]}: ${signals}`;
|
|
108
|
+
}
|
|
109
|
+
const json = JSON.stringify(p.value) ?? String(p.value);
|
|
110
|
+
const whole = json.length > PUSH_LIMIT ? wholeFieldsIn(p.value) : [];
|
|
111
|
+
return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent. */
|
|
115
|
+
function describeCall(c) {
|
|
116
|
+
let u;
|
|
117
|
+
try {
|
|
118
|
+
u = new URL(c.url);
|
|
119
|
+
} catch {
|
|
120
|
+
return `- ${c.vendor ?? c.provider} ${cut(c.url, VALUE_LIMIT)}`;
|
|
121
|
+
}
|
|
122
|
+
const consent = googleConsent(u.searchParams);
|
|
123
|
+
// gclid is the landing click id the ping reports (0 when ads_data_redaction blanks it), gclaw the one _gcl_aw holds.
|
|
124
|
+
const signals = ["gcs", "gcd", "dma", "npa", "gclid", "gclaw"].filter((k) => u.searchParams.has(k))
|
|
125
|
+
.map((k) => `${k}=${u.searchParams.get(k)}${k === "gclid" && u.searchParams.get(k) === "0" ? " (redacted)" : ""}`).join(" ");
|
|
126
|
+
return `- ${c.vendor ?? c.provider}: ${u.hostname}${u.pathname}${signals ? ` [${signals}]` : ""}${consent ? ` [consent ${consentText(consent)}]` : ""}`;
|
|
74
127
|
}
|
|
75
128
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const lines = [
|
|
80
|
-
|
|
129
|
+
const NAMES_SHOWN = 15;
|
|
130
|
+
/** The cookies a step added, changed or removed, by name, and what the analytics and ad cookies say when that changed. */
|
|
131
|
+
function cookieLines(session, step) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
const changes = (session.cookieChanges ?? []).filter((c) => c.step === step);
|
|
134
|
+
const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
|
|
135
|
+
const part = (label, list) => (list.length ? `${label} ${list.slice(0, NAMES_SHOWN).join(", ")}${list.length > NAMES_SHOWN ? ` and ${list.length - NAMES_SHOWN} more` : ""}` : null);
|
|
136
|
+
const parts = [part("added", names("added")), part("changed", names("changed")), part("removed", names("removed"))].filter(Boolean);
|
|
137
|
+
if (parts.length) lines.push(`Cookies (names only): ${parts.join("; ")}`);
|
|
138
|
+
const id = session.steps[step]?.identity;
|
|
139
|
+
const before = session.steps.slice(0, step).map((st) => st.identity).filter(Boolean).at(-1);
|
|
140
|
+
if (!id || (!parts.length && JSON.stringify(id) === JSON.stringify(before))) return lines;
|
|
141
|
+
const facts = [];
|
|
142
|
+
if (id.ga_client_id) {
|
|
143
|
+
const prev = before?.ga_client_id;
|
|
144
|
+
const since = session.steps.findIndex((st) => st.identity?.ga_client_id === id.ga_client_id);
|
|
145
|
+
facts.push(`GA4 client id (_ga) ${id.ga_client_id}, ${!prev ? "new at this step" : prev === id.ga_client_id ? `the same since step ${since}` : `changed from ${prev}`}`);
|
|
146
|
+
}
|
|
147
|
+
if (id.gcl_aw) facts.push(id.gcl_aw === "landing" ? "_gcl_aw holds the landing gclid" : "_gcl_aw holds a gclid other than the landing one");
|
|
148
|
+
if (id.fbc) facts.push(id.fbc === "landing" ? "_fbc holds the landing fbclid" : "_fbc holds an fbclid other than the landing one");
|
|
149
|
+
if (facts.length) lines.push(`${facts.join("; ")}.`);
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function stepCapture(audit, step) {
|
|
154
|
+
const { session } = audit;
|
|
155
|
+
const hits = session.hits.filter((h) => h.step === step);
|
|
156
|
+
const pushes = session.dataLayerLog.filter((e) => e.step === step);
|
|
157
|
+
// Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
|
|
158
|
+
const lateHere = session.hits.filter((h) => h.late?.during === step);
|
|
159
|
+
const pings = session.network.filter((c) => c.step === step && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
|
|
160
|
+
const problem = session.steps[step]?.pageProblem;
|
|
161
|
+
const lines = [
|
|
162
|
+
...(problem?.kind === "challenge" ? [`WARNING: the page is a bot challenge from ${problem.vendor}, not the site. Nothing captured here says anything about the site's tracking; run the audit from a network the site trusts.`] : []),
|
|
163
|
+
...(problem?.kind === "crash" ? [`WARNING: the page crashed in the browser (the ${problem.vendor} error screen)${problem.refusedScripts ? `; ${problem.refusedScripts} of its own scripts came back as HTML pages, which bot protection or a mismatched deployment does` : ""}. Its tags may never have run.`] : []),
|
|
164
|
+
`Step ${step}: "${session.steps[step]?.label ?? ""}". Read a hit's full parameters with get_hit (audit_id, step ${step}, its [h] number).`,
|
|
165
|
+
`Tracking hits sent by this step: ${hits.length}`,
|
|
166
|
+
...hits.map((h, i) => describeHit(h, session.steps, i)),
|
|
167
|
+
];
|
|
168
|
+
if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
|
|
169
|
+
if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
|
|
170
|
+
if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
|
|
171
|
+
lines.push(...cookieLines(session, step));
|
|
172
|
+
const tag = session.steps[step]?.tagConsent;
|
|
173
|
+
if (tag) {
|
|
174
|
+
const state = (e) => (e.default && e.update ? `${e.default} → ${e.update}` : e.default ? `${e.default} (default)` : e.update ? `${e.update} (update)` : "not set");
|
|
175
|
+
const flags = [tag.usedDefault ? "default applied" : "no default applied", tag.usedUpdate && "update applied", tag.wasSetLate && "default set late", tag.waitPeriodTimedOut && "wait_for_update ran out"].filter(Boolean);
|
|
176
|
+
lines.push(`Google tag consent state (google_tag_data.ics), changed at this step: ${Object.entries(tag.entries).map(([k, e]) => `${k} ${state(e)}`).join(", ")}; ${flags.join(", ")}.`);
|
|
177
|
+
}
|
|
178
|
+
// The consent platform once, on the step it was first seen at.
|
|
179
|
+
const platform = session.cmp?.step === step ? cmpFacts(messages("en"), [{ cmp: session.cmp, network: session.network }]) : null;
|
|
180
|
+
if (platform) lines.push(`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`);
|
|
81
181
|
return lines.join("\n");
|
|
82
182
|
}
|
|
83
183
|
|
|
184
|
+
const MAX_PARAM_VALUE = 2000;
|
|
185
|
+
/** What a gcd letter says about one signal, in words. */
|
|
186
|
+
function gcdWords({ letter, default: d, update: u, known }) {
|
|
187
|
+
if (!known) return `${letter}: a letter this tool doesn't know`;
|
|
188
|
+
if (!d && !u) return `${letter}: consent mode not used`;
|
|
189
|
+
return `${letter}: ${d ? `${d} by default` : "no default"}, ${u ? `${u} by update` : "no update"}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** One hit in full: where it was sent, its consent and every parameter its request carried. */
|
|
193
|
+
export function hitDetail(audit, step, index) {
|
|
194
|
+
const { session } = audit;
|
|
195
|
+
const hits = session.hits.filter((h) => h.step === step);
|
|
196
|
+
const h = hits[index];
|
|
197
|
+
if (!h) return null;
|
|
198
|
+
const { call, batch, event, context } = hitParams(session, h);
|
|
199
|
+
const label = session.steps[step]?.label ?? "";
|
|
200
|
+
const rows = (entries) => entries.map(([k, v]) => ` ${k} = ${cut(String(v), MAX_PARAM_VALUE)}`);
|
|
201
|
+
const lines = [`[h${index}] ${h.platform} ${h.name}, step ${step} "${label}"${h.late ? `, sent ${(h.late.ms / 1000).toFixed(1)} s late during step ${h.late.during} "${session.steps[h.late.during]?.label ?? ""}"` : ""}`];
|
|
202
|
+
lines.push(`Request: ${call ? `${call.method ?? ""} ${call.url}`.trim() : h.url}${call?.blocked ? " (answered locally, never reached the platform)" : ""}`);
|
|
203
|
+
if (h.consent) lines.push(`Consent: ${consentText(h.consent)}`);
|
|
204
|
+
const gcd = decodeGcd(h.protocol?.gcd);
|
|
205
|
+
if (gcd) lines.push(`gcd ${h.protocol.gcd}: ${Object.entries(gcd).map(([signal, state]) => `${signal} ${gcdWords(state)}`).join("; ")}`);
|
|
206
|
+
if (h.userData?.fields?.length) lines.push(`User data redacted from this hit: ${h.userData.fields.join(", ")}`);
|
|
207
|
+
lines.push(`Event parameters${batch ? ` (event ${batch[0]} of ${batch[1]} in one request)` : ""}:`, ...(event.length ? rows(event) : [" (none)"]));
|
|
208
|
+
if (context.length) lines.push("Other parameters (ids, page, device, consent):", ...rows(context));
|
|
209
|
+
return lines.join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One entry per step, short enough to return with finish_audit and read without the report: what the step did, the
|
|
214
|
+
* hits it sent with their consent, its consent commands and other tracking calls, the Google tag's consent when it
|
|
215
|
+
* changed, and the cookies and ids that changed.
|
|
216
|
+
*/
|
|
217
|
+
export function timelineOf(run) {
|
|
218
|
+
let lastIds = null;
|
|
219
|
+
return (run.steps ?? []).map((st) => {
|
|
220
|
+
const hits = (run.hits ?? []).filter((h) => h.step === st.index);
|
|
221
|
+
const commands = (run.dataLayerLog ?? []).filter((e) => e.step === st.index && (isConsentCommand(e, "default") || isConsentCommand(e, "update")));
|
|
222
|
+
const others = (run.network ?? []).filter((c) => c.step === st.index && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
|
|
223
|
+
const changes = (run.cookieChanges ?? []).filter((c) => c.step === st.index);
|
|
224
|
+
const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
|
|
225
|
+
const entry = { step: st.index, action: st.label, ...(st.pagePath ? { page: st.pagePath } : {}) };
|
|
226
|
+
if (st.error) entry.error = st.error;
|
|
227
|
+
if (hits.length) entry.hits = hits.map((h) => ({ platform: h.platform, event: h.name, ...(h.consent ? { consent: h.consent } : {}), ...(h.late ? { late_ms: h.late.ms } : {}) }));
|
|
228
|
+
if (others.length) entry.other_tracking_calls = others.length;
|
|
229
|
+
if (commands.length) entry.consent_commands = commands.map((e) => ({ [e.value[1]]: e.value[2] }));
|
|
230
|
+
if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
|
|
231
|
+
const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
|
|
232
|
+
if (Object.keys(cookies).length) entry.cookies = cookies;
|
|
233
|
+
if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
|
|
234
|
+
if (st.identity) lastIds = st.identity;
|
|
235
|
+
return entry;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const campaignSchema = () => z.union([z.boolean(), z.object(Object.fromEntries(CAMPAIGN_PARAMS.map((k) => [k, z.string().min(1).optional()]))).strict()]).optional();
|
|
240
|
+
const CAMPAIGN_DOC = `Default true: test values (utm_source=aitd, gclid=aitd-gclid, fbclid=aitd-fbclid...) unless the site address already carries campaign parameters. false opens the site as given. Or your own values, e.g. {"utm_source": "newsletter", "utm_campaign": "autumn", "gclid": "test123"}; allowed keys: ${CAMPAIGN_PARAMS.join(", ")}.`;
|
|
241
|
+
|
|
242
|
+
const LIVE_NOTE = "LIVE: tracking requests go to the real analytics and ad accounts, so this test traffic shows in their reports (GA4 Realtime and DebugView, Meta Events Manager).";
|
|
243
|
+
|
|
244
|
+
const OFFSCREEN_SHOWN = 40;
|
|
245
|
+
const UNCHANGED_TEXT = "(unchanged since the last page view)";
|
|
246
|
+
const UNCHANGED_ELEMENTS = "(unchanged since the last page view: the same refs still apply)";
|
|
247
|
+
const DETAIL_DOC = "compact (default for browser_action): up to 40 elements above or below the screen, and the page text or element list replaced by a note when unchanged since the last view. full: every element and the whole text again.";
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The page view for the chat app. Long sessions lose their oldest results from the context, so a compact view
|
|
251
|
+
* leaves out what the last one already showed: the same text, the same elements (and so the same refs).
|
|
252
|
+
*/
|
|
253
|
+
async function viewFor(audit, error, detail = "compact") {
|
|
254
|
+
const full = detail === "full";
|
|
255
|
+
const snap = await audit.session.snapshot({ offscreenLimit: full ? null : OFFSCREEN_SHOWN });
|
|
256
|
+
const last = audit.lastView;
|
|
257
|
+
audit.lastView = { url: snap.url, text: snap.text, elements: snap.elements };
|
|
258
|
+
if (full || !last || last.url !== snap.url) return pageView(snap, error);
|
|
259
|
+
return pageView({ ...snap, text: snap.text === last.text ? UNCHANGED_TEXT : snap.text, elements: snap.elements === last.elements ? UNCHANGED_ELEMENTS : snap.elements }, error);
|
|
260
|
+
}
|
|
261
|
+
|
|
84
262
|
function getAudit(id) {
|
|
85
263
|
const audit = audits.get(id);
|
|
86
264
|
if (!audit) throw new Error(`No open audit "${id}". Start one with start_audit. Audits close after ${IDLE_MS / 60000} minutes without activity.`);
|
|
@@ -139,6 +317,8 @@ async function loadAgainst(ref, site, opts) {
|
|
|
139
317
|
*/
|
|
140
318
|
export function buildServer(opts) {
|
|
141
319
|
const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
320
|
+
// The reports this server wrote, by folder name: get_report reads these and nothing else, over HTTP too.
|
|
321
|
+
const written = new Map();
|
|
142
322
|
|
|
143
323
|
server.registerTool(
|
|
144
324
|
"start_audit",
|
|
@@ -161,9 +341,11 @@ export function buildServer(opts) {
|
|
|
161
341
|
.array(z.enum(Object.keys(TRACKING_PROVIDERS)))
|
|
162
342
|
.optional()
|
|
163
343
|
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
|
|
344
|
+
campaign: campaignSchema().describe(`Open the first page the way an ad click does, so finish_audit checks that the campaign parameters survive redirects and reach the hits. ${CAMPAIGN_DOC}`),
|
|
345
|
+
live: z.boolean().optional().describe("Default false: tracking requests are answered locally and never reach the site's accounts. true lets them through to the real analytics and ad accounts, to check them in GA4 Realtime or DebugView, Meta Events Manager or Tag Assistant: the test traffic then shows in the site's reports. Only when the user asks for it."),
|
|
164
346
|
},
|
|
165
347
|
},
|
|
166
|
-
async ({ site, journey, viewport, allowed_hosts, providers }) => {
|
|
348
|
+
async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false }) => {
|
|
167
349
|
let url;
|
|
168
350
|
try {
|
|
169
351
|
url = new URL(site);
|
|
@@ -179,7 +361,8 @@ export function buildServer(opts) {
|
|
|
179
361
|
if (audits.size >= MAX_OPEN_AUDITS) return fail(`${audits.size} audits are already open. Finish one with finish_audit first.`);
|
|
180
362
|
let session;
|
|
181
363
|
try {
|
|
182
|
-
|
|
364
|
+
if (live && !opts.allowLive) return fail("Live audits are off on this server: it was started without --allow-live. Leave live out to audit with tracking requests answered locally.");
|
|
365
|
+
session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live });
|
|
183
366
|
} catch (err) {
|
|
184
367
|
return fail(err.message);
|
|
185
368
|
}
|
|
@@ -194,10 +377,14 @@ export function buildServer(opts) {
|
|
|
194
377
|
const name = preset?.id ?? ((journey || "journey").toLowerCase().replace(/[^\w-]+/g, "-").slice(0, 40) || "journey");
|
|
195
378
|
const audit = { id, session, site: url.href, journey: name, preset, viewport: viewport ?? "desktop", lastUsed: Date.now() };
|
|
196
379
|
audits.set(id, audit);
|
|
197
|
-
const
|
|
198
|
-
const
|
|
380
|
+
const start = campaign === false ? url.href : withCampaignParams(url.href, typeof campaign === "object" ? campaign : null);
|
|
381
|
+
const error = await session.act({ action: "goto", url: start, reason: `Open ${start}` });
|
|
382
|
+
const page = await viewFor(audit, error);
|
|
199
383
|
const goal = preset ? `Journey goal: ${preset.goal}\nEvents expected on the way: ${preset.events.map((e) => `${e.platform} ${e.name}`).join(", ")}\n\n` : "";
|
|
200
|
-
|
|
384
|
+
const sent = campaignParamsIn(start);
|
|
385
|
+
const opened = Object.keys(sent).length ? `Opened like an ad click with ${Object.entries(sent).map(([k, v]) => `${k}=${v}`).join(", ")}; finish_audit checks that they survive redirects and reach the hits.\n` : "";
|
|
386
|
+
const liveLine = live ? `${LIVE_NOTE}\n` : "";
|
|
387
|
+
return text(`audit_id: ${id}\n${liveLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
|
|
201
388
|
},
|
|
202
389
|
);
|
|
203
390
|
|
|
@@ -217,6 +404,7 @@ export function buildServer(opts) {
|
|
|
217
404
|
reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
|
|
218
405
|
accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
|
|
219
406
|
rejects_consent: z.boolean().optional().describe("True only when this click rejects or declines all cookies on the consent banner."),
|
|
407
|
+
detail: z.enum(["compact", "full"]).optional().describe(DETAIL_DOC),
|
|
220
408
|
},
|
|
221
409
|
},
|
|
222
410
|
async (input) => {
|
|
@@ -224,7 +412,7 @@ export function buildServer(opts) {
|
|
|
224
412
|
if (["click", "fill", "select"].includes(input.action) && !Number.isInteger(input.ref) && !input.text?.trim()) return fail(`${input.action} needs a ref or text.`);
|
|
225
413
|
if (input.action === "goto" && !input.url) return fail("goto needs a url.");
|
|
226
414
|
const error = await audit.session.act(input);
|
|
227
|
-
const page =
|
|
415
|
+
const page = await viewFor(audit, error, input.detail);
|
|
228
416
|
return text(`${page}\n\n${stepCapture(audit, audit.session.currentStep)}`);
|
|
229
417
|
},
|
|
230
418
|
);
|
|
@@ -233,11 +421,115 @@ export function buildServer(opts) {
|
|
|
233
421
|
"get_page",
|
|
234
422
|
{
|
|
235
423
|
title: "Show the current page",
|
|
236
|
-
description: "Returns the current page view of an open audit without doing anything.",
|
|
237
|
-
inputSchema: { audit_id: z.string() },
|
|
424
|
+
description: "Returns the current page view of an open audit without doing anything. By default in full: every element and the whole page text.",
|
|
425
|
+
inputSchema: { audit_id: z.string(), detail: z.enum(["compact", "full"]).optional().describe(`Default full here. ${DETAIL_DOC}`) },
|
|
426
|
+
annotations: { readOnlyHint: true },
|
|
427
|
+
},
|
|
428
|
+
async ({ audit_id, detail = "full" }) => text(await viewFor(getAudit(audit_id), null, detail)),
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
server.registerTool(
|
|
432
|
+
"get_hit",
|
|
433
|
+
{
|
|
434
|
+
title: "Show one tracking hit in full",
|
|
435
|
+
description:
|
|
436
|
+
"Returns every parameter one tracking hit's request carried (client and session ids, gcs and gcd with gcd decoded per consent signal, page, device and event parameters), where it was sent, and its consent. Step results list each hit with an [h] number; pass that and the step number.",
|
|
437
|
+
inputSchema: {
|
|
438
|
+
audit_id: z.string(),
|
|
439
|
+
step: z.number().int().min(0).describe("The step number from the step result's first line."),
|
|
440
|
+
hit: z.number().int().min(0).describe("The hit's [h] number in that step."),
|
|
441
|
+
},
|
|
238
442
|
annotations: { readOnlyHint: true },
|
|
239
443
|
},
|
|
240
|
-
async ({ audit_id }) =>
|
|
444
|
+
async ({ audit_id, step, hit }) => {
|
|
445
|
+
const audit = getAudit(audit_id);
|
|
446
|
+
const detail = hitDetail(audit, step, hit);
|
|
447
|
+
if (detail !== null) return text(detail);
|
|
448
|
+
const count = audit.session.hits.filter((h) => h.step === step).length;
|
|
449
|
+
return fail(step >= audit.session.steps.length ? `There is no step ${step}; the last is ${audit.session.steps.length - 1}.` : `Step ${step} has ${count} hit${count === 1 ? "" : "s"}${count ? ` (h0 to h${count - 1})` : ""}.`);
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
server.registerTool(
|
|
454
|
+
"get_step",
|
|
455
|
+
{
|
|
456
|
+
title: "Show an earlier step's capture again",
|
|
457
|
+
description:
|
|
458
|
+
"Returns what an earlier step captured, as browser_action showed it: its hits, other tracking calls and dataLayer pushes. Use it to see a step again, for example after a hit that arrived late was added to the step that loaded its page.",
|
|
459
|
+
inputSchema: { audit_id: z.string(), step: z.number().int().min(0).describe("The step number from the step result's first line.") },
|
|
460
|
+
annotations: { readOnlyHint: true },
|
|
461
|
+
},
|
|
462
|
+
async ({ audit_id, step }) => {
|
|
463
|
+
const audit = getAudit(audit_id);
|
|
464
|
+
if (step >= audit.session.steps.length) return fail(`There is no step ${step}; the last is ${audit.session.steps.length - 1}.`);
|
|
465
|
+
return text(stepCapture(audit, step));
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
server.registerTool(
|
|
470
|
+
"get_report",
|
|
471
|
+
{
|
|
472
|
+
title: "Read a report this server wrote",
|
|
473
|
+
description:
|
|
474
|
+
"Returns a report finish_audit or run_audit wrote, so it can be read without access to the computer running this server. part markdown (default): report.md, the verdict, findings, consent and journeys in about 120 lines. summary: summary.json, the counts, journeys and findings as JSON. timeline: one JSON entry per step of each journey (hits with their consent, consent commands, the Google tag's consent, cookies and ids).",
|
|
475
|
+
inputSchema: {
|
|
476
|
+
report: z.string().describe("The report name finish_audit or run_audit gave."),
|
|
477
|
+
part: z.enum(["markdown", "summary", "timeline"]).optional().describe("Default markdown."),
|
|
478
|
+
},
|
|
479
|
+
annotations: { readOnlyHint: true },
|
|
480
|
+
},
|
|
481
|
+
async ({ report, part = "markdown" }) => {
|
|
482
|
+
const dir = written.get(report);
|
|
483
|
+
if (!dir) return fail(written.size ? `No report "${report}" from this server. Its reports: ${[...written.keys()].join(", ")}.` : "This server has written no report yet.");
|
|
484
|
+
try {
|
|
485
|
+
if (part === "markdown") return text(await readFile(path.join(dir, "report.md"), "utf8"));
|
|
486
|
+
if (part === "summary") return text(await readFile(path.join(dir, "summary.json"), "utf8"));
|
|
487
|
+
const capture = JSON.parse(await readFile(path.join(dir, "capture.json"), "utf8"));
|
|
488
|
+
return text(JSON.stringify((capture.runs ?? []).map((r) => ({ journey: r.journeyId, viewport: r.viewport, steps: timelineOf(r) }))));
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return fail(`Could not read the report: ${err.message}`);
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
server.registerTool(
|
|
496
|
+
"consent_matrix",
|
|
497
|
+
{
|
|
498
|
+
title: "Compare every consent choice side by side",
|
|
499
|
+
description:
|
|
500
|
+
"Opens the site's start page like an ad click in a fresh browser once per consent choice: no choice and on to the next page, accept all, reject all and analytics only. Returns which tags sent after each choice, and a matrix of the first Google hit's consent, whether the campaign parameters reached it, whether the GA4 client id was kept, the _gcl_aw cookie, the cookies set, when each tag was called and the Google tag's own consent, with what should not happen marked. Needs a consent banner the tool can answer. Takes about half a minute per choice.",
|
|
501
|
+
inputSchema: {
|
|
502
|
+
site: z.string().describe("The page to start from, e.g. https://www.example.com/"),
|
|
503
|
+
campaign: campaignSchema().describe(`The campaign parameters to open the start page with, as the UTM check. ${CAMPAIGN_DOC}`),
|
|
504
|
+
providers: z.array(z.enum(Object.keys(TRACKING_PROVIDERS))).optional().describe(`Tracking providers to count. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to.`),
|
|
505
|
+
allowed_hosts: z.array(z.string()).optional().describe("Other hosts the pages may load from or redirect to."),
|
|
506
|
+
concurrency: z.number().int().min(1).max(4).optional().describe("How many choices to try at once, each in its own browser. Default 2."),
|
|
507
|
+
language: z.enum(Object.keys(LANGUAGES)).optional().describe("Language of the answer. Default en."),
|
|
508
|
+
},
|
|
509
|
+
annotations: { readOnlyHint: true },
|
|
510
|
+
},
|
|
511
|
+
async ({ site, campaign, providers, allowed_hosts, concurrency = 2, language = "en" }) => {
|
|
512
|
+
let url;
|
|
513
|
+
try {
|
|
514
|
+
url = new URL(site);
|
|
515
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
|
516
|
+
} catch {
|
|
517
|
+
return fail("site must be an http or https URL.");
|
|
518
|
+
}
|
|
519
|
+
if (opts.blockPrivate) {
|
|
520
|
+
const refused = await navigationError(url.href, { isPrivate: privateHostChecker() });
|
|
521
|
+
if (refused) return fail(`${refused} Over HTTP, audits are limited to public websites.`);
|
|
522
|
+
}
|
|
523
|
+
const config = { site: url.href, providers, allowed_hosts };
|
|
524
|
+
const sc = await runConsentScenarios(config, { campaign, concurrency, ...(opts.blockPrivate ? { blockPrivate: opts.blockPrivate } : {}) });
|
|
525
|
+
if (!sc) return text(`No consent banner the tool can answer was found on ${url.href}, so the choices could not be compared. Walk the journey with start_audit and browser_action instead.`);
|
|
526
|
+
const m = messages(language);
|
|
527
|
+
const table = consentTable(sc);
|
|
528
|
+
const lines = [`Consent banner answered through: ${sc.cmp}.`, "", `| | ${table.cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(table.cols.length + 1)}`];
|
|
529
|
+
for (const r of table.rows) lines.push(`| ${TRACKING_PROVIDERS[r.platform] ?? r.platform} ${r.event} | ${r.cells.map((c) => (c.count === null ? "–" : `${c.count}${c.problem ? " ⚠" : ""}`)).join(" | ")} |`);
|
|
530
|
+
lines.push("", "Hits per tag and event after each choice; ⚠: sent with cookies where the choice says no.", "", ...consentMatrixMarkdown(m, sc));
|
|
531
|
+
return text(lines.join("\n"));
|
|
532
|
+
},
|
|
241
533
|
);
|
|
242
534
|
|
|
243
535
|
server.registerTool(
|
|
@@ -290,8 +582,10 @@ export function buildServer(opts) {
|
|
|
290
582
|
const counts = summarize(grouped);
|
|
291
583
|
const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
|
|
292
584
|
const { reportPath } = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
|
|
585
|
+
written.set(path.basename(outDir), outDir);
|
|
293
586
|
const m = messages(lang);
|
|
294
587
|
const lines = [
|
|
588
|
+
...(run.live ? [LIVE_NOTE] : []),
|
|
295
589
|
m.ui.counts(counts),
|
|
296
590
|
...(againstPlan ? [m.ui.checkedAgainst(againstPlan.label)] : []),
|
|
297
591
|
"",
|
|
@@ -299,15 +593,22 @@ export function buildServer(opts) {
|
|
|
299
593
|
"",
|
|
300
594
|
`${run.hits.length} tracking hits, ${run.dataLayerLog.length} dataLayer pushes and ${run.network.length} analytics or ad network calls captured over ${run.steps.length} steps.`,
|
|
301
595
|
skippedLine(run),
|
|
596
|
+
...((platform) => (platform ? [`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`] : []))(cmpFacts(messages("en"), [run])),
|
|
302
597
|
opts.allowFiles
|
|
303
598
|
? `Full report with the event timeline: ${reportPath}`
|
|
304
599
|
: opts.reportsUrl
|
|
305
600
|
? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
|
|
306
601
|
: "",
|
|
307
602
|
"",
|
|
603
|
+
`To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary or timeline).`,
|
|
308
604
|
"Explain each problem to the user with its likely cause and fix.",
|
|
309
605
|
];
|
|
310
|
-
return
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{ type: "text", text: lines.filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n") },
|
|
609
|
+
{ type: "text", text: `Timeline (JSON, one entry per step):\n${JSON.stringify(timelineOf(run))}` },
|
|
610
|
+
],
|
|
611
|
+
};
|
|
311
612
|
},
|
|
312
613
|
);
|
|
313
614
|
|
|
@@ -436,6 +737,7 @@ export function buildServer(opts) {
|
|
|
436
737
|
"No AI provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) is set on the computer running this server, so scripted false can't browse with a model. Call run_audit again with scripted true (or leave it out) to replay the journeys' fixed steps, or browse the site yourself with start_audit, browser_action and finish_audit. Neither needs a key.",
|
|
437
738
|
);
|
|
438
739
|
}
|
|
740
|
+
written.set(path.basename(result.outDir), result.outDir);
|
|
439
741
|
const m = messages(result.lang);
|
|
440
742
|
const grouped = groupFindings(result.findings).filter((f) => f.severity !== "ok");
|
|
441
743
|
return text(
|
|
@@ -445,6 +747,7 @@ export function buildServer(opts) {
|
|
|
445
747
|
...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
|
|
446
748
|
...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
|
|
447
749
|
`Report: ${result.reportPath}`,
|
|
750
|
+
`To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary or timeline).`,
|
|
448
751
|
].join("\n"),
|
|
449
752
|
);
|
|
450
753
|
},
|
|
@@ -455,7 +758,7 @@ export function buildServer(opts) {
|
|
|
455
758
|
}
|
|
456
759
|
|
|
457
760
|
/** Starts the MCP server on stdio, or on HTTP with --http. */
|
|
458
|
-
export async function startMcp({ http = false, port = 8787, host = "127.0.0.1", token, reportsDir, publicUrl, allowPrivateNetwork = false } = {}) {
|
|
761
|
+
export async function startMcp({ http = false, port = 8787, host = "127.0.0.1", token, reportsDir, publicUrl, allowPrivateNetwork = false, allowLive = false } = {}) {
|
|
459
762
|
const dir = path.resolve(reportsDir ?? path.join(homedir(), "ai-tag-debugger-reports"));
|
|
460
763
|
const shutdown = async () => {
|
|
461
764
|
await Promise.all([...audits.keys()].map(closeAudit));
|
|
@@ -465,7 +768,8 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
|
|
|
465
768
|
process.on("SIGTERM", shutdown);
|
|
466
769
|
|
|
467
770
|
if (!http) {
|
|
468
|
-
|
|
771
|
+
// On your own computer live runs are yours to start; a server others reach needs --allow-live.
|
|
772
|
+
const server = buildServer({ allowFiles: true, reportsDir: dir, allowLive: true });
|
|
469
773
|
await server.connect(new StdioServerTransport());
|
|
470
774
|
// stdout carries the protocol, so status goes to stderr.
|
|
471
775
|
console.error(`ai-tag-debugger MCP server ready on stdio. Reports go to ${dir}`);
|
|
@@ -482,6 +786,9 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
|
|
|
482
786
|
if (allowPrivateNetwork) {
|
|
483
787
|
console.error("Warning: --allow-private-network lets audits open localhost and private network addresses. Use it only for a local tunnel you control.");
|
|
484
788
|
}
|
|
789
|
+
if (allowLive) {
|
|
790
|
+
console.error("Warning: --allow-live lets start_audit send tracking requests to the real analytics and ad accounts of the sites it audits.");
|
|
791
|
+
}
|
|
485
792
|
const secret = token ?? randomBytes(18).toString("base64url");
|
|
486
793
|
const endpoint = `/mcp/${secret}`;
|
|
487
794
|
const digest = (s) => createHash("sha256").update(s).digest();
|
|
@@ -549,7 +856,7 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
|
|
|
549
856
|
return;
|
|
550
857
|
}
|
|
551
858
|
// Stateless: a new server and transport per request. Open audits live in the module, keyed by audit_id.
|
|
552
|
-
const server = buildServer({ allowFiles: false, blockPrivate: !allowPrivateNetwork, reportsDir: dir, reportsUrl });
|
|
859
|
+
const server = buildServer({ allowFiles: false, blockPrivate: !allowPrivateNetwork, reportsDir: dir, reportsUrl, allowLive });
|
|
553
860
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
554
861
|
res.on("close", () => {
|
|
555
862
|
transport.close();
|
package/src/metrics.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// { plan, runs, findings, consentScenarios } (no I/O, no AI, no HTML) and returns plain data; the
|
|
5
5
|
// report decides how to show it. The rules are documented in docs/design/metrics.md.
|
|
6
6
|
|
|
7
|
-
import { canonicalEvent, groupFindings, hasParam, hitMatchesEvent, isConsentCommand, PLAN_BUCKETS, planCheck, summarize } from "./checks.js";
|
|
7
|
+
import { canonicalEvent, groupFindings, hasParam, hitMatchesEvent, isConsentCommand, PLAN_BUCKETS, planCheck, summarize, tagConsentStates } from "./checks.js";
|
|
8
8
|
import { AD_PLATFORMS, CONSENT_STATES, consentTable } from "./consent.js";
|
|
9
9
|
import { presetFor } from "./journeys.js";
|
|
10
10
|
import { OWNERS, ownerOf } from "./verify.js";
|
|
@@ -32,7 +32,7 @@ const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
|
|
|
32
32
|
const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
|
|
33
33
|
// Consent findings that say the visitor's "no" was not honoured, against the ones that say the setup is risky.
|
|
34
34
|
const CONSENT_FAIL_CHECKS = new Set(["after_reject", "cookies_after_reject", "reject_ignored", "reject_button_failed"]);
|
|
35
|
-
const CONSENT_ATTENTION_CHECKS = new Set(["before_consent", "cookies_before_consent", "consent_default_missing", "consent_default_late", "consent_v2_missing", "consent_v2_update"]);
|
|
35
|
+
const CONSENT_ATTENTION_CHECKS = new Set(["before_consent", "cookies_before_consent", "consent_default_missing", "consent_default_late", "consent_default_not_applied", "consent_v2_missing", "consent_v2_update"]);
|
|
36
36
|
const CONSENT_CHECK = /^(consent_|cookies_|reject_|before_consent|after_reject)/;
|
|
37
37
|
|
|
38
38
|
// Ecommerce parameters sites send under another name (UA-style products/revenue, order ids), as checks.js reads them.
|
|
@@ -243,15 +243,21 @@ export function platformParity({ runs = [] } = {}) {
|
|
|
243
243
|
function consentDefaultIn(run) {
|
|
244
244
|
const log = run.dataLayerLog ?? [];
|
|
245
245
|
const entry = log.find((e) => isConsentCommand(e, "default")) ?? null;
|
|
246
|
+
// Without a dataLayer command, the Google tag's own state can still show a default (a consent platform's GTM template).
|
|
247
|
+
const tagStates = entry ? [] : tagConsentStates({ steps: run.steps ?? [] });
|
|
248
|
+
const tagDefault = tagStates.find((st) => st.usedDefault) ?? null;
|
|
249
|
+
const set = Boolean(entry || tagDefault);
|
|
246
250
|
const firstGoogleHit = (run.hits ?? []).filter((h) => GOOGLE_PLATFORMS.has(h.platform)).sort(byTime)[0] ?? null;
|
|
247
251
|
const step = entry ? stepOf(run, entry.step) : null;
|
|
248
|
-
const declared =
|
|
252
|
+
const declared = entry
|
|
253
|
+
? uniq(log.filter((e) => isConsentCommand(e, "default")).flatMap((e) => Object.keys(e.value[2] ?? {})))
|
|
254
|
+
: tagDefault ? Object.entries(tagDefault.entries).filter(([, e]) => e.default).map(([k]) => k) : [];
|
|
249
255
|
const missing = V2_SIGNALS.filter((k) => !declared.includes(k));
|
|
250
256
|
return {
|
|
251
|
-
journey: run.journeyId, viewport: run.viewport, present:
|
|
252
|
-
step: entry?.step ?? null, stepLabel: step?.label ?? null, ms: entry && step && entry.t != null && step.t != null ? entry.t - step.t : null,
|
|
253
|
-
beforeFirstGoogleHit: entry && firstGoogleHit ? !sentBefore(firstGoogleHit, entry) : null,
|
|
254
|
-
v2Signals: { present:
|
|
257
|
+
journey: run.journeyId, viewport: run.viewport, present: set, source: entry ? "dataLayer" : tagDefault ? "tag" : null,
|
|
258
|
+
step: entry?.step ?? tagDefault?.step ?? null, stepLabel: step?.label ?? null, ms: entry && step && entry.t != null && step.t != null ? entry.t - step.t : null,
|
|
259
|
+
beforeFirstGoogleHit: entry && firstGoogleHit ? !sentBefore(firstGoogleHit, entry) : tagDefault ? !tagStates.some((st) => st.wasSetLate) : null,
|
|
260
|
+
v2Signals: { present: set && !missing.length, missing: set ? missing : V2_SIGNALS },
|
|
255
261
|
};
|
|
256
262
|
}
|
|
257
263
|
|