@dreamdata_io/verify-tracking 0.1.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/LICENSE +21 -0
- package/README.md +756 -0
- package/dist/ai/checker.js +222 -0
- package/dist/ai/checker.js.map +1 -0
- package/dist/ai/checklist.js +190 -0
- package/dist/ai/checklist.js.map +1 -0
- package/dist/ai/cli-presets.js +27 -0
- package/dist/ai/cli-presets.js.map +1 -0
- package/dist/ai/evidence-view.js +143 -0
- package/dist/ai/evidence-view.js.map +1 -0
- package/dist/ai/local-cli.js +58 -0
- package/dist/ai/local-cli.js.map +1 -0
- package/dist/ai/openai-compatible.js +78 -0
- package/dist/ai/openai-compatible.js.map +1 -0
- package/dist/ai/response-parser.js +105 -0
- package/dist/ai/response-parser.js.map +1 -0
- package/dist/ai/types.js +2 -0
- package/dist/ai/types.js.map +1 -0
- package/dist/analysis/consent-mode.js +225 -0
- package/dist/analysis/consent-mode.js.map +1 -0
- package/dist/analysis/deployment.js +2658 -0
- package/dist/analysis/deployment.js.map +1 -0
- package/dist/analysis/diagnosis/copy.en.js +303 -0
- package/dist/analysis/diagnosis/copy.en.js.map +1 -0
- package/dist/analysis/diagnosis/diagnose.js +172 -0
- package/dist/analysis/diagnosis/diagnose.js.map +1 -0
- package/dist/analysis/diagnosis/docs.js +118 -0
- package/dist/analysis/diagnosis/docs.js.map +1 -0
- package/dist/analysis/diagnosis/index.js +5 -0
- package/dist/analysis/diagnosis/index.js.map +1 -0
- package/dist/analysis/diagnosis/rules.js +656 -0
- package/dist/analysis/diagnosis/rules.js.map +1 -0
- package/dist/analysis/diagnosis/types.js +2 -0
- package/dist/analysis/diagnosis/types.js.map +1 -0
- package/dist/cli.js +344 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/env.js +100 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/preflight.js +167 -0
- package/dist/config/preflight.js.map +1 -0
- package/dist/config/user-config.js +79 -0
- package/dist/config/user-config.js.map +1 -0
- package/dist/evidence/collect.js +527 -0
- package/dist/evidence/collect.js.map +1 -0
- package/dist/evidence/consent-accept.js +377 -0
- package/dist/evidence/consent-accept.js.map +1 -0
- package/dist/evidence/consent.js +340 -0
- package/dist/evidence/consent.js.map +1 -0
- package/dist/evidence/constants.js +615 -0
- package/dist/evidence/constants.js.map +1 -0
- package/dist/evidence/globals.js +132 -0
- package/dist/evidence/globals.js.map +1 -0
- package/dist/evidence/gtm-container.js +432 -0
- package/dist/evidence/gtm-container.js.map +1 -0
- package/dist/evidence/heuristic-banner.js +107 -0
- package/dist/evidence/heuristic-banner.js.map +1 -0
- package/dist/evidence/http-probe.js +139 -0
- package/dist/evidence/http-probe.js.map +1 -0
- package/dist/evidence/init-script.js +112 -0
- package/dist/evidence/init-script.js.map +1 -0
- package/dist/evidence/network.js +365 -0
- package/dist/evidence/network.js.map +1 -0
- package/dist/evidence/preference-center.js +368 -0
- package/dist/evidence/preference-center.js.map +1 -0
- package/dist/evidence/script.js +425 -0
- package/dist/evidence/script.js.map +1 -0
- package/dist/evidence/second-page.js +332 -0
- package/dist/evidence/second-page.js.map +1 -0
- package/dist/evidence/selector-probe.js +53 -0
- package/dist/evidence/selector-probe.js.map +1 -0
- package/dist/evidence/stealth.js +21 -0
- package/dist/evidence/stealth.js.map +1 -0
- package/dist/evidence/types.js +14 -0
- package/dist/evidence/types.js.map +1 -0
- package/dist/interactive/prompt.js +31 -0
- package/dist/interactive/prompt.js.map +1 -0
- package/dist/mcp/server.js +308 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/summary.js +41 -0
- package/dist/mcp/summary.js.map +1 -0
- package/dist/report/context.js +184 -0
- package/dist/report/context.js.map +1 -0
- package/dist/report/projection.js +125 -0
- package/dist/report/projection.js.map +1 -0
- package/dist/report/quote-check.js +90 -0
- package/dist/report/quote-check.js.map +1 -0
- package/dist/report/render.js +178 -0
- package/dist/report/render.js.map +1 -0
- package/dist/report/serializers.js +124 -0
- package/dist/report/serializers.js.map +1 -0
- package/dist/report/store.js +139 -0
- package/dist/report/store.js.map +1 -0
- package/dist/run/verify.js +77 -0
- package/dist/run/verify.js.map +1 -0
- package/dist/utils/duration.js +10 -0
- package/dist/utils/duration.js.map +1 -0
- package/dist/utils/errors.js +17 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.js +59 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/runTimingsLog.js +27 -0
- package/dist/utils/runTimingsLog.js.map +1 -0
- package/dist/utils/slug.js +27 -0
- package/dist/utils/slug.js.map +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +57 -0
- package/scripts/install.sh +314 -0
package/README.md
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
# @dreamdata_io/verify-tracking
|
|
2
|
+
|
|
3
|
+
Scrapes a live website with Playwright and asks an AI model whether the
|
|
4
|
+
Dreamdata Analytics Script is correctly installed — script presence,
|
|
5
|
+
`window.dreamdata`, cookies, Google Consent Mode v2, and dataLayer wiring.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Two channels, same package. Pick by what is already on the machine.
|
|
10
|
+
|
|
11
|
+
### On a machine with nothing on it (recommended)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
curl -fsSL https://unpkg.com/@dreamdata_io/verify-tracking/scripts/install.sh | sh
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Paste, answer the sign-in, done. The installer fetches everything the run
|
|
18
|
+
needs — a pinned Node 24 if the machine has none, the package, the Claude Code
|
|
19
|
+
CLI, and Chromium if there is no Google Chrome to drive — then walks you
|
|
20
|
+
through the one-time Claude sign-in on the same terminal. **No API key, no
|
|
21
|
+
config file, no Node or npm required beforehand.**
|
|
22
|
+
|
|
23
|
+
Everything lands in two places, and nothing else is touched:
|
|
24
|
+
|
|
25
|
+
| Path | Holds |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `~/.local/share/verify-tracking/` | the Node runtime, both packages, Chromium |
|
|
28
|
+
| `~/.local/bin/verify-tracking` | the command (plus `verify-tracking-mcp`) |
|
|
29
|
+
|
|
30
|
+
No `sudo`, no global npm prefix, and no system Node touched — an already
|
|
31
|
+
installed Node 20+ or `claude` is reused rather than duplicated. Re-running the
|
|
32
|
+
installer upgrades in place; `rm -rf` on those paths is a complete uninstall.
|
|
33
|
+
Anything already provisioned is skipped, so a re-run after a failure is cheap.
|
|
34
|
+
|
|
35
|
+
**The sign-in is the one step that cannot be unattended** — it is an OAuth flow
|
|
36
|
+
through a browser, so a human has to finish it. The installer opens it for you
|
|
37
|
+
rather than leaving you to discover the requirement on a failed first run. In a
|
|
38
|
+
non-interactive shell (CI, a provisioning script) it installs everything and
|
|
39
|
+
prints the one command left to run:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude auth login
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
macOS and Linux, arm64 and x64. On Windows, install Node 20+ and use `npx`
|
|
46
|
+
below.
|
|
47
|
+
|
|
48
|
+
### On a developer machine
|
|
49
|
+
|
|
50
|
+
Already have Node 20+ and a signed-in `claude`? Skip the installer:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx -y @dreamdata_io/verify-tracking@latest --url https://example.com --quick --no-save-report
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Three things are needed either way, and the installer is simply what supplies
|
|
57
|
+
them. Each is checked at startup, so a missing one is reported in under a
|
|
58
|
+
second rather than after the browser work:
|
|
59
|
+
|
|
60
|
+
1. **Node 20+.**
|
|
61
|
+
2. **The `claude` CLI, signed in.** The default backend shells out to a local
|
|
62
|
+
[Claude Code](https://claude.com/claude-code) CLI, so there is **no API key**
|
|
63
|
+
to distribute, rotate, or leak. Installed-but-logged-out is its own check
|
|
64
|
+
with its own fix.
|
|
65
|
+
3. **A browser.** ~150MB of Chromium does not belong in an npm tarball, and
|
|
66
|
+
does not need to: the collector drives an installed Google Chrome first and
|
|
67
|
+
only falls back to Playwright's own Chromium, so a machine with Chrome on it
|
|
68
|
+
needs nothing extra.
|
|
69
|
+
|
|
70
|
+
With `claude` signed in there is nothing to configure: no config file, no flags.
|
|
71
|
+
|
|
72
|
+
### Using a hosted endpoint instead
|
|
73
|
+
|
|
74
|
+
Put a key in `~/.config/verify-tracking/config.env` — a key is what selects
|
|
75
|
+
that backend, so setting one is all it takes:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
AI_API_KEY=...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That file exists because a globally installed CLI has no project `.env` to
|
|
82
|
+
read. It is written once and is independent of the directory you run from. It
|
|
83
|
+
only fills in what is unset, so flags and real environment variables still win
|
|
84
|
+
over it. `VERIFY_TRACKING_OUTPUT_DIR` belongs here too if the default
|
|
85
|
+
`verification/` tree should not land in whatever directory you happen to be in
|
|
86
|
+
— see [Usage](#usage).
|
|
87
|
+
|
|
88
|
+
A run is measured in minutes, not seconds: a single-phase `--quick` check took
|
|
89
|
+
~2m45s, almost all of it waiting on the model. All three consent states take
|
|
90
|
+
longer.
|
|
91
|
+
|
|
92
|
+
## Setup for development
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bun install
|
|
96
|
+
bunx playwright install chromium
|
|
97
|
+
cp .env.example .env # fill in AI_API_KEY, or point AI_BASE_URL at a local model server
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Bun is the development runtime; the published package is plain compiled JS and
|
|
101
|
+
runs under Node (`engines.node >= 20`).
|
|
102
|
+
|
|
103
|
+
### Cutting a release
|
|
104
|
+
|
|
105
|
+
`.github/workflows/release.yml` publishes on a `v*.*.*` tag: it checks the tag
|
|
106
|
+
against `package.json`, runs the typecheck and tests, and `prepublishOnly`
|
|
107
|
+
rebuilds `dist/` so the tarball is never a stale checkout. Keep
|
|
108
|
+
`src/version.ts` in step with `package.json` — the tag check does not see it.
|
|
109
|
+
|
|
110
|
+
Publishing by hand does the same job:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm publish --access public
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Pick one per release. A manual publish followed by a tag push makes the
|
|
117
|
+
workflow fail on a version already on the registry.
|
|
118
|
+
|
|
119
|
+
## Usage
|
|
120
|
+
|
|
121
|
+
One run, two commands:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
bun run src/cli.ts --slug paytronix_com --url https://www.paytronix.com/
|
|
125
|
+
bun run verification/review/build.ts
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The first walks the site and **writes the report itself** — no output flag
|
|
129
|
+
needed. It writes a pair, and prints both paths when the run ends:
|
|
130
|
+
|
|
131
|
+
| File | What it holds |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `verification/reports/<slug>_report.json` | The report. Every judgement, and the evidence behind it with the raw values that only fed those judgements taken back out. ~35KB — a size you can open, diff, or hand to a model. |
|
|
134
|
+
| `verification/evidence/<slug>_evidence.json` | The same run, nothing trimmed: full request bodies, cookie values, the model's raw reply. Written for the archive, read when a judgement needs auditing. |
|
|
135
|
+
|
|
136
|
+
So the command above writes `paytronix_com_report.json` and
|
|
137
|
+
`paytronix_com_evidence.json`. Both come from one in-memory collection in one
|
|
138
|
+
write step, so the pair is always the same run — re-running a live site never
|
|
139
|
+
reproduces its evidence exactly, which is why the untrimmed copy is kept rather
|
|
140
|
+
than re-derived on demand. `--out <path>` writes the report somewhere else and
|
|
141
|
+
puts its companion beside it as `<path>.evidence.json`; `--no-save-report`
|
|
142
|
+
skips both.
|
|
143
|
+
|
|
144
|
+
Both paths are relative to the working directory, which is right inside this
|
|
145
|
+
repo — the review dashboard reads exactly those two directories — and unwelcome
|
|
146
|
+
when the CLI is installed globally and run from somewhere arbitrary. Set
|
|
147
|
+
`VERIFY_TRACKING_OUTPUT_DIR` (a flag-free setting, so `~/.config/verify-tracking/config.env`
|
|
148
|
+
is its natural home) to root them somewhere else; the run prints absolute paths
|
|
149
|
+
when it ends either way.
|
|
150
|
+
|
|
151
|
+
What the report leaves out, and why it is safe to leave out, is documented in
|
|
152
|
+
`src/report/projection.ts`: every check and verdict is already resolved against
|
|
153
|
+
the full evidence before serialization, so nothing the report drops could have
|
|
154
|
+
changed one.
|
|
155
|
+
|
|
156
|
+
The second regenerates the review dashboard's data from whatever is currently
|
|
157
|
+
in `verification/reports/` — that directory decides which accounts appear, with
|
|
158
|
+
`verification/evidence/` read on top of it for the raw values. It has to be
|
|
159
|
+
re-run after every `cli.ts` run
|
|
160
|
+
for `verification/review/index.html` to show the new report — there is no build
|
|
161
|
+
step for the page itself; open the HTML file directly in a browser afterwards.
|
|
162
|
+
|
|
163
|
+
That single `cli.ts` run already walks **all three consent states** — clean,
|
|
164
|
+
grant, refused — each in its own fresh browser context, and reports them side by
|
|
165
|
+
side. There are no consent flags to pass: `--phase clean,grant` narrows the set
|
|
166
|
+
if you want fewer, and `--quick` is shorthand for `--phase clean`. Omit `--url`
|
|
167
|
+
to be prompted interactively. See `bun run src/cli.ts --help` for all flags.
|
|
168
|
+
|
|
169
|
+
`--slug` names the account the run belongs to. It sets the report filename and
|
|
170
|
+
is recorded inside the file, so a run against a host that is not the account's
|
|
171
|
+
own domain still files under the account:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bun run src/cli.ts --slug tungstenautomation_com --url https://sign-in.printix.net/
|
|
175
|
+
bun run verification/review/build.ts
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Without it the slug is derived from the URL's hostname — which would have filed
|
|
179
|
+
that run as `printix_net` and lost the account. The review index flags any report
|
|
180
|
+
whose filename and recorded slug disagree.
|
|
181
|
+
|
|
182
|
+
See [Verification review index](#verification-review-index) below for what the
|
|
183
|
+
dashboard shows and how it's structured.
|
|
184
|
+
|
|
185
|
+
### AI backends
|
|
186
|
+
|
|
187
|
+
- `--backend local-cli` — shells out to an installed AI CLI. `--cli-preset claude` is the default and the
|
|
188
|
+
only verified one; for anything else pass `--cli-cmd` with a command template using `{model}`/`{prompt}`
|
|
189
|
+
placeholders.
|
|
190
|
+
- `--backend openai` — any OpenAI-compatible chat completions endpoint. Works unchanged for OpenRouter
|
|
191
|
+
(`--base-url https://openrouter.ai/api/v1`), a local model server (e.g. a local vLLM/Ollama/LM Studio
|
|
192
|
+
endpoint), or any other compatible provider — just change `--base-url`/`--model`.
|
|
193
|
+
|
|
194
|
+
**An API key picks the backend.** With one set — flag, environment, or config
|
|
195
|
+
file — the hosted endpoint is used; with none, the local CLI is, since it is
|
|
196
|
+
the only one that can answer without credentials. `--backend` overrides the
|
|
197
|
+
choice either way. An unconfigured run used to select the endpoint and die on a
|
|
198
|
+
401 naming nothing the operator could fix, which is the wrong default for a
|
|
199
|
+
tool whose intended setup carries no key at all.
|
|
200
|
+
|
|
201
|
+
The default model follows the backend, because the two name models
|
|
202
|
+
differently: `anthropic/claude-sonnet-4.5` for the endpoint, `sonnet` for the
|
|
203
|
+
claude CLI. Do not cross them — `claude --model anthropic/claude-sonnet-4.5`
|
|
204
|
+
is refused **and still exits 0**, so the refusal used to be read back as the
|
|
205
|
+
model's answer. `local-cli` now rejects a zero-exit reply that carries no JSON
|
|
206
|
+
rather than reporting a verdict it could not parse.
|
|
207
|
+
|
|
208
|
+
### Running against a local model
|
|
209
|
+
|
|
210
|
+
The `openai` backend is the local path too — a local server is just another
|
|
211
|
+
base URL:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
bun run src/cli.ts --slug clio_com --url https://www.clio.com/ \
|
|
215
|
+
--base-url http://localhost:11434/v1 --api-key ollama --model dd-verify
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Build the model tag first; do not point `--model` at a stock Ollama tag.**
|
|
219
|
+
Ollama's default context is 4096 tokens; a three-phase run's prompt measures
|
|
220
|
+
around 16k — a ~6k system prompt (the checklist and shape guidance) plus the
|
|
221
|
+
evidence view — so a stock tag silently truncates the payload and then scores
|
|
222
|
+
checklist items against evidence it was never shown, a wrong verdict that
|
|
223
|
+
reports as a clean run. `replay.ts` prints the measured count for the run in
|
|
224
|
+
front of you. The `Modelfile` at the repo root pins a context window that fits:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
ollama create dd-verify -f Modelfile
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
LM Studio works the same way at `http://localhost:1234/v1`.
|
|
231
|
+
|
|
232
|
+
**Check the model actually fits.** `ollama ps` reports a `PROCESSOR` column: a
|
|
233
|
+
model larger than free RAM runs partly on the CPU, and the difference is not
|
|
234
|
+
subtle. Measured on a 24GB machine, the same single-phase prompt took 148s on
|
|
235
|
+
`qwen2.5-coder:14b` (fully on GPU) and had not finished after ten minutes on
|
|
236
|
+
`qwen2.5-coder:32b` (28GB required, 38% on CPU). Waiting for the latter is a
|
|
237
|
+
choice you can make — `--ai-timeout` exists for it, and defaults to 30 minutes
|
|
238
|
+
— but a batch of accounts is where that cost lands.
|
|
239
|
+
|
|
240
|
+
### Replaying a saved run against another backend
|
|
241
|
+
|
|
242
|
+
Comparing two backends by re-running `cli.ts` against a live site compares two
|
|
243
|
+
different observations: the site changed, the banner behaved differently, a bot
|
|
244
|
+
wall appeared. `verification/replay.ts` removes that variable — it rebuilds
|
|
245
|
+
`MultiPhaseEvidence` from a saved report, re-runs the deterministic
|
|
246
|
+
`analyzeDeployment` over it, and sends that same fixed evidence to whichever
|
|
247
|
+
backend you name:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
bun run verification/replay.ts --slug clio_com \
|
|
251
|
+
--base-url http://localhost:11434/v1 --model dd-verify --compare
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`--compare` diffs the result against the verdicts already in that report and
|
|
255
|
+
exits non-zero on a regression. What it checks, in the order that matters:
|
|
256
|
+
|
|
257
|
+
1. **`overallPass` did not flip** — that is the exit code, and it must not
|
|
258
|
+
depend on which model was asked.
|
|
259
|
+
2. **Every checklist id is present exactly once**, with no invented ids.
|
|
260
|
+
3. **Each `passed` matches.** A `true` ↔ `false` flip fails the run;
|
|
261
|
+
`inconclusive` ↔ `false` is tabled for a human but tolerated.
|
|
262
|
+
4. **`evidenceQuote` names a field that actually exists** in the evidence or the
|
|
263
|
+
analysis. Inventing a field path is the failure mode small models reach for,
|
|
264
|
+
and `checker.ts`'s prompt forbids it explicitly.
|
|
265
|
+
5. **`remediationAdvice` does not tell an installed customer to install the
|
|
266
|
+
script** when `deployment.shape` already confirms an install.
|
|
267
|
+
|
|
268
|
+
The replayed run is written to `verification/reports/local_ai/` — beside the
|
|
269
|
+
baseline it read, never over it (`--out-dir` moves it, `--no-save-report` skips
|
|
270
|
+
it). To look at those runs in the dashboard:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
bun run verification/review/build.ts --reports-dir verification/reports/local_ai --out verification/review/local-data.js
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
then open `verification/review/index.html?data=local-data.js` — the same page,
|
|
277
|
+
which reads its dataset from that query parameter and labels its own header
|
|
278
|
+
with the reports directory it loaded, so a replayed run is never mistaken for a
|
|
279
|
+
ground-truth one.
|
|
280
|
+
|
|
281
|
+
## The three consent states
|
|
282
|
+
|
|
283
|
+
Every consent verdict is a **comparison**, not a property of one page load: a
|
|
284
|
+
bundle that is correctly gated and one that is broken both look like silence. So
|
|
285
|
+
each run observes three states, each in its own fresh browser context (the
|
|
286
|
+
isolation matters — a grant leaves a cookie that would make the next state replay
|
|
287
|
+
the granted page):
|
|
288
|
+
|
|
289
|
+
| Phase | What it does |
|
|
290
|
+
| --- | --- |
|
|
291
|
+
| `clean` | Loads the page and leaves the banner alone |
|
|
292
|
+
| `grant` | Drives the banner's accept control, waits for it to persist, reloads |
|
|
293
|
+
| `reject` | Drives the banner's reject control, waits for it to persist, reloads |
|
|
294
|
+
|
|
295
|
+
`--phase clean,grant` runs a subset; `--quick` is shorthand for `--phase clean`.
|
|
296
|
+
|
|
297
|
+
## The second page
|
|
298
|
+
|
|
299
|
+
Each phase then follows one internal link, because a first page view is the one
|
|
300
|
+
page view a broken install still tends to get right. Two failures live entirely
|
|
301
|
+
in the gap between page one and page two:
|
|
302
|
+
|
|
303
|
+
- A consent-gated bundle injected **once**, on the load that followed the grant,
|
|
304
|
+
and never again. The visitor accepted, got identified tracking for exactly one
|
|
305
|
+
page, and was quietly handed back to the cookieless fallback after that — so
|
|
306
|
+
their session arrives split in two, half identified and half anonymous.
|
|
307
|
+
- A **single-page app** whose router swaps the route without a document load. No
|
|
308
|
+
script re-runs, so a bundle that only tracks on load never sends another page
|
|
309
|
+
event no matter how many pages the visitor reads.
|
|
310
|
+
|
|
311
|
+
Both look perfect in every first-page field, which is why the second page's
|
|
312
|
+
evidence is kept separate (`phases[].secondPage`) rather than folded into the
|
|
313
|
+
phase's own counts.
|
|
314
|
+
|
|
315
|
+
The link is picked in the page and clicked the way a visitor would click it —
|
|
316
|
+
never with `page.goto`, which forces a document load and would hide the SPA case
|
|
317
|
+
outright. `secondPage.kind` records what the site did with it: `hard` (a new
|
|
318
|
+
document) or `soft` (the router handled it in the same document, with the
|
|
319
|
+
`pushState` to prove it). Legal, auth and asset URLs are skipped
|
|
320
|
+
(`SECOND_PAGE_PATH_DENYLIST`) since a missing request there says nothing about the
|
|
321
|
+
install, and the click is dispatched on the element so an unanswered consent
|
|
322
|
+
banner cannot swallow it.
|
|
323
|
+
|
|
324
|
+
When the walk is handled by the router rather than by a document load, the
|
|
325
|
+
phase is left with **no unambiguous page view at all**: its entry page was
|
|
326
|
+
loaded twice around the consent answer, so two page events there can be the
|
|
327
|
+
consent upgrade rather than two loaders, and the page it routed to never
|
|
328
|
+
re-ran a script. So a soft walk is followed by one plain document load of the
|
|
329
|
+
page it landed on (`secondPage.hardLoad`) — the same navigation a visitor
|
|
330
|
+
makes when they open that page directly or refresh it.
|
|
331
|
+
|
|
332
|
+
That load is what `page-view-counted-once` can actually judge. meteomatics.com
|
|
333
|
+
is the case it was added for: every internal link routes in-document, and a
|
|
334
|
+
plain load of the routed-to page sends **both** bundles' page events — 6 of 8
|
|
335
|
+
warm-cache loads, 20–70ms apart, on a settled consent decision with no answer
|
|
336
|
+
anywhere near it. Nothing else the run observed could say so.
|
|
337
|
+
|
|
338
|
+
It costs one reload and one flush wait per phase, and only in phases whose
|
|
339
|
+
walk was soft; a walk that already landed a document load records
|
|
340
|
+
`hardLoad: null`.
|
|
341
|
+
|
|
342
|
+
`--no-second-page` skips the walk. Then the three checks below read
|
|
343
|
+
`inconclusive`, and the run sees one page view per phase again.
|
|
344
|
+
|
|
345
|
+
The comparisons are then made in code, not by the model:
|
|
346
|
+
|
|
347
|
+
| Check | Expectation |
|
|
348
|
+
| --- | --- |
|
|
349
|
+
| `clean-before-consent` | No normal request and no identity cookie while the banner is unanswered |
|
|
350
|
+
| `grant-enables-normal` | Accepting must actually start identified tracking |
|
|
351
|
+
| `reject-blocks-normal` | Refusing must actually stop it |
|
|
352
|
+
| `cookieless-covers-unconsented` | Where nothing else tracks, the cookieless bundle does |
|
|
353
|
+
| `grant-sets-identity-cookies` | A running normal bundle stores an anonymous id |
|
|
354
|
+
| `reject-writes-no-identity` | A refusal leaves no cookie or localStorage entry behind |
|
|
355
|
+
| `consent-mode-signal` | `analytics_storage` follows the choice, with source `update` |
|
|
356
|
+
| `second-page-keeps-tracking` | After a document load onto another page, whatever was tracking still is |
|
|
357
|
+
| `soft-nav-keeps-tracking` | After an in-app route change, a page event still fires |
|
|
358
|
+
| `second-page-honours-consent` | A navigation does not turn identified tracking on by itself |
|
|
359
|
+
| `page-view-counted-once` | One page view produces one `page` event, not two (the banner-answering entry page is exempt: it loaded twice) |
|
|
360
|
+
|
|
361
|
+
The two continuity checks split one question by where its answer lives: a
|
|
362
|
+
document load that stops tracking is a missing bundle or tag-manager trigger on
|
|
363
|
+
non-entry pages, while a route change that stops tracking is a bundle that is
|
|
364
|
+
right there and a router that never calls `page()`. Every phase falls into
|
|
365
|
+
exactly one of them, so nothing is counted twice.
|
|
366
|
+
|
|
367
|
+
A failed check sinks the run regardless of what the model says. A check whose
|
|
368
|
+
phase was not run is `inconclusive`, never a failure.
|
|
369
|
+
|
|
370
|
+
## Deployment shapes
|
|
371
|
+
|
|
372
|
+
Dreamdata installs legitimately differ, and all of these are healthy. The shape is
|
|
373
|
+
computed from all phases at once — in code, not inferred by the model — and handed
|
|
374
|
+
to the checker as a fact, so a valid shape is not reported as a defect:
|
|
375
|
+
|
|
376
|
+
| Shape | Looks like | Cookies |
|
|
377
|
+
| --- | --- | --- |
|
|
378
|
+
| `cookieless-by-design` | Only a cookieless bundle exists anywhere | none, by design |
|
|
379
|
+
| `consent-gated-dual` | Both bundles belong to the install; consent picks | `dd_*` once granted |
|
|
380
|
+
| `normal-only` | Only the standard bundle is installed | `dd_*` |
|
|
381
|
+
| `helper-only` / `nothing-installed` | No tracking bundle at all | — a real failure |
|
|
382
|
+
| `undetermined` | Would read as `nothing-installed`/`cookieless-by-design`, but a detected consent banner could not be driven | can't tell — needs a manual click-through |
|
|
383
|
+
| `third-party-segment-undetermined` | Would read as `nothing-installed`/`helper-only`, but a REAL Segment install (`cdn.segment.com` script and/or a request to `api.segment.io`) was found instead of a Dreamdata bundle | can't tell — Segment→Dreamdata forwarding is server-side and invisible to this harness; check Segment's destination config or Dreamdata's ingestion records directly |
|
|
384
|
+
|
|
385
|
+
`deployment.segmentCompat` is orthogonal: the install exposes `window.analytics`
|
|
386
|
+
and `ajs_*` storage instead of `window.dreamdata` and `dd_*`. Also valid.
|
|
387
|
+
|
|
388
|
+
### Why "no normal bundle in the DOM" proves nothing
|
|
389
|
+
|
|
390
|
+
`cookieless-by-design` is the shape that most often gets asserted wrongly, because
|
|
391
|
+
a consent-gated or tag-manager-injected bundle is **absent from the DOM by
|
|
392
|
+
design** — exactly when the question matters. So the shape is only claimed when
|
|
393
|
+
none of these signals is found in **any** phase (`script.normalBundleSignals`
|
|
394
|
+
records whichever did):
|
|
395
|
+
|
|
396
|
+
| Signal | What it means |
|
|
397
|
+
| --- | --- |
|
|
398
|
+
| `request` | A normal-mode request was actually sent |
|
|
399
|
+
| `dom-script` | A normal bundle tag is in the DOM |
|
|
400
|
+
| `cmp-blocked-script` | The tag is there but neutralised (`type="text/plain"`, `optanon-category-*`, …) — installed, not absent |
|
|
401
|
+
| `tag-manager-container` | The page's published GTM container ships the tag, with the consent types it is gated on |
|
|
402
|
+
| `cookieless-loader-companion` | The cookieless loader checks for `#dreamdata-analytics`, i.e. it expects a normal bundle |
|
|
403
|
+
| `stub-queue` | `window.dreamdata` is the loader's stub array — calls queued for a bundle that never came |
|
|
404
|
+
|
|
405
|
+
The container scan is one HTTP fetch per container per run, cached across phases;
|
|
406
|
+
`--no-tag-manager-scan` skips it, at the cost of reading a GTM-only install as
|
|
407
|
+
absent.
|
|
408
|
+
|
|
409
|
+
Three consequences worth knowing:
|
|
410
|
+
|
|
411
|
+
- **Cookieless requests carry no `anonymousId`.** That is the point of the mode —
|
|
412
|
+
identity resolution happens server-side. Only `writeKey` is required.
|
|
413
|
+
- **`window.dreamdata` can be a stub array.** A loader snippet creates its global
|
|
414
|
+
as an array that queues calls until the bundle replaces it, so an array means
|
|
415
|
+
the bundle never arrived (`globals.dreamdataIsStubQueue`). Expected when it is
|
|
416
|
+
consent-gated; a real defect when it is not.
|
|
417
|
+
- **The two bundles are mutually exclusive, not ordered.** The cookieless snippet
|
|
418
|
+
stands down when it finds `#dreamdata-analytics` present and still
|
|
419
|
+
`type="text/javascript"` (a CMP that blocks the tag rewrites that type, which
|
|
420
|
+
is what lets cookieless take over), and the normal bundle `delete`s
|
|
421
|
+
`window['dreamdata-cl']` as soon as it initializes. So a missing cookieless
|
|
422
|
+
global in a granted phase is the handoff working, and both modes tracking *one
|
|
423
|
+
page view* is the finding — that page view counted twice, usually because the
|
|
424
|
+
cookieless snippet sits above the normal one. Scoped to a page view, not to a
|
|
425
|
+
phase: `page-view-counted-once` counts `page` requests per observed page view,
|
|
426
|
+
and it skips the entry page of a phase that answered the banner, because the
|
|
427
|
+
run loaded that URL twice — anonymous before the answer, identified after —
|
|
428
|
+
which is the consent upgrade rather than a duplicate loader. Which mode fired
|
|
429
|
+
first is a race with no correct answer, so `network.cookielessRequestSeenFirst`
|
|
430
|
+
is reported for humans but never scored.
|
|
431
|
+
|
|
432
|
+
## Headless runs advertise themselves, and sites answer differently
|
|
433
|
+
|
|
434
|
+
Headless Chrome writes `HeadlessChrome/<version>` into its User-Agent — in the
|
|
435
|
+
request header and in `navigator.userAgent` alike — and `isbot`-style checks
|
|
436
|
+
match that substring. Sites do not merely *log* such a visitor as a bot; they
|
|
437
|
+
change what they render. meteomatics.com never mounts its consent banner at
|
|
438
|
+
all for a matching UA, so every phase found no CMP, no accept control and
|
|
439
|
+
nothing to drive, and all three consent states measured the same banner-free
|
|
440
|
+
page — a page no visitor is ever served.
|
|
441
|
+
|
|
442
|
+
So a headless run renames it back to `Chrome`, which is what the same binary
|
|
443
|
+
reports when it has a window, and does it at the browser-context level so the
|
|
444
|
+
request header changes too and not just the JS-visible copy a server-side
|
|
445
|
+
check never reads. Headed runs (`--headed`) are left alone.
|
|
446
|
+
|
|
447
|
+
Worth knowing when a run looks implausible: this is one specific tell, not a
|
|
448
|
+
stealth suite (see `stealth.ts` for the rest), and a site behind stronger bot
|
|
449
|
+
management can still serve automation a different page.
|
|
450
|
+
|
|
451
|
+
## Consent-gated sites
|
|
452
|
+
|
|
453
|
+
A CMP running in automatic blocking mode (e.g. Cookiebot with
|
|
454
|
+
`data-blockingmode="auto"`) rewrites tracking tags to `type="text/plain"` before
|
|
455
|
+
the visitor answers the banner, so nothing loads and nothing is tracked. **That is
|
|
456
|
+
correct GDPR behaviour, not a broken install** — and the `clean` phase reports it
|
|
457
|
+
as such, while `grant` shows what the same page does once consent exists.
|
|
458
|
+
|
|
459
|
+
Consent state is reported with its provenance. `analytics_storage: denied` from
|
|
460
|
+
Google's own implicit default is *not* a user decision; check
|
|
461
|
+
`consent.analyticsStorageSource` before reading a denial as one.
|
|
462
|
+
|
|
463
|
+
Two failure modes worth naming, both of which need more than one state to see:
|
|
464
|
+
|
|
465
|
+
- **The CMP stores a choice Consent Mode never receives.** The banner records the
|
|
466
|
+
answer, `analytics_storage` never moves, and every gated tag stays frozen.
|
|
467
|
+
`consentAcceptance.consentSignalConfirmed` is false and `consent-mode-signal`
|
|
468
|
+
fails — distinct from a broken bundle, and fixed somewhere else entirely.
|
|
469
|
+
- **A tag manager holds the bundle back for its own reasons.** Consent is granted,
|
|
470
|
+
the Consent Mode signal lands, and the tag still never fires because its
|
|
471
|
+
trigger does not cover the page. `grant-enables-normal` fails and says so.
|
|
472
|
+
|
|
473
|
+
## First-party tracking hosts
|
|
474
|
+
|
|
475
|
+
Dreamdata-owned hosts are detected automatically, matched on the request's
|
|
476
|
+
**hostname** so a third party's `?referer=dreamdata.io` query string cannot pass
|
|
477
|
+
as an install. A first-party install on `api.s.dreamdata.io` therefore needs no
|
|
478
|
+
configuration. Only a fully custom CNAME (`track.yourdomain.com`) has to be
|
|
479
|
+
named with `--tracking-host`.
|
|
480
|
+
|
|
481
|
+
Worth knowing that a first-party install spans **three distinct hosts**, and the
|
|
482
|
+
one that matters for requests is the delivery host — not the CDN in `_cdn`:
|
|
483
|
+
|
|
484
|
+
| Role | Example on dreamdata.io |
|
|
485
|
+
| --- | --- |
|
|
486
|
+
| Loader script | `evs.s.dreamdata.io/…/CxqWbtgYml.min.js` |
|
|
487
|
+
| Bundle CDN (`analytics._cdn`) | `evs.s.dreamdata.io` |
|
|
488
|
+
| **Event delivery** | **`api.s.dreamdata.io/v1/p`** |
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
bun run src/cli.ts --url https://dreamdata.io \
|
|
492
|
+
--tracking-host api.s.dreamdata.io
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`--tracking-host` repeats and accepts comma-separated values; `DD_TRACKING_HOSTS`
|
|
496
|
+
sets it via env. The watched set is recorded in `network.trackingHosts`, so a run
|
|
497
|
+
that captured nothing shows what it was looking at.
|
|
498
|
+
|
|
499
|
+
Delivery paths are `/api/v1/<letter>` on the shared CDN and `/v1/<letter>` on
|
|
500
|
+
first-party hosts; both are recognised.
|
|
501
|
+
|
|
502
|
+
## Manual verification
|
|
503
|
+
|
|
504
|
+
No fake fixture site is used. Sanity-check against a site you can inspect in
|
|
505
|
+
DevTools, and compare field by field:
|
|
506
|
+
|
|
507
|
+
| Evidence field | DevTools check |
|
|
508
|
+
| --- | --- |
|
|
509
|
+
| `script.present` / `writeKey` | `document.querySelectorAll('script[src*="dreamdata"], script[src*="drda"]')` |
|
|
510
|
+
| `network.requests` | Network tab filtered on the host in `network.trackingHosts` |
|
|
511
|
+
| `globals.*` | `window.dreamdata`, `window['dreamdata-cl']`, `window.analytics` |
|
|
512
|
+
| `cookies.cookieNames` | Application → Cookies, filter `dd_` (or `ajs_`) — the report lists which are set, `verification/evidence/` has their values |
|
|
513
|
+
| `consent.*` | `window.google_tag_data.ics.entries.analytics_storage` |
|
|
514
|
+
| `secondPage.*` | Click an internal link yourself and watch the Network tab; `secondPage.kind` should match whether the tab reloaded |
|
|
515
|
+
|
|
516
|
+
To diff raw values field by field, read the run's
|
|
517
|
+
`verification/evidence/<slug>_evidence.json` — that is the untrimmed copy, and
|
|
518
|
+
it is already on disk from the run you just did.
|
|
519
|
+
|
|
520
|
+
## What the verdicts are, and are not
|
|
521
|
+
|
|
522
|
+
Evidence collection, the deployment shape and the cross-phase consent checks are
|
|
523
|
+
deterministic code. The **checklist verdicts are an LLM reading that evidence**,
|
|
524
|
+
so their wording varies between runs on the same page; the model is told the shape
|
|
525
|
+
and the resolved checks as facts rather than being asked to derive them, because a
|
|
526
|
+
single misread boolean used to turn every consent check into "not applicable" and
|
|
527
|
+
the report into a green PASS.
|
|
528
|
+
|
|
529
|
+
`overallPass` is not taken from the model either. It is derived from the per-item
|
|
530
|
+
verdicts — any `false` sinks the run, `not_applicable` and `inconclusive` do not,
|
|
531
|
+
and an all-inconclusive run is not a pass — and then a failed cross-phase check
|
|
532
|
+
sinks it regardless. The process exit code follows it.
|
|
533
|
+
|
|
534
|
+
Each cross-phase check also carries `phases`: the consent states its status
|
|
535
|
+
actually rests on, and for a **failure** only the states that actually failed. A
|
|
536
|
+
continuity check that spans three phases and broke in one says so in one field
|
|
537
|
+
rather than only in its prose, so anything drawing a per-state view — the review
|
|
538
|
+
index's matrix — can attribute the failure instead of painting all three red.
|
|
539
|
+
|
|
540
|
+
## Driving the CLI from an MCP client
|
|
541
|
+
|
|
542
|
+
The CLI also runs as an [MCP](https://modelcontextprotocol.io) server, so an
|
|
543
|
+
agent can run a verification and then read the result:
|
|
544
|
+
|
|
545
|
+
```bash
|
|
546
|
+
bun run src/mcp/server.ts
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
| Tool | What it does |
|
|
550
|
+
| --- | --- |
|
|
551
|
+
| `verify_tracking` | Runs the full three-state check and writes `verification/reports/<slug>_report.json`. Returns a summary and that path — never the report itself, which runs 40–50KB. |
|
|
552
|
+
| `list_reports` | Every saved report, newest first, with its shape and verdict. |
|
|
553
|
+
| `read_report` | One report by slug, or one field of it (`deployment.checks`, `phases[0].script`). |
|
|
554
|
+
| `explain_verdict` | One checklist item, with the field its `evidenceQuote` names resolved — so a quote pointing at a field that does not exist shows up as `resolved: false` — plus the cross-phase consent checks. |
|
|
555
|
+
|
|
556
|
+
The backend is read from `.env` (or the environment), never from a tool
|
|
557
|
+
argument: a tool call is model-authored text, and a call that could name its
|
|
558
|
+
own `--base-url` could send a run's whole evidence payload to a host of the
|
|
559
|
+
model's choosing.
|
|
560
|
+
|
|
561
|
+
### Pairing it with chrome-devtools-mcp
|
|
562
|
+
|
|
563
|
+
The reason to run this over MCP is having both servers in one agent —
|
|
564
|
+
[`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp)
|
|
565
|
+
for looking, this one for measuring:
|
|
566
|
+
|
|
567
|
+
```jsonc
|
|
568
|
+
{
|
|
569
|
+
"mcpServers": {
|
|
570
|
+
"verify-tracking": {
|
|
571
|
+
"command": "bun",
|
|
572
|
+
"args": ["run", "src/mcp/server.ts"],
|
|
573
|
+
"cwd": "/path/to/tracking-verification-cli",
|
|
574
|
+
// A full run is minutes, not seconds — see "One run is a long tool call" below.
|
|
575
|
+
"timeout": 900000
|
|
576
|
+
},
|
|
577
|
+
"chrome-devtools": { "command": "npx", "args": ["chrome-devtools-mcp@latest"] }
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Goose, Cline and mcphost all take this shape; only the file it goes in
|
|
583
|
+
differs. Ollama and LM Studio are model runtimes, not MCP clients — a local
|
|
584
|
+
model reaches these tools through a client like those, not on its own.
|
|
585
|
+
|
|
586
|
+
For Goose specifically, `verification/goose-config.example.yaml` is that block
|
|
587
|
+
already filled in, and `.goosehints` in the repo root is what the agent is told
|
|
588
|
+
about the two servers. That file is not optional decoration: without it a model
|
|
589
|
+
will re-check a consent verdict by driving one browser tab through accept and
|
|
590
|
+
then reject, which measures a page still carrying the previous answer's cookie.
|
|
591
|
+
Whatever client you use, give it those instructions in whatever form it reads.
|
|
592
|
+
|
|
593
|
+
Two things to know before pointing a local model at an agent loop:
|
|
594
|
+
|
|
595
|
+
- **Tool calling is required.** `ollama show <model>` must list `tools` under
|
|
596
|
+
Capabilities. `qwen2.5-coder:14b` does.
|
|
597
|
+
- **An agent loop spends context that single-shot scoring does not.** The
|
|
598
|
+
Modelfile pins `num_ctx 65536`, but qwen2.5-coder's trained context is 32768
|
|
599
|
+
— fine for one ~16k-token scoring prompt, less so for a conversation
|
|
600
|
+
accumulating tool results. This is why the tools return summaries and a field
|
|
601
|
+
path rather than reports: `read_report` without a `path` will spend 40-50KB of
|
|
602
|
+
that budget in one call.
|
|
603
|
+
|
|
604
|
+
A run then looks like: `verify_tracking` on the URL → read the failing item
|
|
605
|
+
with `explain_verdict` → `navigate_page` and `list_network_requests` in
|
|
606
|
+
DevTools to look at the actual request that item is about.
|
|
607
|
+
|
|
608
|
+
### One run is a long tool call
|
|
609
|
+
|
|
610
|
+
`verify_tracking` takes minutes: three consent states at roughly 15–20s of
|
|
611
|
+
page loading each, and then a model reading a ~16k-token prompt — which on a
|
|
612
|
+
local 14B model measured 148s for a single-phase run, and grows with the
|
|
613
|
+
phases. Most clients time a tool call out well before that.
|
|
614
|
+
|
|
615
|
+
The server sends a progress notification every 10 seconds while a run is going,
|
|
616
|
+
naming the step it is on. That is enough for a client that resets its timeout
|
|
617
|
+
on progress — in the MCP TypeScript SDK that is `resetTimeoutOnProgress`, which
|
|
618
|
+
is **off** by default, so a client has to opt in.
|
|
619
|
+
|
|
620
|
+
**Claude Code's timeout is not one of them**: its per-server `timeout` (and the
|
|
621
|
+
`MCP_TOOL_TIMEOUT` environment variable it overrides) is a hard wall-clock
|
|
622
|
+
limit per tool call that progress notifications do not extend. Set it to cover
|
|
623
|
+
a whole run — the `"timeout": 900000` above is 15 minutes — rather than relying
|
|
624
|
+
on the heartbeat.
|
|
625
|
+
|
|
626
|
+
**Measure with this server; look with DevTools — not the other way round.** An
|
|
627
|
+
agent that "re-checks" a consent verdict by driving one browser tab through
|
|
628
|
+
accept and then reject is measuring a page that still carries the previous
|
|
629
|
+
answer's cookie. Every consent verdict here is a comparison between three
|
|
630
|
+
states collected in three *fresh* contexts (see
|
|
631
|
+
[The three consent states](#the-three-consent-states)); a shared tab cannot
|
|
632
|
+
reproduce it, and what it produces instead is a confident contradiction of a
|
|
633
|
+
correct report.
|
|
634
|
+
|
|
635
|
+
## Verification review index
|
|
636
|
+
|
|
637
|
+
`verification/review/index.html` gives a browsable view of every report in
|
|
638
|
+
`verification/reports/`. One `<slug>_report.json` there is one account on the
|
|
639
|
+
page; nothing else is aggregated, so whatever `cli.ts` has written is exactly
|
|
640
|
+
what the dashboard shows.
|
|
641
|
+
|
|
642
|
+
It is built in three levels, so the first screen is scannable and everything
|
|
643
|
+
that needs reading is one click away:
|
|
644
|
+
|
|
645
|
+
| Level | Shows |
|
|
646
|
+
| --- | --- |
|
|
647
|
+
| **The list** | One line per account: slug, overall PASS/FAIL, the check tally (`✕` fail / `?` inconclusive / `✓` pass), and a 3×5 thumbnail of that account's matrix. |
|
|
648
|
+
| **The card** | The **matrix** — three consent states down the side, five signals (`script present`, `event on page load`, `event on page nav`, `identity storage`, `consent mode`) across the top, one verdict per cell — then every cross-phase check as a single line, worst first. |
|
|
649
|
+
| **The reason** | A cell opens the raw values it was computed from plus the check that judged it; a check line opens its own detail and evidence. *Everything measured* holds the complete per-phase evidence JSON, the deployment analysis and the model's raw response — `build.ts` reads those from `verification/evidence/` when the run archived them, so nothing is summarised away here even though the report file itself is trimmed. |
|
|
650
|
+
|
|
651
|
+
Cell colour is never re-derived in the page. Every consent comparison is already
|
|
652
|
+
resolved in `src/analysis/deployment.ts` and shipped as `deployment.checks`; a
|
|
653
|
+
cell takes the worst status of the checks that name its phase, and `script
|
|
654
|
+
present` is left uncoloured because a tag's presence is a fact about the install
|
|
655
|
+
(a consent-gated tag is absent by design exactly when the question matters), not
|
|
656
|
+
a verdict. A **dashed** cell is one whose colour is genuinely shared by more than
|
|
657
|
+
one consent state.
|
|
658
|
+
|
|
659
|
+
Rebuild its data after any sweep run:
|
|
660
|
+
|
|
661
|
+
```bash
|
|
662
|
+
bun run verification/review/build.ts
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
This writes `verification/review/data.js`, which `index.html` loads directly
|
|
666
|
+
via `<script src="data.js">` (avoiding `fetch()`/CORS restrictions when opened
|
|
667
|
+
via `file://`). Re-run it any time a report under `verification/reports/`
|
|
668
|
+
changes, then open `index.html` in a browser.
|
|
669
|
+
|
|
670
|
+
## Known limitations
|
|
671
|
+
|
|
672
|
+
- Each phase covers two page views — the load, then one followed link — plus a
|
|
673
|
+
document load of the second page when the walk was soft. Form submissions are
|
|
674
|
+
still never driven, so the `event-behavior` item stays inconclusive on that
|
|
675
|
+
sub-point. Only one link is followed, and only from the entry page, so a site
|
|
676
|
+
that breaks on the third page or on one particular section will not be caught.
|
|
677
|
+
- A page view whose two loaders race is only sometimes doubled, so one
|
|
678
|
+
observation of a single page event does not clear a site. On meteomatics.com
|
|
679
|
+
the same URL sent one request on 5 of 6 cold-cache loads and two on 6 of 8
|
|
680
|
+
warm-cache ones — a tag manager injecting the normal bundle either side of
|
|
681
|
+
the moment the cookieless snippet looks for it. A run sees one load per
|
|
682
|
+
phase and can therefore miss it; a pass on this check is weaker evidence
|
|
683
|
+
than a failure.
|
|
684
|
+
- The entry page of a phase that answered the banner is never judged on
|
|
685
|
+
double-counting unless its page events name two different builds. Two loads
|
|
686
|
+
of one URL either side of a consent answer are two runs of one install, and
|
|
687
|
+
the ordering of the two bundles cannot separate that from a real duplicate:
|
|
688
|
+
on paytronix.com the normal event the accept click produced is stamped
|
|
689
|
+
mid-reload and so arrives *before* the reloaded page's cookieless one,
|
|
690
|
+
which looks exactly like the race but is not. The extra document load above
|
|
691
|
+
exists because inference on that page view does not work.
|
|
692
|
+
- The second-page probe picks the link itself. A site whose entry page has no
|
|
693
|
+
followable same-origin link, or whose link opens a modal instead of
|
|
694
|
+
navigating, records `urlChanged: false` and leaves the three continuity checks
|
|
695
|
+
inconclusive rather than failed.
|
|
696
|
+
- A known CMP is driven via its vendor API or a vendor-specific selector; add the
|
|
697
|
+
vendor to `CMP_VENDORS`/`DOM_ONLY_CMP_VENDORS` and the selector lists in
|
|
698
|
+
`consent-accept.ts` to cover a new one. An unrecognised CMP falls back to a
|
|
699
|
+
heuristic: a clickable control whose exact text matches a curated accept/reject
|
|
700
|
+
phrase, inside a container that both mentions cookies/consent/privacy and holds
|
|
701
|
+
the opposite-direction control too (`heuristic-banner.ts`) — this is what stops
|
|
702
|
+
an unrelated page button (e.g. a signup form's "Accept") from being mistaken for
|
|
703
|
+
a consent banner.
|
|
704
|
+
- A banner with no reject control anywhere is still *detected*: the heuristic
|
|
705
|
+
proves a container is a consent banner by finding a second consent control
|
|
706
|
+
next to the one it matched, and where there is no opposite-direction control
|
|
707
|
+
a cookie-explicit settings link plays that part
|
|
708
|
+
(`PREFERENCE_CENTER_COUNTERPART_PHRASES` — the vaguer "settings"/
|
|
709
|
+
"preferences" openers are excluded, since they sit in too many page footers
|
|
710
|
+
to prove anything). Requiring the opposite control outright made those
|
|
711
|
+
banners invisible, which in turn meant the preference-centre flow written to
|
|
712
|
+
answer them was never reached.
|
|
713
|
+
- Banners with no one-click answer in the direction we want — the common
|
|
714
|
+
"Accept all" + "Cookie settings" pair, with no reject button anywhere — are
|
|
715
|
+
answered through their preference centre instead (`preference-center.ts`):
|
|
716
|
+
open the panel, set every non-essential category toggle to the target state,
|
|
717
|
+
then click the panel's own save control. The save search deliberately excludes
|
|
718
|
+
accept-all/reject-all shortcuts, which would discard the toggles, and both the
|
|
719
|
+
open and save searches are scoped the same way the accept/reject heuristic is —
|
|
720
|
+
the settings link must sit in a cookie-context container that also holds a real
|
|
721
|
+
accept/reject control, and the save control must sit around the category
|
|
722
|
+
toggles themselves. Always-on essential categories (matched by
|
|
723
|
+
`ESSENTIAL_CATEGORY_PATTERN`, or simply disabled) are left alone. Add a vendor
|
|
724
|
+
to `PREFERENCE_CENTER_OPEN_SELECTORS`/`PREFERENCE_CENTER_SAVE_SELECTORS` to
|
|
725
|
+
drive its panel by element rather than by button text. The category-toggle
|
|
726
|
+
search is scoped more tightly than the banner search is: a container only
|
|
727
|
+
counts as the panel when it mentions cookies or consent — not merely
|
|
728
|
+
"privacy", which every marketing form's terms checkbox does — and is small
|
|
729
|
+
enough to be a panel rather than the page (`PREFERENCE_PANEL_*`). Without
|
|
730
|
+
both, an unrelated form's checkboxes join the toggle set, the panel resolves
|
|
731
|
+
to the whole document, and the save-button search is scoped to it.
|
|
732
|
+
- When none of the above finds or clicks a control, the
|
|
733
|
+
`grant`/`reject` drive is recorded as not-succeeded; if a CMP was detected but
|
|
734
|
+
every attempted drive failed, a would-be `nothing-installed`/`cookieless-by-design`
|
|
735
|
+
shape is downgraded to `undetermined` instead of asserted, and the affected
|
|
736
|
+
checks read `inconclusive` rather than failed — honest, but not a verdict.
|
|
737
|
+
- The GTM consent gate is read out of a minified container by looking backwards
|
|
738
|
+
from the bundle filename for the nearest `"consent":["list",…]`. It is reported
|
|
739
|
+
as a corroborating signal, never as the sole basis for a verdict.
|
|
740
|
+
- Debug mode (`dreamdata.debug(true)`) is never actually enabled.
|
|
741
|
+
- `console.errors`/`warnings` are filtered to messages mentioning "dreamdata", so
|
|
742
|
+
an empty list means no Dreamdata-specific error, not an error-free page.
|
|
743
|
+
- Anonymous-id stability is only partly observable: the id on the first page and
|
|
744
|
+
the one in the same phase's `secondPage` can be compared, but no check asserts
|
|
745
|
+
on it — the model reports it, and a drift there is worth a manual look. The
|
|
746
|
+
report itself lists only which entries were set, so read the values out of
|
|
747
|
+
`verification/evidence/<slug>_evidence.json`.
|
|
748
|
+
- A site that reassigns `window.dataLayer` after the hook installs is covered —
|
|
749
|
+
the accessor re-wraps whatever is assigned — but a snippet holding a direct
|
|
750
|
+
reference to the array from before the hook can push past the wrapper.
|
|
751
|
+
- A bespoke banner's decision is only seen to persist when it lands in a cookie
|
|
752
|
+
or localStorage entry that is either new or *named* like a consent record (a
|
|
753
|
+
vendor prefix, or a name containing "consent"/"cookie settings" and friends).
|
|
754
|
+
A banner that records its answer under an unrecognisable name it also wrote
|
|
755
|
+
on load reads as `not-persisted`, and the phase is reported as not driven
|
|
756
|
+
even though the click worked.
|