@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
|
@@ -0,0 +1,2658 @@
|
|
|
1
|
+
import { COOKIELESS_GLOBAL_KEY, DD_COOKIE_NAMES, describesFailure, isDreamdataUrl, mentionsDreamdata, SEGMENT_COMPAT_GLOBAL_KEY, } from "../evidence/constants.js";
|
|
2
|
+
import { extractLibraries, extractMessageId, formatLibraryBuild } from "../evidence/network.js";
|
|
3
|
+
import { judgeConsentModeV2, sawGoogleTag } from "./consent-mode.js";
|
|
4
|
+
import { diagnose } from "./diagnosis/diagnose.js";
|
|
5
|
+
function storedIdentityNames(storage) {
|
|
6
|
+
// Older evidence (collected before per-value capture existed) has no
|
|
7
|
+
// `values`/`ajsValues` at all — treat that the same as "nothing stored"
|
|
8
|
+
// rather than crashing replay of that evidence.
|
|
9
|
+
const dd = Object.entries(storage.values ?? {});
|
|
10
|
+
const ajs = Object.entries(storage.ajsValues ?? {});
|
|
11
|
+
return [...dd, ...ajs].filter(([, value]) => value !== null && value !== "").map(([name]) => name);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The stored identity names, each said once, with where it lives.
|
|
15
|
+
*
|
|
16
|
+
* An SDK writes its anonymous id to BOTH a cookie and localStorage, so
|
|
17
|
+
* concatenating the two lists printed "dd_anonymous_id, dd_anonymous_id" —
|
|
18
|
+
* which reads as a bug in the report rather than as one entry in two places,
|
|
19
|
+
* and told the reader nothing about which store to go and clear.
|
|
20
|
+
*/
|
|
21
|
+
function describeIdentityStores(cookies, localStorage) {
|
|
22
|
+
const stores = new Map();
|
|
23
|
+
for (const name of cookies)
|
|
24
|
+
stores.set(name, [...(stores.get(name) ?? []), "cookie"]);
|
|
25
|
+
for (const name of localStorage)
|
|
26
|
+
stores.set(name, [...(stores.get(name) ?? []), "localStorage"]);
|
|
27
|
+
return [...stores].map(([name, where]) => `${name} (${where.join(" + ")})`).join(", ");
|
|
28
|
+
}
|
|
29
|
+
function identityCookiesSet(phase) {
|
|
30
|
+
return storedIdentityNames(phase.cookies);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* True when a second page was actually reached. `attempted` alone is not
|
|
34
|
+
* enough: a click that changed nothing leaves a phase with no second page to
|
|
35
|
+
* judge, and treating that as observed would turn "we could not look" into a
|
|
36
|
+
* failure.
|
|
37
|
+
*/
|
|
38
|
+
function secondPageObserved(second) {
|
|
39
|
+
return second !== null && second.attempted && second.urlChanged;
|
|
40
|
+
}
|
|
41
|
+
function summarizeSecondPage(second) {
|
|
42
|
+
if (second === null)
|
|
43
|
+
return null;
|
|
44
|
+
return {
|
|
45
|
+
kind: second.kind,
|
|
46
|
+
observed: secondPageObserved(second),
|
|
47
|
+
toUrl: second.toUrl,
|
|
48
|
+
skippedReason: second.skippedReason,
|
|
49
|
+
normalRequests: second.network.normalRequestCount,
|
|
50
|
+
cookielessRequests: second.network.cookielessRequestCount,
|
|
51
|
+
identityCookiesSet: storedIdentityNames(second.cookies),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function summarizePhase(phase) {
|
|
55
|
+
return {
|
|
56
|
+
phase: phase.phase,
|
|
57
|
+
consentDriven: phase.consentAcceptance.attempted && phase.consentAcceptance.succeeded,
|
|
58
|
+
consentDriveDetail: phase.consentAcceptance.detail,
|
|
59
|
+
consentUndrivenReason: phase.consentAcceptance.undrivenReason,
|
|
60
|
+
consentSignalConfirmed: phase.consentAcceptance.consentSignalConfirmed,
|
|
61
|
+
cmpDetected: phase.consent.cmpDetected,
|
|
62
|
+
bannerReveal: phase.consent.bannerReveal,
|
|
63
|
+
normalRequests: phase.network.normalRequestCount,
|
|
64
|
+
cookielessRequests: phase.network.cookielessRequestCount,
|
|
65
|
+
identityCookiesSet: identityCookiesSet(phase),
|
|
66
|
+
analyticsStorage: phase.consent.analyticsStorageState ?? "unreadable",
|
|
67
|
+
analyticsStorageSource: phase.consent.analyticsStorageSource ?? "none",
|
|
68
|
+
cmpHasResponse: phase.consent.cmpHasResponse,
|
|
69
|
+
normalBundleInDom: phase.script.normalSnippet,
|
|
70
|
+
cookielessBundleInDom: phase.script.cookielessSnippet,
|
|
71
|
+
secondPage: summarizeSecondPage(phase.secondPage),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const NOT_RUN = (id, title, phase) => ({
|
|
75
|
+
id,
|
|
76
|
+
title,
|
|
77
|
+
status: "inconclusive",
|
|
78
|
+
detail: `The ${phase} phase was not run, so this could not be checked.`,
|
|
79
|
+
evidence: `phases collected: none named "${phase}"`,
|
|
80
|
+
phases: [phase],
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* How to describe a phase whose consent drive was not certified.
|
|
84
|
+
*
|
|
85
|
+
* The three ways a drive falls short are not the same finding, and reporting
|
|
86
|
+
* them all as "could not be driven" misreads two of them. Only
|
|
87
|
+
* "not-submitted" and "did-not-survive-reload" mean the numbers under the
|
|
88
|
+
* phase's label were measured on a page with the banner still unanswered.
|
|
89
|
+
* "not-persisted" means the answer WAS submitted and the page WAS reloaded
|
|
90
|
+
* afterwards — the numbers describe the answered page; what is missing is
|
|
91
|
+
* only our evidence that the CMP filed the answer away, which some CMPs
|
|
92
|
+
* simply do not expose. Telling an operator their refusal was never driven,
|
|
93
|
+
* when the refusal demonstrably moved Consent Mode, sends them to debug the
|
|
94
|
+
* wrong thing.
|
|
95
|
+
*/
|
|
96
|
+
function undrivenDetail(acceptance) {
|
|
97
|
+
const label = acceptance.direction === "deny" ? "refusal" : "grant";
|
|
98
|
+
const asked = acceptance.direction === "deny" ? "told no" : "asked to turn tracking on";
|
|
99
|
+
const trace = acceptance.detail ? ` (${acceptance.detail})` : "";
|
|
100
|
+
switch (acceptance.undrivenReason) {
|
|
101
|
+
case "not-persisted":
|
|
102
|
+
return (`The ${label} was submitted${trace} and the page was reloaded afterwards, so the values below were measured after the decision — ` +
|
|
103
|
+
`but the CMP was never seen to record it, so this pass cannot be certified as a real ${label}.` +
|
|
104
|
+
(acceptance.consentSignalConfirmed === true
|
|
105
|
+
? " Consent Mode did move to the expected value, which is independent evidence the decision landed; the gap is in what this harness can read back from the CMP, not necessarily in the site."
|
|
106
|
+
: ""));
|
|
107
|
+
case "did-not-survive-reload":
|
|
108
|
+
return (`The ${label} was submitted${trace} but the reloaded page reported the banner unanswered again, ` +
|
|
109
|
+
`so the values below describe a visit with the banner still open, under a ${label} label.`);
|
|
110
|
+
default:
|
|
111
|
+
return (`The ${label} could not be driven${trace}, so the site was never actually ${asked} — ` +
|
|
112
|
+
`the values below describe a visit with the banner still unanswered.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Is the missing cookieless bundle a configuration choice this run can SEE,
|
|
117
|
+
* rather than an absence it merely failed to observe?
|
|
118
|
+
*
|
|
119
|
+
* The difference is the published tag-manager container. It is the
|
|
120
|
+
* deployment's own manifest — every tag, whatever its trigger, including the
|
|
121
|
+
* ones no crawl would ever make fire — so a container that was read in full
|
|
122
|
+
* and ships the standard bundle's tag and no cookieless one is the install
|
|
123
|
+
* stating what it is: consent-gated, standard-only, unconsented visitors
|
|
124
|
+
* deliberately unrecorded. beckman.com is exactly that shape, and reporting
|
|
125
|
+
* it as a failure told a customer whose GTM container does precisely what
|
|
126
|
+
* they built it to do that their install is broken.
|
|
127
|
+
*
|
|
128
|
+
* A snippet install offers nothing equivalent: "no cookieless tag in the DOM"
|
|
129
|
+
* is an observation about the pages this run happened to load, so the absence
|
|
130
|
+
* stays a finding there. Requiring `errors` to be empty matters for the same
|
|
131
|
+
* reason — a container that half-parsed could be hiding the tag in the part
|
|
132
|
+
* that did not.
|
|
133
|
+
*/
|
|
134
|
+
function cookielessAbsentByDesign(phases) {
|
|
135
|
+
const scan = phases
|
|
136
|
+
.map((p) => p.script.tagManager)
|
|
137
|
+
.find((tm) => tm.scanned && tm.errors.length === 0 && tm.normalBundleTagFound && !tm.cookielessBundleTagFound);
|
|
138
|
+
if (!scan)
|
|
139
|
+
return null;
|
|
140
|
+
const containers = scan.containerIds.length > 0 ? `container ${scan.containerIds.join(", ")}` : "the container";
|
|
141
|
+
return {
|
|
142
|
+
containers,
|
|
143
|
+
evidence: `script.tagManager: scanned, containerIds [${scan.containerIds.join(", ")}], normalBundleTagFound: true, cookielessBundleTagFound: false, errors: []`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The checks that only exist across phases. Each one is a comparison — "did
|
|
148
|
+
* behaviour change when the answer changed?" — which is precisely what a
|
|
149
|
+
* single-state run can never answer, because a correctly gated bundle and a
|
|
150
|
+
* broken one both look like silence.
|
|
151
|
+
*/
|
|
152
|
+
function buildChecks(byPhase, facts) {
|
|
153
|
+
const checks = [];
|
|
154
|
+
const clean = byPhase.get("clean");
|
|
155
|
+
const grant = byPhase.get("grant");
|
|
156
|
+
const reject = byPhase.get("reject");
|
|
157
|
+
// Read here rather than threaded through `facts`, which is deliberately a
|
|
158
|
+
// Pick of three fields: the container scan does not change between phases,
|
|
159
|
+
// so the longest read is the one whose fetch completed.
|
|
160
|
+
const cookielessGateContainers = [...byPhase.values()]
|
|
161
|
+
.map((p) => p.script.tagManager.containers ?? [])
|
|
162
|
+
.reduce((best, current) => (current.length > best.length ? current : best), []);
|
|
163
|
+
// 1. Nothing identified may fire before the visitor has answered.
|
|
164
|
+
if (!clean) {
|
|
165
|
+
checks.push(NOT_RUN("clean-before-consent", "No identified tracking before consent", "clean"));
|
|
166
|
+
}
|
|
167
|
+
else if (!clean.consent.cmpDetected || clean.consent.cmpHasResponse === true) {
|
|
168
|
+
checks.push({
|
|
169
|
+
id: "clean-before-consent",
|
|
170
|
+
title: "No identified tracking before consent",
|
|
171
|
+
status: "not_applicable",
|
|
172
|
+
detail: clean.consent.cmpDetected
|
|
173
|
+
? "The banner was already answered on first load (an auto-accept or a carried-over decision), so this load is not a pre-consent one."
|
|
174
|
+
: "No consent platform was detected, so there is no gate for tracking to precede.",
|
|
175
|
+
evidence: `clean.consent.cmpDetected: ${clean.consent.cmpDetected}, clean.consent.cmpHasResponse: ${clean.consent.cmpHasResponse}`,
|
|
176
|
+
phases: ["clean"],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const cookies = identityCookiesSet(clean);
|
|
181
|
+
const bad = clean.network.normalRequestCount > 0 || cookies.length > 0;
|
|
182
|
+
checks.push({
|
|
183
|
+
id: "clean-before-consent",
|
|
184
|
+
title: "No identified tracking before consent",
|
|
185
|
+
status: bad ? "fail" : "pass",
|
|
186
|
+
detail: bad
|
|
187
|
+
? `The banner was unanswered, but identified tracking already ran: ${clean.network.normalRequestCount} normal request(s)${cookies.length > 0 ? ` and identity cookies ${cookies.join(", ")}` : ""}.`
|
|
188
|
+
: "With the banner unanswered, no normal-bundle request fired and no identity cookie was written.",
|
|
189
|
+
evidence: `clean.network.normalRequestCount: ${clean.network.normalRequestCount}, clean identity cookies: [${cookies.join(", ")}]`,
|
|
190
|
+
phases: ["clean"],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
// 1b. Before any decision, Consent Mode has to default to denied. Unlike
|
|
194
|
+
// the driven grant/reject phases, there is no choice for the clean
|
|
195
|
+
// phase to "carry" — nothing has been declared yet, so
|
|
196
|
+
// analyticsStorageSource reading implicit-default here is the expected,
|
|
197
|
+
// correct shape of a working install, not a coincidence to be
|
|
198
|
+
// suspicious of.
|
|
199
|
+
//
|
|
200
|
+
// A default of granted is a pass, not a finding, when the run can show
|
|
201
|
+
// something ELSE held the line: abcam declares analytics_storage
|
|
202
|
+
// granted before the banner is answered, and the normal bundle still
|
|
203
|
+
// never fires, because its GTM tag carries a blocking trigger on a
|
|
204
|
+
// OneTrust group. The observed behaviour is correct in all three
|
|
205
|
+
// phases, and this reader only reports what a run actually did — that
|
|
206
|
+
// the surviving layer is a single point of failure is a future
|
|
207
|
+
// configuration risk, not a defect in this install, so it is stated in
|
|
208
|
+
// the detail and costs the check nothing.
|
|
209
|
+
if (!clean) {
|
|
210
|
+
checks.push(NOT_RUN("clean-consent-mode-default", "Consent Mode defaults to denied before consent", "clean"));
|
|
211
|
+
}
|
|
212
|
+
else if (!clean.consent.cmpDetected) {
|
|
213
|
+
checks.push({
|
|
214
|
+
id: "clean-consent-mode-default",
|
|
215
|
+
title: "Consent Mode defaults to denied before consent",
|
|
216
|
+
status: "not_applicable",
|
|
217
|
+
detail: "No consent platform was detected, so there is no Consent Mode default to check.",
|
|
218
|
+
evidence: "clean.consent.cmpDetected: false",
|
|
219
|
+
phases: ["clean"],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else if (clean.consent.analyticsStorageState === null) {
|
|
223
|
+
checks.push({
|
|
224
|
+
id: "clean-consent-mode-default",
|
|
225
|
+
title: "Consent Mode defaults to denied before consent",
|
|
226
|
+
status: "not_applicable",
|
|
227
|
+
detail: "The page exposes no Google Consent Mode state at all (no gtag.js), so it cannot be held to a default.",
|
|
228
|
+
evidence: "clean.consent.analyticsStorageState: null",
|
|
229
|
+
phases: ["clean"],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
const denied = clean.consent.analyticsStorageState === "denied";
|
|
234
|
+
const blockingTriggers = clean.script.tagManager.normalBundleBlockingTriggers ?? [];
|
|
235
|
+
// "Nothing identified ran" is checked here rather than taken from
|
|
236
|
+
// check 1a, so the pass rests on this phase's own evidence.
|
|
237
|
+
const cleanPhaseHeldUp = clean.network.normalRequestCount === 0 && identityCookiesSet(clean).length === 0;
|
|
238
|
+
const heldBySomethingElse = !denied && blockingTriggers.length > 0 && cleanPhaseHeldUp;
|
|
239
|
+
const gateList = blockingTriggers.join("; or ");
|
|
240
|
+
checks.push({
|
|
241
|
+
id: "clean-consent-mode-default",
|
|
242
|
+
title: "Consent Mode defaults to denied before consent",
|
|
243
|
+
status: denied || heldBySomethingElse ? "pass" : "fail",
|
|
244
|
+
detail: denied
|
|
245
|
+
? `Before the banner was answered, analytics_storage already read denied (${clean.consent.analyticsStorageSource ?? "no source"}) — this is expected even when the source is gtag.js's own implicit default, since nothing has been declared yet.`
|
|
246
|
+
: heldBySomethingElse
|
|
247
|
+
? `Before the banner was answered, analytics_storage already read ${clean.consent.analyticsStorageState} (${clean.consent.analyticsStorageSource ?? "no source"}), so Consent Mode is not what keeps identified tracking off an unanswered page here. What keeps it off is a blocking trigger on the normal bundle's own tag manager tag: it is blocked whenever ${gateList}. That held in this run — the clean phase sent no normal request and wrote no identity cookie — so nothing identified ran before the visitor answered.`
|
|
248
|
+
: `Before the banner was answered, analytics_storage already read ${clean.consent.analyticsStorageState} (${clean.consent.analyticsStorageSource ?? "no source"}) — tracking is not actually gated on the decision.`,
|
|
249
|
+
evidence: `clean.consent.analyticsStorageState: ${clean.consent.analyticsStorageState}, clean.consent.analyticsStorageSource: ${clean.consent.analyticsStorageSource ?? "none"}` +
|
|
250
|
+
(heldBySomethingElse
|
|
251
|
+
? `, clean.script.tagManager.normalBundleBlockingTriggers: ${gateList}, clean.network.normalRequestCount: 0`
|
|
252
|
+
: ""),
|
|
253
|
+
phases: ["clean"],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// 2. Granting consent has to actually turn identified tracking on.
|
|
257
|
+
if (!grant) {
|
|
258
|
+
checks.push(NOT_RUN("grant-enables-normal", "Granting consent enables the normal bundle", "grant"));
|
|
259
|
+
}
|
|
260
|
+
else if (!facts.normalBundleConfigured) {
|
|
261
|
+
checks.push({
|
|
262
|
+
id: "grant-enables-normal",
|
|
263
|
+
title: "Granting consent enables the normal bundle",
|
|
264
|
+
status: "not_applicable",
|
|
265
|
+
detail: "No normal bundle is part of this deployment, so there is nothing for a grant to enable.",
|
|
266
|
+
evidence: "script.normalBundleSignals: []",
|
|
267
|
+
phases: ["grant"],
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
else if (!grant.consentAcceptance.succeeded && !grant.consent.cmpDetected) {
|
|
271
|
+
checks.push({
|
|
272
|
+
id: "grant-enables-normal",
|
|
273
|
+
title: "Granting consent enables the normal bundle",
|
|
274
|
+
status: "not_applicable",
|
|
275
|
+
detail: "No consent platform was detected, so there is no gate for a grant to enable — whatever the normal bundle does here is not evidence of a working consent gate.",
|
|
276
|
+
evidence: "grant.consent.cmpDetected: false",
|
|
277
|
+
phases: ["grant"],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
else if (!grant.consentAcceptance.succeeded) {
|
|
281
|
+
checks.push({
|
|
282
|
+
id: "grant-enables-normal",
|
|
283
|
+
title: "Granting consent enables the normal bundle",
|
|
284
|
+
status: "inconclusive",
|
|
285
|
+
detail: undrivenDetail(grant.consentAcceptance),
|
|
286
|
+
evidence: `grant.consentAcceptance.succeeded: false, undrivenReason: ${grant.consentAcceptance.undrivenReason ?? "none"}`,
|
|
287
|
+
phases: ["grant"],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
else if (grant.network.normalRequestCount > 0) {
|
|
291
|
+
checks.push({
|
|
292
|
+
id: "grant-enables-normal",
|
|
293
|
+
title: "Granting consent enables the normal bundle",
|
|
294
|
+
status: "pass",
|
|
295
|
+
detail: `After the grant, the normal bundle sent ${grant.network.normalRequestCount} request(s).`,
|
|
296
|
+
evidence: `grant.network.normalRequestCount: ${grant.network.normalRequestCount}`,
|
|
297
|
+
phases: ["grant"],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// No signal proves a tag ever reached the page: everything we know comes
|
|
302
|
+
// from a container, a loader's reference to its companion, or a queue. A
|
|
303
|
+
// tag manager has firing triggers of its own on top of the consent gate,
|
|
304
|
+
// so the operator needs to check those too before blaming the bundle.
|
|
305
|
+
const neverSeenInPage = !facts.normalBundleSignals.some((s) => s.source === "dom-script" ||
|
|
306
|
+
s.source === "cmp-blocked-script" ||
|
|
307
|
+
s.source === "request" ||
|
|
308
|
+
s.source === "beacon");
|
|
309
|
+
checks.push({
|
|
310
|
+
id: "grant-enables-normal",
|
|
311
|
+
title: "Granting consent enables the normal bundle",
|
|
312
|
+
status: "fail",
|
|
313
|
+
detail: `Consent was granted and reloaded, but no normal-bundle request ever fired — the visitor agreed to identified tracking and did not get it. ` +
|
|
314
|
+
(grant.consentAcceptance.consentSignalConfirmed === false
|
|
315
|
+
? "The CMP recorded the choice but never emitted a Consent Mode update, which is the most likely cause. "
|
|
316
|
+
: grant.consent.analyticsStorageState !== null && grant.consent.analyticsStorageState !== "granted"
|
|
317
|
+
? `After the post-consent reload analytics_storage reads ${grant.consent.analyticsStorageState} (${grant.consent.analyticsStorageSource ?? "no source"}) despite the grant, which is the most likely cause: anything gated on Consent Mode is still blocked on the very page the visitor lands on. `
|
|
318
|
+
: "") +
|
|
319
|
+
(neverSeenInPage
|
|
320
|
+
? "No normal-bundle tag was seen on the page in any phase — it is known only from the tag manager container and the cookieless loader's companion check — so also confirm the tag's own firing trigger covers this page."
|
|
321
|
+
: ""),
|
|
322
|
+
evidence: `grant.network.normalRequestCount: 0, grant.consentAcceptance.succeeded: true, script.normalBundleSignals: ${facts.normalBundleSignals
|
|
323
|
+
.map((s) => s.source)
|
|
324
|
+
.join(", ")}`,
|
|
325
|
+
phases: ["grant"],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
// 3. Refusing consent has to actually keep identified tracking off.
|
|
329
|
+
if (!reject) {
|
|
330
|
+
checks.push(NOT_RUN("reject-blocks-normal", "Refusing consent blocks the normal bundle", "reject"));
|
|
331
|
+
}
|
|
332
|
+
else if (!reject.consentAcceptance.succeeded && !reject.consent.cmpDetected) {
|
|
333
|
+
checks.push({
|
|
334
|
+
id: "reject-blocks-normal",
|
|
335
|
+
title: "Refusing consent blocks the normal bundle",
|
|
336
|
+
status: "not_applicable",
|
|
337
|
+
detail: "No consent platform was detected, so there is no gate for a refusal to block — whatever the normal bundle does here is not evidence of a working consent gate.",
|
|
338
|
+
evidence: "reject.consent.cmpDetected: false",
|
|
339
|
+
phases: ["reject"],
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
else if (!reject.consentAcceptance.succeeded) {
|
|
343
|
+
checks.push({
|
|
344
|
+
id: "reject-blocks-normal",
|
|
345
|
+
title: "Refusing consent blocks the normal bundle",
|
|
346
|
+
status: "inconclusive",
|
|
347
|
+
detail: undrivenDetail(reject.consentAcceptance),
|
|
348
|
+
evidence: `reject.consentAcceptance.succeeded: false, undrivenReason: ${reject.consentAcceptance.undrivenReason ?? "none"}`,
|
|
349
|
+
phases: ["reject"],
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
const fired = reject.network.normalRequestCount > 0;
|
|
354
|
+
checks.push({
|
|
355
|
+
id: "reject-blocks-normal",
|
|
356
|
+
title: "Refusing consent blocks the normal bundle",
|
|
357
|
+
status: fired ? "fail" : "pass",
|
|
358
|
+
detail: fired
|
|
359
|
+
? `Consent was explicitly refused, and the normal bundle still sent ${reject.network.normalRequestCount} request(s).`
|
|
360
|
+
: "After an explicit refusal, no normal-bundle request fired.",
|
|
361
|
+
evidence: `reject.network.normalRequestCount: ${reject.network.normalRequestCount}`,
|
|
362
|
+
phases: ["reject"],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
// 4. The cookieless bundle exists to cover exactly the states above — but
|
|
366
|
+
// only where the normal bundle is not already running. The loader stands
|
|
367
|
+
// down on purpose when it finds an unblocked #dreamdata-analytics, so a
|
|
368
|
+
// silent cookieless bundle next to a live normal one is correct
|
|
369
|
+
// behaviour, and whether normal SHOULD be live there is the
|
|
370
|
+
// clean-before-consent check's question, not this one.
|
|
371
|
+
// A reject phase whose refusal was never confirmed to survive its own
|
|
372
|
+
// reload (see collect.ts) measured an indeterminate page, not a refusal —
|
|
373
|
+
// its silence would otherwise read as "cookieless doesn't cover a refusal"
|
|
374
|
+
// when it may only mean "the refusal itself never took".
|
|
375
|
+
const unreliableReject = reject !== undefined && !reject.consentAcceptance.succeeded && reject.consentAcceptance.attempted;
|
|
376
|
+
const unconsented = [clean, unreliableReject ? undefined : reject].filter((p) => p !== undefined);
|
|
377
|
+
const unconsentedNames = unconsented.map((p) => p.phase);
|
|
378
|
+
const uncovered = unconsented.filter((p) => p.network.normalRequestCount === 0);
|
|
379
|
+
if (!facts.cookielessBundleConfigured) {
|
|
380
|
+
// No cookieless bundle at all is a legitimate architecture choice when
|
|
381
|
+
// there is no consent gate to cover. When a CMP IS present, an
|
|
382
|
+
// unconsented visitor records nothing, and that is only waved through
|
|
383
|
+
// where the deployment says in its own published container that this is
|
|
384
|
+
// what it is — see cookielessAbsentByDesign. Absent that, silence where
|
|
385
|
+
// a fallback belongs stays a finding.
|
|
386
|
+
const uncoveredNoCmp = uncovered.filter((p) => p.consent.cmpDetected);
|
|
387
|
+
const byDesign = cookielessAbsentByDesign([...byPhase.values()]);
|
|
388
|
+
if (uncoveredNoCmp.length > 0 && byDesign !== null) {
|
|
389
|
+
checks.push({
|
|
390
|
+
id: "cookieless-covers-unconsented",
|
|
391
|
+
title: "Cookieless tracking covers the unconsented states",
|
|
392
|
+
status: "not_applicable",
|
|
393
|
+
detail: `No cookieless bundle is part of this deployment, and the published tag-manager container says so on purpose: ` +
|
|
394
|
+
`${byDesign.containers} ships the standard bundle's tag and no cookieless one. This deployment is configured to ` +
|
|
395
|
+
`track only visitors who have agreed, so the ${uncoveredNoCmp.map((p) => p.phase).join(" and ")} phase(s) ` +
|
|
396
|
+
`recording nothing is the install behaving as built, not a fault in it. Those visits do go unrecorded: adding ` +
|
|
397
|
+
`Cookieless Account Analytics is how to change that, but it is a decision about coverage, not a defect to fix.`,
|
|
398
|
+
evidence: `${byDesign.evidence}; ${uncoveredNoCmp.map((p) => `${p.phase}.network.normalRequestCount: 0`).join(", ")}`,
|
|
399
|
+
phases: unconsentedNames,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
else if (uncoveredNoCmp.length > 0) {
|
|
403
|
+
checks.push({
|
|
404
|
+
id: "cookieless-covers-unconsented",
|
|
405
|
+
title: "Cookieless tracking covers the unconsented states",
|
|
406
|
+
status: "fail",
|
|
407
|
+
detail: `A consent banner is present, but no cookieless bundle is part of this deployment, so the ${uncoveredNoCmp
|
|
408
|
+
.map((p) => p.phase)
|
|
409
|
+
.join(" and ")} phase(s) recorded nothing at all while consent was withheld or unanswered — those visits went untracked entirely instead of falling back to cookieless tracking.`,
|
|
410
|
+
evidence: uncoveredNoCmp.map((p) => `${p.phase}.network.normalRequestCount: 0`).join(", "),
|
|
411
|
+
phases: uncoveredNoCmp.map((p) => p.phase),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
checks.push({
|
|
416
|
+
id: "cookieless-covers-unconsented",
|
|
417
|
+
title: "Cookieless tracking covers the unconsented states",
|
|
418
|
+
status: "not_applicable",
|
|
419
|
+
detail: "No cookieless bundle is part of this deployment.",
|
|
420
|
+
evidence: "no cookieless snippet, request or global in any phase",
|
|
421
|
+
phases: unconsentedNames,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else if (unconsented.length === 0) {
|
|
426
|
+
checks.push(unreliableReject
|
|
427
|
+
? {
|
|
428
|
+
id: "cookieless-covers-unconsented",
|
|
429
|
+
title: "Cookieless tracking covers the unconsented states",
|
|
430
|
+
status: "inconclusive",
|
|
431
|
+
detail: `The clean phase did not run, and the reject phase's refusal was not certified: ${undrivenDetail(reject.consentAcceptance)} So no unconsented state was reliably observed.`,
|
|
432
|
+
evidence: `clean: not run; reject.consentAcceptance.succeeded: false, undrivenReason: ${reject.consentAcceptance.undrivenReason ?? "none"}`,
|
|
433
|
+
phases: ["reject"],
|
|
434
|
+
}
|
|
435
|
+
: NOT_RUN("cookieless-covers-unconsented", "Cookieless tracking covers the unconsented states", "clean"));
|
|
436
|
+
}
|
|
437
|
+
else if (uncovered.length === 0) {
|
|
438
|
+
checks.push({
|
|
439
|
+
id: "cookieless-covers-unconsented",
|
|
440
|
+
title: "Cookieless tracking covers the unconsented states",
|
|
441
|
+
status: "not_applicable",
|
|
442
|
+
detail: "The normal bundle ran in every unconsented state, so the cookieless bundle correctly stood down — nothing was left uncovered." +
|
|
443
|
+
(unreliableReject
|
|
444
|
+
? ` (The reject phase's refusal was not certified — ${reject.consentAcceptance.undrivenReason ?? "unknown reason"} — so it was left out of this check.)`
|
|
445
|
+
: ""),
|
|
446
|
+
evidence: unconsented
|
|
447
|
+
.map((p) => `${p.phase}.network.normalRequestCount: ${p.network.normalRequestCount}`)
|
|
448
|
+
.join(", "),
|
|
449
|
+
phases: unconsentedNames,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
const silent = uncovered.filter((p) => p.network.cookielessRequestCount === 0);
|
|
454
|
+
checks.push({
|
|
455
|
+
id: "cookieless-covers-unconsented",
|
|
456
|
+
title: "Cookieless tracking covers the unconsented states",
|
|
457
|
+
status: silent.length === 0 ? "pass" : "fail",
|
|
458
|
+
detail: silent.length === 0
|
|
459
|
+
? `The cookieless bundle fired in every unconsented state where nothing else was tracking (${uncovered
|
|
460
|
+
.map((p) => p.phase)
|
|
461
|
+
.join(", ")}).`
|
|
462
|
+
: `A cookieless bundle is installed but the ${silent
|
|
463
|
+
.map((p) => p.phase)
|
|
464
|
+
.join(" and ")} phase(s) recorded nothing at all — neither bundle sent an event, so those visits went untracked entirely.` +
|
|
465
|
+
(unreliableReject
|
|
466
|
+
? ` (The reject phase's refusal was not certified — ${reject.consentAcceptance.undrivenReason ?? "unknown reason"} — so it was left out of this check.)`
|
|
467
|
+
: ""),
|
|
468
|
+
// The container's own account of the cookieless tag's gate, when it has
|
|
469
|
+
// one, appended to the behavioural counts. Silence in the unconsented
|
|
470
|
+
// phases is the symptom; a consent requirement on the cookieless tag is
|
|
471
|
+
// the reason, and until it was read the fix rested on inference from
|
|
472
|
+
// request counts alone. checkproof.com is the case: tag 96 (standard)
|
|
473
|
+
// and tag 97 (cookieless) both require analytics_storage.
|
|
474
|
+
evidence: [
|
|
475
|
+
uncovered
|
|
476
|
+
.map((p) => `${p.phase}.network.cookielessRequestCount: ${p.network.cookielessRequestCount} (normal: ${p.network.normalRequestCount})`)
|
|
477
|
+
.join(", "),
|
|
478
|
+
...cookielessTagGates(cookielessGateContainers).map(({ containerId, tag }) => `${containerId} tag ${tag.tagIndex} (cookieless) consent: ${tag.consent === null ? "none" : `[${tag.consent.join(", ")}]`}${tag.blockedWhen.length > 0 ? `, blocked when ${tag.blockedWhen.join("; or ")}` : ""}`),
|
|
479
|
+
]
|
|
480
|
+
.filter((part) => part.length > 0)
|
|
481
|
+
.join("; "),
|
|
482
|
+
// A failure belongs to the phases that actually went untracked, not to
|
|
483
|
+
// every unconsented phase the check looked at.
|
|
484
|
+
phases: (silent.length === 0 ? uncovered : silent).map((p) => p.phase),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// 5/6. Identity storage has to follow the answer, in both directions.
|
|
488
|
+
if (!grant) {
|
|
489
|
+
checks.push(NOT_RUN("grant-sets-identity-cookies", "Identity cookies are written after a grant", "grant"));
|
|
490
|
+
}
|
|
491
|
+
else if (!grant.consentAcceptance.succeeded && !grant.consent.cmpDetected) {
|
|
492
|
+
// Storage seen here is the ungated default, not the effect of a grant.
|
|
493
|
+
// Calling it a pass would credit consent handling to a site that has no
|
|
494
|
+
// consent gate at all — the reader would then trust a grant path that was
|
|
495
|
+
// never exercised.
|
|
496
|
+
checks.push({
|
|
497
|
+
id: "grant-sets-identity-cookies",
|
|
498
|
+
title: "Identity cookies are written after a grant",
|
|
499
|
+
status: "not_applicable",
|
|
500
|
+
detail: "No consent platform was detected, so there is no grant for identity storage to follow.",
|
|
501
|
+
evidence: "grant.consent.cmpDetected: false",
|
|
502
|
+
phases: ["grant"],
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
else if (!grant.consentAcceptance.succeeded) {
|
|
506
|
+
checks.push({
|
|
507
|
+
id: "grant-sets-identity-cookies",
|
|
508
|
+
title: "Identity cookies are written after a grant",
|
|
509
|
+
status: "inconclusive",
|
|
510
|
+
detail: `${undrivenDetail(grant.consentAcceptance)} Any storage seen here is not attributable to a grant.`,
|
|
511
|
+
evidence: `grant.consentAcceptance.succeeded: false, undrivenReason: ${grant.consentAcceptance.undrivenReason ?? "none"}`,
|
|
512
|
+
phases: ["grant"],
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
else if (grant.network.normalRequestCount === 0) {
|
|
516
|
+
checks.push({
|
|
517
|
+
id: "grant-sets-identity-cookies",
|
|
518
|
+
title: "Identity cookies are written after a grant",
|
|
519
|
+
status: "not_applicable",
|
|
520
|
+
detail: "The normal bundle never ran in the grant phase, so there was nothing to write cookies.",
|
|
521
|
+
evidence: "grant.network.normalRequestCount: 0",
|
|
522
|
+
phases: ["grant"],
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
const cookies = identityCookiesSet(grant);
|
|
527
|
+
const hasAnonymous = cookies.some((name) => name.endsWith("anonymous_id"));
|
|
528
|
+
checks.push({
|
|
529
|
+
id: "grant-sets-identity-cookies",
|
|
530
|
+
title: "Identity cookies are written after a grant",
|
|
531
|
+
status: hasAnonymous ? "pass" : "fail",
|
|
532
|
+
detail: hasAnonymous
|
|
533
|
+
? `An anonymous id was stored after the grant (${cookies.join(", ")}).`
|
|
534
|
+
: "The normal bundle ran after the grant but stored no anonymous id, so visitors cannot be stitched across events.",
|
|
535
|
+
evidence: `grant identity cookies: [${cookies.join(", ")}]`,
|
|
536
|
+
phases: ["grant"],
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
if (!reject) {
|
|
540
|
+
checks.push(NOT_RUN("reject-writes-no-identity", "No identity storage after a refusal", "reject"));
|
|
541
|
+
}
|
|
542
|
+
else if (!reject.consentAcceptance.succeeded && !reject.consent.cmpDetected) {
|
|
543
|
+
checks.push({
|
|
544
|
+
id: "reject-writes-no-identity",
|
|
545
|
+
title: "No identity storage after a refusal",
|
|
546
|
+
status: "not_applicable",
|
|
547
|
+
detail: "No consent platform was detected, so there is no refusal for identity storage to follow.",
|
|
548
|
+
evidence: "reject.consent.cmpDetected: false",
|
|
549
|
+
phases: ["reject"],
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
else if (!reject.consentAcceptance.succeeded) {
|
|
553
|
+
checks.push({
|
|
554
|
+
id: "reject-writes-no-identity",
|
|
555
|
+
title: "No identity storage after a refusal",
|
|
556
|
+
status: "inconclusive",
|
|
557
|
+
detail: `${undrivenDetail(reject.consentAcceptance)} Any storage seen here is not attributable to a refusal.`,
|
|
558
|
+
evidence: `reject.consentAcceptance.succeeded: false, undrivenReason: ${reject.consentAcceptance.undrivenReason ?? "none"}`,
|
|
559
|
+
phases: ["reject"],
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
const cookies = identityCookiesSet(reject);
|
|
564
|
+
const stored = Object.entries(reject.cookies.localStorageValues)
|
|
565
|
+
.filter(([, v]) => v !== null)
|
|
566
|
+
.map(([k]) => k);
|
|
567
|
+
const bad = cookies.length > 0 || stored.length > 0;
|
|
568
|
+
checks.push({
|
|
569
|
+
id: "reject-writes-no-identity",
|
|
570
|
+
title: "No identity storage after a refusal",
|
|
571
|
+
status: bad ? "fail" : "pass",
|
|
572
|
+
detail: bad
|
|
573
|
+
? `Consent was refused, but identity was still stored: ${describeIdentityStores(cookies, stored)}.`
|
|
574
|
+
: `No ${DD_COOKIE_NAMES[0]}-style cookie or localStorage entry survived the refusal.`,
|
|
575
|
+
evidence: `reject identity cookies: [${cookies.join(", ")}], localStorage: [${stored.join(", ")}]`,
|
|
576
|
+
phases: ["reject"],
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// 7. Consent Mode v2 has to carry the decision, not just the CMP's own store.
|
|
580
|
+
const driven = [grant, reject].filter((p) => p?.consentAcceptance.succeeded === true);
|
|
581
|
+
if (driven.length === 0) {
|
|
582
|
+
const anyCmpDetected = [grant, reject].some((p) => p?.consent.cmpDetected);
|
|
583
|
+
checks.push({
|
|
584
|
+
id: "consent-mode-signal",
|
|
585
|
+
title: "Google Consent Mode v2 reflects the decision",
|
|
586
|
+
status: anyCmpDetected ? "inconclusive" : "not_applicable",
|
|
587
|
+
detail: anyCmpDetected
|
|
588
|
+
? "No consent choice was successfully driven, so no Consent Mode transition could be observed."
|
|
589
|
+
: "No consent platform was detected, so there is no decision for Consent Mode to reflect.",
|
|
590
|
+
evidence: anyCmpDetected
|
|
591
|
+
? "no phase with consentAcceptance.succeeded: true"
|
|
592
|
+
: "consent.cmpDetected: false in grant and reject",
|
|
593
|
+
phases: [grant, reject].filter((p) => p !== undefined).map((p) => p.phase),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
else if (driven.every((p) => p.consentAcceptance.consentSignalConfirmed === null && p.consent.analyticsStorageState === null)) {
|
|
597
|
+
checks.push({
|
|
598
|
+
id: "consent-mode-signal",
|
|
599
|
+
title: "Google Consent Mode v2 reflects the decision",
|
|
600
|
+
status: "not_applicable",
|
|
601
|
+
detail: "The page exposes no Google Consent Mode state at all (no gtag.js), so it cannot be held to one.",
|
|
602
|
+
evidence: "consentAcceptance.consentSignalConfirmed: null and analytics_storage unreadable in every driven phase",
|
|
603
|
+
phases: driven.map((p) => p.phase),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
const verdicts = driven.map(consentModeVerdict);
|
|
608
|
+
const broken = verdicts.filter((v) => v.verdict === "broken");
|
|
609
|
+
const unconfirmed = verdicts.filter((v) => v.verdict === "unconfirmed");
|
|
610
|
+
const status = broken.length > 0 ? "fail" : unconfirmed.length > 0 ? "inconclusive" : "pass";
|
|
611
|
+
const relevant = broken.length > 0 ? broken : unconfirmed.length > 0 ? unconfirmed : verdicts;
|
|
612
|
+
checks.push({
|
|
613
|
+
id: "consent-mode-signal",
|
|
614
|
+
title: "Google Consent Mode v2 reflects the decision",
|
|
615
|
+
status,
|
|
616
|
+
detail: status === "pass"
|
|
617
|
+
? `analytics_storage followed every choice this run drove (${verdicts.map((v) => v.phase).join(", ")}), and the value was still there after the post-consent reload.`
|
|
618
|
+
: relevant.map((v) => v.why).join(" "),
|
|
619
|
+
evidence: verdicts.map((v) => v.evidence).join("; "),
|
|
620
|
+
phases: relevant.map((v) => v.phase),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
// 8. And the mechanism itself has to be installed, complete and in the
|
|
624
|
+
// right order — which is a different question from either of the two
|
|
625
|
+
// above and, until now, one no check asked.
|
|
626
|
+
//
|
|
627
|
+
// clean-consent-mode-default asks whether the default denies.
|
|
628
|
+
// consent-mode-signal asks whether a driven decision is carried. Both
|
|
629
|
+
// read analytics_storage, and both are satisfiable by a page that never
|
|
630
|
+
// called gtag('consent', …) in its life: gtag/GTM creates the entries
|
|
631
|
+
// itself as soon as a container loads, so `denied` on an unanswered
|
|
632
|
+
// page can equally be a declared default or gtag.js's own fallback on a
|
|
633
|
+
// site with no Consent Mode at all. ics.active is what separates them.
|
|
634
|
+
//
|
|
635
|
+
// SCOPE BOUNDARY, and it matters for how a reader takes a pass here:
|
|
636
|
+
// this says nothing about whether an unanswered visitor is COVERED.
|
|
637
|
+
// A site can pass this check while every unconsented visit goes
|
|
638
|
+
// untracked, because the standard bundle's loader correctly declines to
|
|
639
|
+
// initialise and no cookieless bundle was installed behind it. That is
|
|
640
|
+
// cookieless-covers-unconsented's question, and the two must not be
|
|
641
|
+
// read as one.
|
|
642
|
+
const observedPhases = [...byPhase.values()];
|
|
643
|
+
checks.push(consentModeV2Check(observedPhases, judgeConsentModeV2(observedPhases)));
|
|
644
|
+
return checks;
|
|
645
|
+
}
|
|
646
|
+
const CONSENT_MODE_V2_TITLE = "Google Consent Mode v2 is correctly enabled";
|
|
647
|
+
/** Turns the run-level Consent Mode v2 verdict into the customer-facing check. */
|
|
648
|
+
function consentModeV2Check(phases, verdict) {
|
|
649
|
+
const observed = verdict.perPhase.map((entry) => entry.phase);
|
|
650
|
+
const check = (status, detail) => ({
|
|
651
|
+
id: "consent-mode-v2-enabled",
|
|
652
|
+
title: CONSENT_MODE_V2_TITLE,
|
|
653
|
+
status,
|
|
654
|
+
detail,
|
|
655
|
+
evidence: verdict.evidence,
|
|
656
|
+
phases: observed,
|
|
657
|
+
});
|
|
658
|
+
if (verdict.outcome === "unreadable") {
|
|
659
|
+
// A page with no gtag.js has nothing to be held to. A page that HAS one
|
|
660
|
+
// and still exposed no readable state is a gap in what this run could
|
|
661
|
+
// see, which is a different thing and must not read as a pass.
|
|
662
|
+
return sawGoogleTag(phases)
|
|
663
|
+
? check("inconclusive", "A Google tag is on the page, but it exposed no readable Consent Mode state, so whether Consent Mode v2 is enabled could not be established.")
|
|
664
|
+
: check("not_applicable", "The page carries no Google tag, so there is no Google Consent Mode to enable. Consent still has to be honoured — the checks above are what say whether it is.");
|
|
665
|
+
}
|
|
666
|
+
if (verdict.outcome === "not-installed") {
|
|
667
|
+
return check("fail", "Google Consent Mode v2 is not enabled. A Google tag is on the page and it created the consent signal entries itself, which is why analytics_storage looks readable, but nothing ever called gtag('consent', …) — so no consent decision reaches any tag gated on it, and a tag waiting for one waits forever.");
|
|
668
|
+
}
|
|
669
|
+
if (verdict.outcome === "misconfigured") {
|
|
670
|
+
// v1 and a late-setting CMP are faults in what the site DECLARES and in
|
|
671
|
+
// what order — the mechanism is incomplete or arrives too late to gate
|
|
672
|
+
// anything — so they fail.
|
|
673
|
+
//
|
|
674
|
+
// A shadowed default or a closed wait window is a fault in what the
|
|
675
|
+
// declared state can be READ as. Google resolved both correctly, so
|
|
676
|
+
// anything gated on analytics_storage got the right answer and neither
|
|
677
|
+
// can, by construction, be what let a tag through. Deliberately NOT
|
|
678
|
+
// upgraded to a fail when this run also leaked: on workflex.com the leak
|
|
679
|
+
// is a tag manager tag that declares no consent requirement at all, which
|
|
680
|
+
// clean-before-consent already fails on, and blaming it on the shadowed
|
|
681
|
+
// default would both double-count it and tell a customer their Consent
|
|
682
|
+
// Mode is broken when it is the one part that works.
|
|
683
|
+
const structural = verdict.causes.includes("v1-only") || verdict.causes.includes("set-late");
|
|
684
|
+
return check(structural ? "fail" : "warn", describeMisconfigured(verdict));
|
|
685
|
+
}
|
|
686
|
+
if (verdict.outcome === "enabled-not-yet-answered") {
|
|
687
|
+
return check("pass", "Google Consent Mode v2 is enabled: both signals v2 added are declared and gtag('consent', …) is in use. No consent update applied during this run, which is the correct and normal state for a visitor who has not answered the banner — it is not evidence of a fault.");
|
|
688
|
+
}
|
|
689
|
+
return check("pass", "Google Consent Mode v2 is enabled and carrying decisions: both signals v2 added are declared, gtag('consent', …) is in use, and a consent update was applied for the choice this run drove.");
|
|
690
|
+
}
|
|
691
|
+
/** One sentence per cause, in the verdict's own fixed order. */
|
|
692
|
+
function describeMisconfigured(verdict) {
|
|
693
|
+
const sentences = [];
|
|
694
|
+
if (verdict.causes.includes("v1-only")) {
|
|
695
|
+
sentences.push("Consent Mode is running v1, not v2: ad_user_data and ad_personalization are not among the declared signals.");
|
|
696
|
+
}
|
|
697
|
+
if (verdict.causes.includes("set-late")) {
|
|
698
|
+
sentences.push("Consent was set after tags had already run (ics.wasSetLate), so the consent platform loads too late to gate them — the tags that fire before it see no consent state at all.");
|
|
699
|
+
}
|
|
700
|
+
if (verdict.causes.includes("region-shadowed-default")) {
|
|
701
|
+
sentences.push("The consent platform declares a permissive global default for analytics_storage and then overrides it for specific regions. Google resolves that correctly, so the end state is right, but anything reading the dataLayer consent commands directly sees the global 'granted' and takes it for a decision.");
|
|
702
|
+
}
|
|
703
|
+
if (verdict.causes.includes("wait-period-timed-out")) {
|
|
704
|
+
sentences.push("A wait_for_update window closed with no consent update in it, so tags fell back to the declared defaults rather than the visitor's answer.");
|
|
705
|
+
}
|
|
706
|
+
return sentences.join(" ");
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* What Consent Mode actually did with one driven decision.
|
|
710
|
+
*
|
|
711
|
+
* The transient confirmation is not enough on its own. `consentSignalConfirmed`
|
|
712
|
+
* is read on the answered page, before the post-consent reload, so a CMP that
|
|
713
|
+
* emits the update and then fails to restore it on the next load looks
|
|
714
|
+
* identical to one that works — and every gated tag on every subsequent page
|
|
715
|
+
* stays frozen. The phase's own end state is therefore judged too, and only a
|
|
716
|
+
* value whose source is an explicit `update` is taken as agreement. A site
|
|
717
|
+
* that declares analytics_storage denied by default, or one running on
|
|
718
|
+
* gtag.js's own fallback because nothing declared covers this visitor, reads
|
|
719
|
+
* "denied" on every reject phase whether or not the refusal was ever carried
|
|
720
|
+
* — a match by coincidence is not evidence.
|
|
721
|
+
*/
|
|
722
|
+
function consentModeVerdict(phase) {
|
|
723
|
+
const expected = phase.phase === "grant" ? "granted" : "denied";
|
|
724
|
+
const observed = phase.consent.analyticsStorageState;
|
|
725
|
+
const source = phase.consent.analyticsStorageSource;
|
|
726
|
+
const confirmed = phase.consentAcceptance.consentSignalConfirmed;
|
|
727
|
+
const evidence = `${phase.phase}: analytics_storage ${observed ?? "unreadable"} (${source ?? "no source"}), expected ${expected}, signalConfirmed ${confirmed}`;
|
|
728
|
+
const of = (verdict, why) => ({
|
|
729
|
+
phase: phase.phase,
|
|
730
|
+
verdict,
|
|
731
|
+
why,
|
|
732
|
+
evidence,
|
|
733
|
+
});
|
|
734
|
+
if (confirmed === false) {
|
|
735
|
+
return of("broken", `The CMP recorded the ${phase.phase === "grant" ? "grant" : "refusal"} but analytics_storage never moved to ${expected} at all, so nothing gated on Consent Mode could react.`);
|
|
736
|
+
}
|
|
737
|
+
if (observed === null) {
|
|
738
|
+
return of("unconfirmed", `The ${phase.phase} phase saw analytics_storage reach ${expected} while the banner was being answered, but the reloaded page exposes no Consent Mode state to check it against.`);
|
|
739
|
+
}
|
|
740
|
+
if (observed !== expected) {
|
|
741
|
+
return of("broken", `The ${phase.phase} phase drove the banner and analytics_storage read ${expected} while it was being answered, but after the post-consent reload it reads ${observed} (${source ?? "no source"}) — the decision does not survive a page load, so on the page the visitor actually browses every tag gated on Consent Mode stays ${observed}.`);
|
|
742
|
+
}
|
|
743
|
+
if (source !== "update") {
|
|
744
|
+
return of("unconfirmed", `The ${phase.phase} phase ends with analytics_storage ${observed}, which is what was wanted, but ` +
|
|
745
|
+
(source === "default"
|
|
746
|
+
? "it comes from a default the site itself declares rather than from a consent update"
|
|
747
|
+
: "its source is gtag.js's own implicit default — nothing declared by the site or CMP applies to this visitor") +
|
|
748
|
+
`, so the value matches by coincidence and is not evidence the choice was carried into Consent Mode.`);
|
|
749
|
+
}
|
|
750
|
+
return of("confirmed", `The ${phase.phase} phase ends with analytics_storage ${observed} (${source}).`);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Whether tracking survived the walk to the second page, for one phase.
|
|
754
|
+
*
|
|
755
|
+
* Two asymmetries are deliberate. A normal bundle that stops is always a
|
|
756
|
+
* failure — the visitor consented and stopped being counted. A cookieless
|
|
757
|
+
* bundle that stops is only a failure when nothing replaced it, because the
|
|
758
|
+
* loader stands down on purpose when it finds the normal bundle live, exactly
|
|
759
|
+
* as `cookieless-covers-unconsented` allows on the first page. And a mode
|
|
760
|
+
* APPEARING on the second page is never this check's business: that is a
|
|
761
|
+
* consent question, and second-page-honours-consent asks it.
|
|
762
|
+
*/
|
|
763
|
+
function judgeContinuity({ phase, second }) {
|
|
764
|
+
// A page the install was never put on cannot show tracking stopping.
|
|
765
|
+
// whereby.com serves /blog/ from a separate Gatsby build with no tag
|
|
766
|
+
// manager, no script and no global; judged as continuity it says "tracking
|
|
767
|
+
// stopped after the first page view" about a site whose every other page
|
|
768
|
+
// tracks. The walk now steps past such pages (see SECOND_PAGE_MAX_RETRIES);
|
|
769
|
+
// this is what happens when every candidate it could reach was one.
|
|
770
|
+
// Optional-chained rather than compared to null: evidence collected before
|
|
771
|
+
// the second-page install probe existed has no such field at all, and
|
|
772
|
+
// replaying the stored corpus must not crash on it.
|
|
773
|
+
if (second.install?.present === false) {
|
|
774
|
+
return { phase: phase.phase, judged: false, outsideInstall: true, failures: [] };
|
|
775
|
+
}
|
|
776
|
+
const firstNormal = phase.network.normalRequestCount;
|
|
777
|
+
const firstCookieless = phase.network.cookielessRequestCount;
|
|
778
|
+
const nextNormal = second.network.normalRequestCount;
|
|
779
|
+
const nextCookieless = second.network.cookielessRequestCount;
|
|
780
|
+
const failures = [];
|
|
781
|
+
if (firstNormal > 0 && nextNormal === 0) {
|
|
782
|
+
failures.push(`in the ${phase.phase} phase the normal bundle sent ${firstNormal} request(s) on the first page and none on ${second.toUrl}` +
|
|
783
|
+
(nextCookieless > 0
|
|
784
|
+
? ", where only the cookieless bundle fired — a visitor whose consent state allows identified tracking is tracked anonymously from the second page onwards, and their two page views cannot be stitched together"
|
|
785
|
+
: ""));
|
|
786
|
+
}
|
|
787
|
+
if (firstCookieless > 0 && nextCookieless === 0 && nextNormal === 0) {
|
|
788
|
+
failures.push(`in the ${phase.phase} phase the cookieless bundle covered the first page and nothing at all tracked ${second.toUrl}`);
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
phase: phase.phase,
|
|
792
|
+
judged: firstNormal > 0 || firstCookieless > 0,
|
|
793
|
+
outsideInstall: false,
|
|
794
|
+
failures,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Pages the walk stepped past, as one clause.
|
|
799
|
+
*
|
|
800
|
+
* They are a finding in their own right and must not vanish into the evidence
|
|
801
|
+
* JSON: a section of the site that ships no bundle at all — whereby.com's blog
|
|
802
|
+
* before a consent decision is recorded — is worth a reader's attention even
|
|
803
|
+
* though the check itself went on to judge somewhere else.
|
|
804
|
+
*/
|
|
805
|
+
function walkedPastNote(walked) {
|
|
806
|
+
const skipped = [
|
|
807
|
+
...new Set(walked.flatMap((w) => w.second.rejectedCandidates.map((c) => c.url))),
|
|
808
|
+
];
|
|
809
|
+
return skipped.length === 0
|
|
810
|
+
? ""
|
|
811
|
+
: ` The walk stepped past ${skipped.join(", ")} first: no Dreamdata script, global or tag manager was on ${skipped.length > 1 ? "those pages" : "that page"}, so nothing there could be judged either way — worth checking by hand if that section is meant to be tracked.`;
|
|
812
|
+
}
|
|
813
|
+
function continuityEvidence(walked) {
|
|
814
|
+
return walked
|
|
815
|
+
.map(({ phase, second }) => `${phase.phase}: first page ${phase.network.normalRequestCount}n/${phase.network.cookielessRequestCount}c → second page ${second.network.normalRequestCount}n/${second.network.cookielessRequestCount}c (${second.kind}${second.install?.present === false ? ", no install on that page" : ""})`)
|
|
816
|
+
.join("; ");
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* The continuity checks, split by how the site navigates.
|
|
820
|
+
*
|
|
821
|
+
* One question — "does tracking survive a page change?" — but the two answers
|
|
822
|
+
* are fixed in different places and by different people, so they are reported
|
|
823
|
+
* as two checks rather than one. A document load that stops tracking is a
|
|
824
|
+
* deployment problem: the bundle or its tag-manager trigger is not on the
|
|
825
|
+
* second page. A same-document route change that stops tracking is a code
|
|
826
|
+
* problem: the bundle is right there and nobody calls page() when the route
|
|
827
|
+
* changes. Every phase falls into exactly one of them, so nothing is counted
|
|
828
|
+
* twice.
|
|
829
|
+
*/
|
|
830
|
+
function buildContinuityCheck(kind, phases, walkedByKind) {
|
|
831
|
+
const id = kind === "hard" ? "second-page-keeps-tracking" : "soft-nav-keeps-tracking";
|
|
832
|
+
const title = kind === "hard" ? "Tracking survives a page change" : "Tracking survives an in-app route change";
|
|
833
|
+
if (phases.every((p) => p.secondPage === null)) {
|
|
834
|
+
return {
|
|
835
|
+
id,
|
|
836
|
+
title,
|
|
837
|
+
status: "inconclusive",
|
|
838
|
+
detail: "No second page was visited this run (--no-second-page), so nothing beyond the first page view was observed.",
|
|
839
|
+
evidence: "secondPage: null in every phase",
|
|
840
|
+
phases: phases.map((p) => p.phase),
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (walkedByKind.length === 0) {
|
|
844
|
+
const walkedOther = phases.filter((p) => secondPageObserved(p.secondPage));
|
|
845
|
+
if (walkedOther.length > 0) {
|
|
846
|
+
return {
|
|
847
|
+
id,
|
|
848
|
+
title,
|
|
849
|
+
status: "not_applicable",
|
|
850
|
+
detail: kind === "hard"
|
|
851
|
+
? "Every navigation this run followed was handled inside the same document by the site's own router, so there was no second document load to judge — soft-nav-keeps-tracking covers those."
|
|
852
|
+
: "Every navigation this run followed loaded a new document, so the site does not navigate in-app on the paths tested — second-page-keeps-tracking covers those.",
|
|
853
|
+
evidence: walkedOther
|
|
854
|
+
.map((p) => `${p.phase}.secondPage.kind: ${p.secondPage?.kind}`)
|
|
855
|
+
.join(", "),
|
|
856
|
+
// Every one of those phases is the other check's to judge, so this one
|
|
857
|
+
// claims none of them.
|
|
858
|
+
phases: [],
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
const reasons = phases
|
|
862
|
+
.filter((p) => p.secondPage !== null)
|
|
863
|
+
.map((p) => `${p.phase}: ${p.secondPage?.skippedReason ?? "no navigation observed"}`);
|
|
864
|
+
return {
|
|
865
|
+
id,
|
|
866
|
+
title,
|
|
867
|
+
status: "inconclusive",
|
|
868
|
+
detail: `No second page could be reached, so whether tracking continues past the first page view is unknown. ${reasons.join("; ")}`,
|
|
869
|
+
evidence: reasons.join("; "),
|
|
870
|
+
phases: phases.filter((p) => p.secondPage !== null).map((p) => p.phase),
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
const verdicts = walkedByKind.map(judgeContinuity);
|
|
874
|
+
const judged = verdicts.filter((v) => v.judged);
|
|
875
|
+
if (judged.length === 0) {
|
|
876
|
+
const outside = verdicts.filter((v) => v.outsideInstall);
|
|
877
|
+
const landings = walkedByKind
|
|
878
|
+
.filter((w) => w.second.install?.present === false)
|
|
879
|
+
.map((w) => `${w.phase.phase}: ${w.second.toUrl}`);
|
|
880
|
+
return {
|
|
881
|
+
id,
|
|
882
|
+
title,
|
|
883
|
+
status: "not_applicable",
|
|
884
|
+
detail: outside.length === verdicts.length
|
|
885
|
+
? `Every page the walk could reach is outside this install — no Dreamdata script, global or tag manager is on any of them (${landings.join("; ")}), which is what a separately-built section of the same domain looks like. Nothing about whether tracking survives a page change follows from them, in either direction. Check a page the install does cover by hand.`
|
|
886
|
+
: "Nothing tracked the first page in any phase that reached a second one, so there was no tracking for a page change to interrupt.",
|
|
887
|
+
evidence: continuityEvidence(walkedByKind),
|
|
888
|
+
phases: walkedByKind.map((w) => w.phase.phase),
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
const failures = judged.flatMap((v) => v.failures);
|
|
892
|
+
const mechanism = kind === "hard"
|
|
893
|
+
? "The second page was a full document load, so every bundle got a fresh chance to run: check that the bundle — and, if a tag manager injects it, that tag's own firing trigger — is present on pages other than the entry page."
|
|
894
|
+
: "The second page was a same-document route change, so no script re-ran. A bundle that only tracks on load will never fire again: the site's router has to call page() itself on every route change.";
|
|
895
|
+
const walkedPast = walkedPastNote(walkedByKind);
|
|
896
|
+
return {
|
|
897
|
+
id,
|
|
898
|
+
title,
|
|
899
|
+
status: failures.length === 0 ? "pass" : "fail",
|
|
900
|
+
detail: failures.length === 0
|
|
901
|
+
? `Tracking continued onto the second page in every phase that reached one (${judged
|
|
902
|
+
.map((v) => v.phase)
|
|
903
|
+
.join(", ")}).${walkedPast}`
|
|
904
|
+
: `Tracking stopped after the first page view: ${failures.join("; ")}. ${mechanism}${walkedPast}`,
|
|
905
|
+
evidence: continuityEvidence(walkedByKind),
|
|
906
|
+
// Only the phases that actually stopped tracking. One broken phase out of
|
|
907
|
+
// three used to read as all three being broken to anything reading the
|
|
908
|
+
// status alone.
|
|
909
|
+
phases: (failures.length === 0 ? judged : judged.filter((v) => v.failures.length > 0)).map((v) => v.phase),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Whether the consent decision still held one page later.
|
|
914
|
+
*
|
|
915
|
+
* Only the states where identified tracking is not allowed can fail this. The
|
|
916
|
+
* usual cause is a CMP configured to treat a navigation as implied consent,
|
|
917
|
+
* which is invisible to any single-page-load check and turns an unanswered or
|
|
918
|
+
* refused banner into a live normal bundle on page two.
|
|
919
|
+
*/
|
|
920
|
+
function buildSecondPageConsentCheck(phases, walked) {
|
|
921
|
+
const id = "second-page-honours-consent";
|
|
922
|
+
const title = "The consent decision still holds one page later";
|
|
923
|
+
const candidates = walked.filter(({ phase }) => phase.phase === "clean" || phase.phase === "reject");
|
|
924
|
+
if (candidates.length === 0) {
|
|
925
|
+
return {
|
|
926
|
+
id,
|
|
927
|
+
title,
|
|
928
|
+
status: "inconclusive",
|
|
929
|
+
detail: "Neither the clean nor the reject phase reached a second page, so whether an unanswered or refused banner still holds after a navigation is unknown.",
|
|
930
|
+
evidence: walked.length === 0 ? "no phase reached a second page" : `second pages reached: ${walked.map((w) => w.phase.phase).join(", ")}`,
|
|
931
|
+
phases: phases.filter((p) => p.phase !== "grant").map((p) => p.phase),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
// A phase only has a consent decision to still honour one page later if it
|
|
935
|
+
// actually had one: "clean" needs a banner that exists and is genuinely
|
|
936
|
+
// unanswered, "reject" needs the refusal to have actually been driven. A
|
|
937
|
+
// site with no CMP at all firing the normal bundle everywhere is not this
|
|
938
|
+
// check's business — there is no decision for the second page to betray.
|
|
939
|
+
const gated = candidates.filter(({ phase }) => phase.phase === "clean"
|
|
940
|
+
? phase.consent.cmpDetected && phase.consent.cmpHasResponse !== true
|
|
941
|
+
: phase.consentAcceptance.succeeded);
|
|
942
|
+
if (gated.length === 0) {
|
|
943
|
+
const anyCmpDetected = phases.some((p) => p.consent.cmpDetected);
|
|
944
|
+
const reasons = candidates.map(({ phase }) => phase.phase === "clean"
|
|
945
|
+
? phase.consent.cmpDetected
|
|
946
|
+
? `${phase.phase}: the banner was already answered on first load, so there was no unanswered state to carry forward`
|
|
947
|
+
: `${phase.phase}: no consent platform was detected, so there is no gate for tracking to honour`
|
|
948
|
+
: `${phase.phase}: ${undrivenDetail(phase.consentAcceptance)}`);
|
|
949
|
+
return {
|
|
950
|
+
id,
|
|
951
|
+
title,
|
|
952
|
+
status: anyCmpDetected ? "inconclusive" : "not_applicable",
|
|
953
|
+
detail: `${reasons.join("; ")}. A normal bundle firing on every page is expected when there is no consent decision to honour.`,
|
|
954
|
+
evidence: candidates
|
|
955
|
+
.map(({ phase }) => `${phase.phase}.consent.cmpDetected: ${phase.consent.cmpDetected}, consentAcceptance.succeeded: ${phase.consentAcceptance.succeeded}`)
|
|
956
|
+
.join("; "),
|
|
957
|
+
phases: [],
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
// Kept per-phase rather than flattened straight to strings, so the failure
|
|
961
|
+
// can be attributed to the state that broke instead of to both.
|
|
962
|
+
const perPhase = gated.map(({ phase, second }) => {
|
|
963
|
+
const stored = storedIdentityNames(second.cookies);
|
|
964
|
+
const problems = [];
|
|
965
|
+
if (second.network.normalRequestCount > 0) {
|
|
966
|
+
problems.push(`${phase.phase}: the normal bundle sent ${second.network.normalRequestCount} request(s) on ${second.toUrl} even though consent was ${phase.phase === "reject" ? "explicitly refused" : "never given"}`);
|
|
967
|
+
}
|
|
968
|
+
if (stored.length > 0 && identityCookiesSet(phase).length === 0) {
|
|
969
|
+
problems.push(`${phase.phase}: identity storage appeared only after the navigation (${stored.join(", ")}), so the page change itself was treated as consent`);
|
|
970
|
+
}
|
|
971
|
+
return { phase: phase.phase, problems };
|
|
972
|
+
});
|
|
973
|
+
const failures = perPhase.flatMap((p) => p.problems);
|
|
974
|
+
return {
|
|
975
|
+
id,
|
|
976
|
+
title,
|
|
977
|
+
status: failures.length === 0 ? "pass" : "fail",
|
|
978
|
+
detail: failures.length === 0
|
|
979
|
+
? `Navigating to another page did not turn identified tracking on in the ${gated
|
|
980
|
+
.map((w) => w.phase.phase)
|
|
981
|
+
.join(" or ")} phase(s).`
|
|
982
|
+
: `${failures.join("; ")}. A CMP that treats a navigation as implied consent is the usual cause, and no single page load can reveal it.`,
|
|
983
|
+
evidence: gated
|
|
984
|
+
.map(({ phase, second }) => `${phase.phase}.secondPage.normalRequestCount: ${second.network.normalRequestCount}, identity storage: [${storedIdentityNames(second.cookies).join(", ")}]`)
|
|
985
|
+
.join("; "),
|
|
986
|
+
phases: failures.length === 0
|
|
987
|
+
? gated.map((w) => w.phase.phase)
|
|
988
|
+
: perPhase.filter((p) => p.problems.length > 0).map((p) => p.phase),
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Which identity fields a request must carry, decided by the bundle that sent
|
|
993
|
+
* it.
|
|
994
|
+
*
|
|
995
|
+
* The asymmetry is the whole point: a cookieless event carries NO anonymousId
|
|
996
|
+
* by design — identity is resolved server-side — so requiring one of it would
|
|
997
|
+
* report the cookieless bundle working exactly as built as a defect. An
|
|
998
|
+
* unknown mode is held only to the write key, since which bundle's rules
|
|
999
|
+
* apply cannot be read off the payload. hasUserId/hasUserData are optional in
|
|
1000
|
+
* every mode: they depend on the site calling identify(), which this harness
|
|
1001
|
+
* never triggers.
|
|
1002
|
+
*/
|
|
1003
|
+
function requiredIdentityFields(mode) {
|
|
1004
|
+
return mode === "normal" ? ["hasWriteKey", "hasAnonymousId"] : ["hasWriteKey"];
|
|
1005
|
+
}
|
|
1006
|
+
function inspectRequests(phases) {
|
|
1007
|
+
const inspected = [];
|
|
1008
|
+
for (const phase of phases) {
|
|
1009
|
+
const sources = [
|
|
1010
|
+
[phase.network.requests, "first page"],
|
|
1011
|
+
[phase.secondPage?.network.requests ?? [], "second page"],
|
|
1012
|
+
];
|
|
1013
|
+
for (const [requests, where] of sources) {
|
|
1014
|
+
for (const request of requests) {
|
|
1015
|
+
inspected.push({
|
|
1016
|
+
phase: phase.phase,
|
|
1017
|
+
where,
|
|
1018
|
+
mode: request.mode,
|
|
1019
|
+
endpointName: request.endpointName ?? "unknown",
|
|
1020
|
+
missing: requiredIdentityFields(request.mode).filter((field) => !request.identity[field]),
|
|
1021
|
+
status: request.status,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return inspected;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Every event that reached Dreamdata carried the identity its own bundle owes.
|
|
1030
|
+
*
|
|
1031
|
+
* A request missing a field its mode requires is an event the server cannot
|
|
1032
|
+
* attribute — a normal-bundle page view with no anonymousId cannot be joined
|
|
1033
|
+
* to the visitor's other page views, so it lands as an orphan.
|
|
1034
|
+
*
|
|
1035
|
+
* The severity turns on whether ANY request of that mode got it right in the
|
|
1036
|
+
* same phase, and the distinction is load-bearing rather than cosmetic. All
|
|
1037
|
+
* of them malformed means that bundle's identity is genuinely broken and the
|
|
1038
|
+
* events are being lost — a failure. Some malformed alongside well-formed
|
|
1039
|
+
* ones means the page view WAS delivered attributably and something sent an
|
|
1040
|
+
* extra, thinner copy: a real defect that this run proved is not costing the
|
|
1041
|
+
* customer the page view, which is exactly what "warn" is for. Reporting the
|
|
1042
|
+
* second as a failure would sink a run whose tracking demonstrably works.
|
|
1043
|
+
*/
|
|
1044
|
+
function buildRequestPayloadCheck(phases) {
|
|
1045
|
+
const id = "request-payload-complete";
|
|
1046
|
+
const title = "Every event carried the identity its bundle owes";
|
|
1047
|
+
const inspected = inspectRequests(phases);
|
|
1048
|
+
if (inspected.length === 0) {
|
|
1049
|
+
return {
|
|
1050
|
+
id,
|
|
1051
|
+
title,
|
|
1052
|
+
status: "not_applicable",
|
|
1053
|
+
detail: "No request reached Dreamdata in any phase, so there was no payload to inspect. Whether anything should have fired is a question for the consent and core-tracking checks, not this one.",
|
|
1054
|
+
evidence: `requests observed: 0 across ${phases.length} phase(s)`,
|
|
1055
|
+
phases: [],
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
const malformed = inspected.filter((b) => b.missing.length > 0);
|
|
1059
|
+
if (malformed.length === 0) {
|
|
1060
|
+
return {
|
|
1061
|
+
id,
|
|
1062
|
+
title,
|
|
1063
|
+
status: "pass",
|
|
1064
|
+
detail: `All ${inspected.length} event(s) carried the identity fields their bundle requires.`,
|
|
1065
|
+
evidence: `${inspected.length} request(s) inspected; 0 missing a required identity field`,
|
|
1066
|
+
phases: [...new Set(inspected.map((b) => b.phase))],
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
// Per phase AND mode: "the normal bundle never identified anything in the
|
|
1070
|
+
// reject phase" is a different statement from "one of its three events in
|
|
1071
|
+
// the grant phase went out thin", and only the first is a failure.
|
|
1072
|
+
const brokenGroups = [...new Set(malformed.map((b) => `${b.phase}/${b.mode}`))].filter((key) => inspected.filter((b) => `${b.phase}/${b.mode}` === key).every((b) => b.missing.length > 0));
|
|
1073
|
+
const describe = (b) => `${b.phase}/${b.where}: a ${b.mode} ${b.endpointName} event with no ${b.missing.join(" or ")}`;
|
|
1074
|
+
return {
|
|
1075
|
+
id,
|
|
1076
|
+
title,
|
|
1077
|
+
status: brokenGroups.length > 0 ? "fail" : "warn",
|
|
1078
|
+
detail: brokenGroups.length > 0
|
|
1079
|
+
? `${malformed.map(describe).join("; ")}. Every ${brokenGroups.join(" and ")} event this run saw was missing a required field, so those events cannot be attributed to the visitor who generated them.`
|
|
1080
|
+
: `${malformed.map(describe).join("; ")}. The same page view was also delivered with full identity in that phase, so the visitor is attributed and nothing was lost — but something is sending an extra, thinner copy of the event, which is worth removing.`,
|
|
1081
|
+
evidence: malformed
|
|
1082
|
+
.map((b) => `${b.phase}.${b.where === "first page" ? "network" : "secondPage.network"}.requests: ${b.mode} ${b.endpointName} missing ${b.missing.join(", ")}`)
|
|
1083
|
+
.join("; "),
|
|
1084
|
+
phases: [...new Set(malformed.map((b) => b.phase))],
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* What the install said about itself in the console.
|
|
1089
|
+
*
|
|
1090
|
+
* The collector keeps only messages mentioning "dreamdata" (see
|
|
1091
|
+
* attachConsoleCollector), so this is a narrow signal in both directions: a
|
|
1092
|
+
* message here is almost certainly about the install, and an empty list says
|
|
1093
|
+
* nothing whatsoever about the page's overall health. The detail says so
|
|
1094
|
+
* rather than letting "no errors" read as "the page is clean".
|
|
1095
|
+
*
|
|
1096
|
+
* **This check never fails**, whatever severity the message was printed at.
|
|
1097
|
+
* A console message is the install narrating itself; it is not evidence that
|
|
1098
|
+
* anything the customer cares about broke, and every consequence that would
|
|
1099
|
+
* matter is already observed directly somewhere else — a bundle that never
|
|
1100
|
+
* loaded fails script-installed, events that never fired fail the consent
|
|
1101
|
+
* checks, events that were rejected fail request-delivery-accepted. Two runs
|
|
1102
|
+
* settled it: blackline.com prints "Plugin loaded - YOU SHOULD SEE THIS IN
|
|
1103
|
+
* PRODUCTION" through console.error on every page load, and qa.com's install
|
|
1104
|
+
* reports "Failed to enable Dynamics 365 Marketing" — a third-party
|
|
1105
|
+
* destination that was not ready — while its own page views were delivered
|
|
1106
|
+
* and answered 2xx in all three phases. Both sank a report that was otherwise
|
|
1107
|
+
* clean, on a line of text no visitor sees and no event lost.
|
|
1108
|
+
*
|
|
1109
|
+
* So the message is reported, quoted in full, and graded a warning: a real
|
|
1110
|
+
* defect worth fixing that this run showed cost nothing (see CrossPhaseCheck).
|
|
1111
|
+
* describesFailure only decides how it is worded — whether the install is
|
|
1112
|
+
* reporting a failure or narrating itself in production.
|
|
1113
|
+
*
|
|
1114
|
+
* Kept apart from global-handoff, which uses these same messages only to
|
|
1115
|
+
* EXPLAIN a stub-queue failure it detected another way. A site can log
|
|
1116
|
+
* "Dreamdata snippet included twice" while the handoff still completes, and
|
|
1117
|
+
* that is worth reporting on its own.
|
|
1118
|
+
*/
|
|
1119
|
+
function buildConsoleCheck(phases) {
|
|
1120
|
+
const id = "console-clean";
|
|
1121
|
+
const title = "No Dreamdata errors in the console";
|
|
1122
|
+
// Filtered again here, not only in the collector: replay re-scores evidence
|
|
1123
|
+
// JSON collected by an older build, and idtdna.com's corpus entry carries a
|
|
1124
|
+
// CSP error that only matched because the policy it printed allows
|
|
1125
|
+
// cdn.dreamdata.cloud. Re-running the crawl is not a reasonable price for
|
|
1126
|
+
// correcting a false positive in a stored run.
|
|
1127
|
+
const errors = phases.flatMap((p) => p.console.errors.filter(mentionsDreamdata).map((text) => ({ phase: p.phase, text })));
|
|
1128
|
+
const warnings = phases.flatMap((p) => p.console.warnings.filter(mentionsDreamdata).map((text) => ({ phase: p.phase, text })));
|
|
1129
|
+
const logged = [...errors, ...warnings];
|
|
1130
|
+
const quote = (entries) => entries.map((e) => `${e.phase}: "${e.text}"`).join("; ");
|
|
1131
|
+
if (logged.length > 0) {
|
|
1132
|
+
// Severity is the site's choice and a poor guide to it: blackline.com's
|
|
1133
|
+
// announcement arrives at error level, and plenty of real trouble arrives
|
|
1134
|
+
// as a warning. What the message SAYS is the better reading, and it only
|
|
1135
|
+
// shapes the sentence — never the status.
|
|
1136
|
+
const reported = errors.filter((e) => describesFailure(e.text));
|
|
1137
|
+
const chatter = errors.filter((e) => !describesFailure(e.text));
|
|
1138
|
+
const parts = [
|
|
1139
|
+
`The install logged ${logged.length} Dreamdata message(s) to the console: ${quote(logged)}.`,
|
|
1140
|
+
reported.length > 0
|
|
1141
|
+
? `${reported.length} of them report something the install could not do.`
|
|
1142
|
+
: "",
|
|
1143
|
+
chatter.length > 0
|
|
1144
|
+
? `${chatter.length} ${chatter.length === 1 ? "is" : "are"} printed at error level but report nothing going wrong — debug output left on in production.`
|
|
1145
|
+
: "",
|
|
1146
|
+
"Nothing in this run showed tracking failing because of any of them; delivery, consent and identity are judged by their own checks.",
|
|
1147
|
+
];
|
|
1148
|
+
return {
|
|
1149
|
+
id,
|
|
1150
|
+
title,
|
|
1151
|
+
status: "warn",
|
|
1152
|
+
detail: parts.filter((part) => part !== "").join(" "),
|
|
1153
|
+
evidence: quote(logged),
|
|
1154
|
+
phases: [...new Set(logged.map((e) => e.phase))],
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
id,
|
|
1159
|
+
title,
|
|
1160
|
+
status: "pass",
|
|
1161
|
+
detail: "No Dreamdata error or warning was logged in any phase. Only messages mentioning Dreamdata are captured, so this says nothing about the page's other scripts.",
|
|
1162
|
+
evidence: `console.errors and console.warnings empty across ${phases.length} phase(s)`,
|
|
1163
|
+
phases: phases.map((p) => p.phase),
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Everything this run set out to observe, it observed.
|
|
1168
|
+
*
|
|
1169
|
+
* The other half of debugging-signals. console-clean speaks for what the
|
|
1170
|
+
* install printed; this speaks for what the harness itself could not reach —
|
|
1171
|
+
* a phase that timed out, a banner it failed to drive, a navigation it gave
|
|
1172
|
+
* up on. Those are limits of the observation, never a defect of the site, so
|
|
1173
|
+
* this check never fails: it is "inconclusive", which says the run saw less
|
|
1174
|
+
* than it meant to without pretending the site did something wrong.
|
|
1175
|
+
*
|
|
1176
|
+
* It exists so debugging-signals is decided by checks rather than by the
|
|
1177
|
+
* model's reading (see CHECK_DECIDED_ITEMS). Without it, the one thing left
|
|
1178
|
+
* for the model to score the item down for was a collection error, and it had
|
|
1179
|
+
* to be trusted to tell that apart from a broken install.
|
|
1180
|
+
*/
|
|
1181
|
+
function buildCollectionCompleteCheck(phases) {
|
|
1182
|
+
const id = "collection-complete";
|
|
1183
|
+
const title = "The run observed everything it set out to";
|
|
1184
|
+
const errors = phases.flatMap((p) => p.collectionErrors.map((text) => ({ phase: p.phase, text })));
|
|
1185
|
+
if (errors.length > 0) {
|
|
1186
|
+
return {
|
|
1187
|
+
id,
|
|
1188
|
+
title,
|
|
1189
|
+
status: "inconclusive",
|
|
1190
|
+
detail: `The harness logged ${errors.length} collection error(s): ${errors.map((e) => `${e.phase}: "${e.text}"`).join("; ")}. These limit what this run could see; they say nothing about the install.`,
|
|
1191
|
+
evidence: errors.map((e) => `${e.phase}.collectionErrors: "${e.text}"`).join("; "),
|
|
1192
|
+
phases: [...new Set(errors.map((e) => e.phase))],
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
return {
|
|
1196
|
+
id,
|
|
1197
|
+
title,
|
|
1198
|
+
status: "pass",
|
|
1199
|
+
detail: "No phase logged a collection error, so nothing in the report is limited by something the harness failed to reach.",
|
|
1200
|
+
evidence: `collectionErrors empty across ${phases.length} phase(s)`,
|
|
1201
|
+
phases: phases.map((p) => p.phase),
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* The events went to the site's own domain, and the writeKey proves they are
|
|
1206
|
+
* this install's.
|
|
1207
|
+
*
|
|
1208
|
+
* respond.io delivers to dd.respond.io, a first-party CNAME onto Dreamdata.
|
|
1209
|
+
* Nothing in the URL says so, and the run that could not see it reported "no
|
|
1210
|
+
* request reached Dreamdata in any phase" about a site whose every page view
|
|
1211
|
+
* was being delivered and accepted — the worst kind of wrong a verification
|
|
1212
|
+
* report can be. The collector now claims a Segment-shaped request on any host
|
|
1213
|
+
* when it carries the same writeKey the installed bundle does (see
|
|
1214
|
+
* attributeTo); this check is where that claim is stated out loud, because a
|
|
1215
|
+
* host the run decided to trust on its own should never be something the
|
|
1216
|
+
* reader has to reverse-engineer from a URL in the request log.
|
|
1217
|
+
*
|
|
1218
|
+
* The leftovers matter too, and differently:
|
|
1219
|
+
* - No key anywhere in the page (a bundled npm install) means there was
|
|
1220
|
+
* nothing to match against, so a real first-party host would look exactly
|
|
1221
|
+
* like this. That is a warn naming --tracking-host, not a silent pass.
|
|
1222
|
+
* - A key that simply differs is somebody else's Segment-shaped collector.
|
|
1223
|
+
* Not this install, and not this check's business.
|
|
1224
|
+
*/
|
|
1225
|
+
function buildFirstPartyDeliveryCheck(phases, install) {
|
|
1226
|
+
const id = "first-party-delivery-host";
|
|
1227
|
+
const title = "Events delivered to a first-party domain";
|
|
1228
|
+
const hostOf = (url) => {
|
|
1229
|
+
try {
|
|
1230
|
+
return new URL(url).hostname;
|
|
1231
|
+
}
|
|
1232
|
+
catch {
|
|
1233
|
+
return url;
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
// A host the operator named with --tracking-host is not a discovery: the run
|
|
1237
|
+
// was told to watch it, and saying the writeKey is what claimed it would
|
|
1238
|
+
// credit this check with work the flag did.
|
|
1239
|
+
const configured = [...new Set(phases.flatMap((p) => p.network.trackingHosts))];
|
|
1240
|
+
const offHost = (requests) => requests.map((r) => hostOf(r.url)).filter((host) => !isDreamdataUrl(`https://${host}/`, configured));
|
|
1241
|
+
const claimed = [...new Set(phases.flatMap((p) => offHost(p.network.requests)))];
|
|
1242
|
+
const leftover = phases.flatMap((p) => p.network.unattributedRequests);
|
|
1243
|
+
const leftoverHosts = [...new Set(leftover.map((b) => hostOf(b.url)))];
|
|
1244
|
+
if (claimed.length > 0) {
|
|
1245
|
+
return {
|
|
1246
|
+
id,
|
|
1247
|
+
title,
|
|
1248
|
+
status: "pass",
|
|
1249
|
+
detail: `Events are delivered to ${claimed.join(", ")} rather than to a Dreamdata host directly — a first-party CNAME. They are counted as this install's because every one carries writeKey ${install.writeKey}, the key the installed bundle itself uses.`,
|
|
1250
|
+
evidence: `network.requests hosts: ${claimed.join(", ")}; install.writeKey: ${install.writeKey ?? "null"}`,
|
|
1251
|
+
phases: phases.filter((p) => offHost(p.network.requests).length > 0).map((p) => p.phase),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
if (leftover.length > 0 && install.writeKey === null) {
|
|
1255
|
+
return {
|
|
1256
|
+
id,
|
|
1257
|
+
title,
|
|
1258
|
+
status: "warn",
|
|
1259
|
+
detail: `${leftover.length} request(s) with the right shape went to ${leftoverHosts.join(", ")}, but this install exposes no writeKey anywhere in the page, so nothing can confirm they are Dreamdata's rather than another Segment-compatible tool's. Re-run with --tracking-host ${leftoverHosts[0]} if that host is yours.`,
|
|
1260
|
+
evidence: `${leftoverHosts.join(", ")}; install.writeKey: null`,
|
|
1261
|
+
phases: [...new Set(phases.filter((p) => p.network.unattributedRequests.length > 0).map((p) => p.phase))],
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
if (leftover.length > 0) {
|
|
1265
|
+
return {
|
|
1266
|
+
id,
|
|
1267
|
+
title,
|
|
1268
|
+
status: "not_applicable",
|
|
1269
|
+
detail: `${leftover.length} Segment-shaped request(s) went to ${leftoverHosts.join(", ")} carrying a different writeKey than this install's (${install.writeKey}), so they belong to another tool and are not counted here.`,
|
|
1270
|
+
evidence: `${leftoverHosts.join(", ")} carry a writeKey other than ${install.writeKey}`,
|
|
1271
|
+
phases: [...new Set(phases.filter((p) => p.network.unattributedRequests.length > 0).map((p) => p.phase))],
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
id,
|
|
1276
|
+
title,
|
|
1277
|
+
status: "not_applicable",
|
|
1278
|
+
detail: "Every request this run saw went to a host it was already watching, so no first-party delivery domain had to be recognised.",
|
|
1279
|
+
evidence: `no request outside the watched hosts (${["Dreamdata-owned", ...configured].join(", ")})`,
|
|
1280
|
+
phases: phases.map((p) => p.phase),
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Dreamdata accepted the events the site sent it.
|
|
1285
|
+
*
|
|
1286
|
+
* A request leaving the browser is not the same as an event being recorded:
|
|
1287
|
+
* the request can be answered 400 for a write key the account does not
|
|
1288
|
+
* recognise, 403, or 5xx. Every other check in this file reads the REQUEST —
|
|
1289
|
+
* whether a bundle fired, what it carried, when — and would report a site
|
|
1290
|
+
* whose every event is being rejected as tracking perfectly, because from the
|
|
1291
|
+
* page's side it is. This is the one that reads the answer.
|
|
1292
|
+
*
|
|
1293
|
+
* A request whose response never came back is not counted either way: the page
|
|
1294
|
+
* navigated away or the phase ended before it resolved, which is a limit of
|
|
1295
|
+
* the observation and not a verdict about the server.
|
|
1296
|
+
*/
|
|
1297
|
+
function buildRequestDeliveryCheck(phases) {
|
|
1298
|
+
const id = "request-delivery-accepted";
|
|
1299
|
+
const title = "Dreamdata accepted the events it was sent";
|
|
1300
|
+
const answered = inspectRequests(phases).filter((b) => b.status !== undefined);
|
|
1301
|
+
if (answered.length === 0) {
|
|
1302
|
+
return {
|
|
1303
|
+
id,
|
|
1304
|
+
title,
|
|
1305
|
+
status: "not_applicable",
|
|
1306
|
+
detail: "No event's response was observed in any phase, so whether Dreamdata accepted anything could not be read. That is a limit of what this run could see, not a statement about the server.",
|
|
1307
|
+
evidence: "requests with an observed response status: 0",
|
|
1308
|
+
phases: [],
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
const rejected = answered.filter((b) => b.status < 200 || b.status >= 300);
|
|
1312
|
+
return {
|
|
1313
|
+
id,
|
|
1314
|
+
title,
|
|
1315
|
+
status: rejected.length === 0 ? "pass" : "fail",
|
|
1316
|
+
detail: rejected.length === 0
|
|
1317
|
+
? `All ${answered.length} event(s) whose response was observed were accepted (2xx).`
|
|
1318
|
+
: `${rejected
|
|
1319
|
+
.map((b) => `${b.phase}/${b.where}: a ${b.mode} ${b.endpointName} event was answered HTTP ${b.status}`)
|
|
1320
|
+
.join("; ")}. Those events left the browser but were not recorded, so the site looks instrumented from the page's side while Dreamdata receives nothing. A write key the account does not recognise is the usual cause of a 4xx here.`,
|
|
1321
|
+
evidence: rejected.length === 0
|
|
1322
|
+
? `${answered.length} request(s) answered 2xx`
|
|
1323
|
+
: rejected.map((b) => `${b.phase}: ${b.mode} ${b.endpointName} status ${b.status}`).join("; "),
|
|
1324
|
+
phases: rejected.length === 0 ? [...new Set(answered.map((b) => b.phase))] : [...new Set(rejected.map((b) => b.phase))],
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
/**
|
|
1328
|
+
* The visitor's anonymous id, wherever this install keeps it.
|
|
1329
|
+
*
|
|
1330
|
+
* Cookie first, then localStorage: a bundle configured for localStorage
|
|
1331
|
+
* storage keeps no cookie at all, and reading only the cookie would report
|
|
1332
|
+
* that install as having no id rather than as storing it elsewhere.
|
|
1333
|
+
*
|
|
1334
|
+
* The value is normalised before anyone compares two of them, because the
|
|
1335
|
+
* SAME id genuinely arrives in three different spellings: JSON-quoted in
|
|
1336
|
+
* localStorage (`"abc"`), percent-encoded in a cookie (`%22abc%22`), and bare
|
|
1337
|
+
* (`abc`). manatal.com serves the entry page's cookie percent-encoded and the
|
|
1338
|
+
* second page's bare, so a comparison on the raw strings reports a stable id
|
|
1339
|
+
* as having changed — a defect this harness would have manufactured for
|
|
1340
|
+
* itself. Decode first, then strip the quotes.
|
|
1341
|
+
*/
|
|
1342
|
+
function anonymousIdOf(storage) {
|
|
1343
|
+
const unquote = (value) => {
|
|
1344
|
+
let decoded = value;
|
|
1345
|
+
try {
|
|
1346
|
+
decoded = decodeURIComponent(value);
|
|
1347
|
+
}
|
|
1348
|
+
catch {
|
|
1349
|
+
// A malformed escape is not an id worth rewriting; compare it as-is.
|
|
1350
|
+
}
|
|
1351
|
+
return decoded.replace(/^"|"$/g, "");
|
|
1352
|
+
};
|
|
1353
|
+
const candidates = [
|
|
1354
|
+
storage.values?.["dd_anonymous_id"],
|
|
1355
|
+
storage.ajsValues?.["ajs_anonymous_id"],
|
|
1356
|
+
storage.localStorageValues?.["dd_anonymous_id"],
|
|
1357
|
+
storage.ajsLocalStorageValues?.["ajs_anonymous_id"],
|
|
1358
|
+
];
|
|
1359
|
+
for (const value of candidates) {
|
|
1360
|
+
if (typeof value === "string" && value !== "")
|
|
1361
|
+
return unquote(value);
|
|
1362
|
+
}
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* One visitor must stay one visitor across a navigation.
|
|
1367
|
+
*
|
|
1368
|
+
* An anonymous id that changes between the entry page and the page the phase
|
|
1369
|
+
* walked to splits a single session into two visitors: the two page views
|
|
1370
|
+
* cannot be stitched together, every downstream journey is cut in half, and
|
|
1371
|
+
* nothing about a single page load can reveal it — the entry page looks
|
|
1372
|
+
* perfect, and so does the second one in isolation.
|
|
1373
|
+
*
|
|
1374
|
+
* Deliberately narrow. The id APPEARING only on the second page is a consent
|
|
1375
|
+
* question, not a continuity one, and second-page-honours-consent already
|
|
1376
|
+
* asks it; judging it here too would report one defect as two. This check
|
|
1377
|
+
* only ever fires when both pages had an id and the id is not the same one.
|
|
1378
|
+
*/
|
|
1379
|
+
function buildAnonymousIdStableCheck(phases, walked) {
|
|
1380
|
+
const id = "anonymous-id-stable";
|
|
1381
|
+
const title = "The anonymous id survives a page change";
|
|
1382
|
+
const compared = walked
|
|
1383
|
+
.map(({ phase, second }) => ({
|
|
1384
|
+
phase: phase.phase,
|
|
1385
|
+
first: anonymousIdOf(phase.cookies),
|
|
1386
|
+
next: anonymousIdOf(second.cookies),
|
|
1387
|
+
toUrl: second.toUrl,
|
|
1388
|
+
}))
|
|
1389
|
+
.filter((c) => c.first !== null && c.next !== null);
|
|
1390
|
+
if (compared.length === 0) {
|
|
1391
|
+
const anyIdAnywhere = phases.some((p) => anonymousIdOf(p.cookies) !== null);
|
|
1392
|
+
return {
|
|
1393
|
+
id,
|
|
1394
|
+
title,
|
|
1395
|
+
status: anyIdAnywhere ? "inconclusive" : "not_applicable",
|
|
1396
|
+
detail: anyIdAnywhere
|
|
1397
|
+
? "No phase reached a second page while holding an anonymous id on both sides of the navigation, so whether the id survives a page change was not observed."
|
|
1398
|
+
: "No anonymous id was stored in any phase — expected for a cookieless-by-design install, and for any phase where consent was withheld — so there is no id whose stability could be judged.",
|
|
1399
|
+
evidence: walked.length === 0
|
|
1400
|
+
? "no phase reached a second page"
|
|
1401
|
+
: walked
|
|
1402
|
+
.map(({ phase, second }) => `${phase.phase}: first ${anonymousIdOf(phase.cookies) ?? "none"}, second ${anonymousIdOf(second.cookies) ?? "none"}`)
|
|
1403
|
+
.join("; "),
|
|
1404
|
+
phases: walked.map((w) => w.phase.phase),
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
const changed = compared.filter((c) => c.first !== c.next);
|
|
1408
|
+
return {
|
|
1409
|
+
id,
|
|
1410
|
+
title,
|
|
1411
|
+
status: changed.length === 0 ? "pass" : "fail",
|
|
1412
|
+
detail: changed.length === 0
|
|
1413
|
+
? `The anonymous id was the same before and after the navigation in the ${compared.map((c) => c.phase).join(" and ")} phase(s), so both page views belong to one visitor.`
|
|
1414
|
+
: `${changed
|
|
1415
|
+
.map((c) => `in the ${c.phase} phase the anonymous id changed from ${c.first} to ${c.next} on the way to ${c.toUrl}`)
|
|
1416
|
+
.join("; ")}. One visitor is being recorded as two, so their page views cannot be stitched into a single journey. A bundle re-initialising with a fresh id, or an id written to a storage the next page cannot read back (a cookie scoped to the wrong path or subdomain), are the usual causes.`,
|
|
1417
|
+
evidence: compared
|
|
1418
|
+
.map((c) => `${c.phase}: cookies ${c.first} -> secondPage ${c.next}`)
|
|
1419
|
+
.join("; "),
|
|
1420
|
+
phases: changed.length === 0 ? compared.map((c) => c.phase) : changed.map((c) => c.phase),
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
/** The checks that need a second page view, and so a navigation, to exist at all. */
|
|
1424
|
+
function buildSecondPageChecks(phases) {
|
|
1425
|
+
const walked = phases
|
|
1426
|
+
.filter((phase) => secondPageObserved(phase.secondPage))
|
|
1427
|
+
.map((phase) => ({ phase, second: phase.secondPage }));
|
|
1428
|
+
return [
|
|
1429
|
+
buildContinuityCheck("hard", phases, walked.filter((w) => w.second.kind === "hard")),
|
|
1430
|
+
buildContinuityCheck("soft", phases, walked.filter((w) => w.second.kind === "soft")),
|
|
1431
|
+
buildSecondPageConsentCheck(phases, walked),
|
|
1432
|
+
buildAnonymousIdStableCheck(phases, walked),
|
|
1433
|
+
];
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* True when a real consent gate exists on the page but every attempt to
|
|
1437
|
+
* answer it (grant and/or reject, whichever ran) failed to actually drive
|
|
1438
|
+
* it. Distinct from "no CMP" (nothing to drive, so silence is expected) and
|
|
1439
|
+
* from a phase simply not being requested (phasesMissing already covers
|
|
1440
|
+
* that): this is specifically "a banner was seen, and this run could not get
|
|
1441
|
+
* past it", which means any conclusion resting on "the normal bundle never
|
|
1442
|
+
* showed up" — cookieless-by-design, nothing-installed — was never actually
|
|
1443
|
+
* tested against the answered state and cannot be trusted.
|
|
1444
|
+
*/
|
|
1445
|
+
function isConsentGateUndetermined(phases) {
|
|
1446
|
+
const nonClean = phases.filter((p) => p.phase !== "clean");
|
|
1447
|
+
if (nonClean.length === 0)
|
|
1448
|
+
return false;
|
|
1449
|
+
const anyCmpDetected = phases.some((p) => p.consent.cmpDetected);
|
|
1450
|
+
if (!anyCmpDetected)
|
|
1451
|
+
return false;
|
|
1452
|
+
return nonClean.every((p) => p.consentAcceptance.attempted && !p.consentAcceptance.succeeded);
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* True when this phase's browser found NOTHING — not "nothing Dreamdata", but
|
|
1456
|
+
* nothing at all.
|
|
1457
|
+
*
|
|
1458
|
+
* Every clause on its own is a normal thing to see on some real site, which is
|
|
1459
|
+
* why the composite matters: a site can genuinely have no Dreamdata tag, or no
|
|
1460
|
+
* consent banner, or (rarely) no followable internal link. A page with none of
|
|
1461
|
+
* them, no bundle global on window, and not one request to any host we watch
|
|
1462
|
+
* is not a site — it is a document with no site in it.
|
|
1463
|
+
*
|
|
1464
|
+
* Strictly the BROWSER's own evidence. Notably NOT
|
|
1465
|
+
* script.tagManager.containerIds, which by this point may hold ids the
|
|
1466
|
+
* plain-HTTP probe supplied (see scanTagManagerContainers) — using a field the
|
|
1467
|
+
* probe feeds would let the corroboration corroborate itself, and the
|
|
1468
|
+
* comparison below is the whole basis of the call.
|
|
1469
|
+
*/
|
|
1470
|
+
function phaseFoundNothing(phase) {
|
|
1471
|
+
return (!phase.script.present &&
|
|
1472
|
+
phase.script.matches.length === 0 &&
|
|
1473
|
+
!phase.globals.dreamdataExists &&
|
|
1474
|
+
!phase.globals.cookielessGlobalExists &&
|
|
1475
|
+
!phase.globals.segmentCompatGlobalExists &&
|
|
1476
|
+
!phase.consent.cmpDetected &&
|
|
1477
|
+
phase.network.requests.length === 0 &&
|
|
1478
|
+
(phase.secondPage === null || !phase.secondPage.urlChanged));
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Decides whether the browser was ever shown the real page — see
|
|
1482
|
+
* PageReadability for why this has to be asked before anything else.
|
|
1483
|
+
*
|
|
1484
|
+
* Asserted only on a CONTRADICTION, never on the browser's emptiness alone.
|
|
1485
|
+
* "The browser found nothing" is the ordinary shape of a site with nothing
|
|
1486
|
+
* installed and must stay reportable as one; what makes it a blocked run is
|
|
1487
|
+
* the same URL, fetched without a browser, coming back with the site in it.
|
|
1488
|
+
* With no probe to compare against, this stays false and the run reports what
|
|
1489
|
+
* it saw — a false negative is better than a fabricated excuse for one.
|
|
1490
|
+
*/
|
|
1491
|
+
function assessPageReadability(phases, httpProbe) {
|
|
1492
|
+
const blind = phases.filter(phaseFoundNothing);
|
|
1493
|
+
if (blind.length === 0 || phases.length === 0) {
|
|
1494
|
+
return {
|
|
1495
|
+
unreadable: false,
|
|
1496
|
+
reason: "The browser found page content in at least one phase, so it was served the site.",
|
|
1497
|
+
signals: [],
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
const blindLabel = blind.length === phases.length ? "every phase" : `the ${blind.map((p) => p.phase).join(", ")} phase(s)`;
|
|
1501
|
+
if (httpProbe === null || !httpProbe.attempted) {
|
|
1502
|
+
return {
|
|
1503
|
+
unreadable: false,
|
|
1504
|
+
reason: `${blindLabel} found no script, no tag-manager container, no consent banner, no request and no second page — but no plain-HTTP fetch was taken, so there is nothing to tell "nothing is installed" apart from "the browser was served a different page". Re-run without --no-http-probe.`,
|
|
1505
|
+
signals: [],
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
if (httpProbe.failedReason !== null) {
|
|
1509
|
+
return {
|
|
1510
|
+
unreadable: false,
|
|
1511
|
+
reason: `${blindLabel} found nothing at all, and the plain-HTTP fetch that would corroborate it also failed (${httpProbe.failedReason}), so this run cannot say whether the site is empty or the browser was turned away.`,
|
|
1512
|
+
signals: [],
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
// A refusal is a finding, not a missing one. The browser found nothing and
|
|
1516
|
+
// the plain HTTP GET was turned away outright — two independent clients,
|
|
1517
|
+
// both refused, which is what a host defending itself against automation
|
|
1518
|
+
// looks like and is not what a site with nothing installed looks like. This
|
|
1519
|
+
// is the branch zoominfo.com lands on when the wall extends to the probe:
|
|
1520
|
+
// asserting "nothing is installed" off the back of two refusals is the
|
|
1521
|
+
// false negative this whole assessment exists to prevent.
|
|
1522
|
+
const status = httpProbe.status;
|
|
1523
|
+
if (status !== null && (status < 200 || status >= 300)) {
|
|
1524
|
+
return {
|
|
1525
|
+
unreadable: true,
|
|
1526
|
+
reason: `${blindLabel} found no script, no tag-manager container, no consent banner, no request and no second page, ` +
|
|
1527
|
+
`and a plain HTTP GET of the same URL was refused as well (HTTP ${status}, ${httpProbe.bytes} bytes — a body far too small to be the site). ` +
|
|
1528
|
+
"Both clients were turned away, so this run never reached this site's page and nothing here is a finding about its tracking. " +
|
|
1529
|
+
"A --headed re-run is worth one try, but a behavioural challenge (a press-and-hold control, a \"verify you are human\" step) " +
|
|
1530
|
+
"usually survives it — expect to confirm this install in a real browser by hand.",
|
|
1531
|
+
signals: [
|
|
1532
|
+
`the plain HTTP GET was answered HTTP ${status} with a ${httpProbe.bytes}-byte body rather than the site's page`,
|
|
1533
|
+
],
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
// What the served document has that the rendered page did not. Each of
|
|
1537
|
+
// these alone is the same page answering the same question two ways.
|
|
1538
|
+
const signals = [];
|
|
1539
|
+
if (httpProbe.sameOriginLinkCount > 0) {
|
|
1540
|
+
signals.push(`a plain HTTP GET of the same URL was served ${httpProbe.sameOriginLinkCount} same-origin link(s), while the rendered page had none to walk to`);
|
|
1541
|
+
}
|
|
1542
|
+
if (httpProbe.tagManagerContainerIds.length > 0) {
|
|
1543
|
+
signals.push(`the served markup names tag-manager container(s) ${httpProbe.tagManagerContainerIds.join(", ")}, which the rendered page did not`);
|
|
1544
|
+
}
|
|
1545
|
+
if (httpProbe.cmpMarkers.length > 0) {
|
|
1546
|
+
signals.push(`the served markup loads ${httpProbe.cmpMarkers.join(", ")}, while no consent platform was detected on the rendered page`);
|
|
1547
|
+
}
|
|
1548
|
+
if (httpProbe.script?.present) {
|
|
1549
|
+
signals.push("the served markup contains a Dreamdata tag that was not in the rendered DOM");
|
|
1550
|
+
}
|
|
1551
|
+
if (signals.length === 0) {
|
|
1552
|
+
return {
|
|
1553
|
+
unreadable: false,
|
|
1554
|
+
reason: `${blindLabel} found nothing, and a plain HTTP GET of the same URL (HTTP ${httpProbe.status}) found nothing either — the two agree, so this reads as a genuinely empty page rather than a blocked run.`,
|
|
1555
|
+
signals: [],
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
return {
|
|
1559
|
+
unreadable: true,
|
|
1560
|
+
reason: `${blindLabel} found no script, no tag-manager container, no consent banner, no request and no second page, ` +
|
|
1561
|
+
`yet a plain HTTP GET of the same URL (HTTP ${httpProbe.status}, ${httpProbe.bytes} bytes) was served a real page. ` +
|
|
1562
|
+
"The browser was shown something else — a bot wall's challenge document is the usual cause — so this run observed " +
|
|
1563
|
+
"no page belonging to this site and cannot judge its tracking. A --headed re-run is worth one try, but a " +
|
|
1564
|
+
"behavioural challenge (a press-and-hold control, a \"verify you are human\" step) usually survives it, so expect " +
|
|
1565
|
+
"to confirm this in a real browser by hand. What IS installed was still recovered, from the served markup and the " +
|
|
1566
|
+
"tag-manager container it names — that part of the report the wall did not cost.",
|
|
1567
|
+
signals,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Every reason to believe a REAL (non-Dreamdata) Segment install is on this
|
|
1572
|
+
* page, across phases — the clio_com case: Segment forwards to Dreamdata
|
|
1573
|
+
* server-to-server, so no Dreamdata script, global or request ever appears,
|
|
1574
|
+
* and this is the only client-side trace such a pipeline leaves.
|
|
1575
|
+
*
|
|
1576
|
+
* The tag-manager container matters as much as the DOM/request here, and for
|
|
1577
|
+
* the same reason it matters for Dreamdata's own bundles: clio.com ships its
|
|
1578
|
+
* Segment tag from GTM container GTM-TXX2PT8, fired on its own trigger, so a
|
|
1579
|
+
* bounded crawl that never satisfies that trigger sees nothing in the live
|
|
1580
|
+
* DOM or on the wire even though nothing is gating it on consent — reading
|
|
1581
|
+
* the published container is the only way to see it at all.
|
|
1582
|
+
*/
|
|
1583
|
+
function thirdPartySegmentSignals(phases) {
|
|
1584
|
+
const signals = [];
|
|
1585
|
+
const seenContainers = new Set();
|
|
1586
|
+
for (const phase of phases) {
|
|
1587
|
+
// Older evidence (collected before this signal existed) has no
|
|
1588
|
+
// `thirdPartySegment` field at all — that is "not collected", not "no
|
|
1589
|
+
// signal found", but it must not crash the offline replay of that
|
|
1590
|
+
// evidence either. Skip the phase rather than fabricate a value.
|
|
1591
|
+
const seg = phase.thirdPartySegment;
|
|
1592
|
+
if (!seg)
|
|
1593
|
+
continue;
|
|
1594
|
+
if (seg.scriptDetected) {
|
|
1595
|
+
signals.push(`${phase.phase}: <script src=${seg.scriptSrc}> loads Segment's own library, not a Dreamdata bundle`);
|
|
1596
|
+
}
|
|
1597
|
+
if (seg.requestCount > 0) {
|
|
1598
|
+
signals.push(`${phase.phase}: ${seg.requestCount} request(s) sent to Segment's own collection endpoint (api.segment.io)`);
|
|
1599
|
+
}
|
|
1600
|
+
const tagManager = phase.script.tagManager;
|
|
1601
|
+
if (tagManager.thirdPartySegmentTagFound) {
|
|
1602
|
+
const key = tagManager.containerIds.join(",");
|
|
1603
|
+
if (!seenContainers.has(key)) {
|
|
1604
|
+
seenContainers.add(key);
|
|
1605
|
+
signals.push(`GTM container ${tagManager.containerIds.join(", ")} ships a tag loading Segment's own library (cdn.segment.com), not a Dreamdata tag`);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return signals;
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Reconciles the per-phase navigation observations into one statement, and
|
|
1613
|
+
* subordinates the served markup to them.
|
|
1614
|
+
*
|
|
1615
|
+
* Behaviour decides, in this order, and a marker only ever changes the
|
|
1616
|
+
* wording — except in branch 3, where there is no behaviour left to describe.
|
|
1617
|
+
*/
|
|
1618
|
+
function classifyRouting(phases, httpProbe) {
|
|
1619
|
+
const markers = httpProbe?.frameworkMarkers ?? [];
|
|
1620
|
+
const named = markers.join(", ");
|
|
1621
|
+
const softPhases = phases.filter((p) => p.secondPage?.kind === "soft").map((p) => p.phase);
|
|
1622
|
+
const hardPhases = phases.filter((p) => p.secondPage?.kind === "hard").map((p) => p.phase);
|
|
1623
|
+
const pathsFollowed = softPhases.length + hardPhases.length;
|
|
1624
|
+
const base = { softPhases, hardPhases, frameworkMarkers: markers, pathsFollowed };
|
|
1625
|
+
// One observed router proves a router exists, so a mixed run resolves here
|
|
1626
|
+
// — but the reason names both lists rather than hiding the hard phases,
|
|
1627
|
+
// because a consent-gated router would look exactly like this.
|
|
1628
|
+
if (softPhases.length > 0) {
|
|
1629
|
+
const mixed = hardPhases.length > 0 ? ` The ${hardPhases.join(", ")} phase(s) loaded a new document for the link they followed.` : "";
|
|
1630
|
+
const corroboration = markers.length > 0 ? ` The served markup names ${named}.` : "";
|
|
1631
|
+
return {
|
|
1632
|
+
...base,
|
|
1633
|
+
mode: "client-routed",
|
|
1634
|
+
observed: true,
|
|
1635
|
+
reason: `The site's own router handled the link this run followed in the ${softPhases.join(", ")} phase(s) without loading a new document.${mixed}${corroboration}`,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
if (hardPhases.length > 0) {
|
|
1639
|
+
// The honesty guarantee, and the corpus contains the site that needs it:
|
|
1640
|
+
// yougov.com serves ng-version, ships the Angular router, and loaded a
|
|
1641
|
+
// new document for every link followed. The marker is reported and then
|
|
1642
|
+
// explicitly denied any authority over what was observed.
|
|
1643
|
+
const corroboration = markers.length > 0
|
|
1644
|
+
? ` The served markup names ${named}, which does not by itself mean client-side routing.`
|
|
1645
|
+
: "";
|
|
1646
|
+
return {
|
|
1647
|
+
...base,
|
|
1648
|
+
mode: "document-loads",
|
|
1649
|
+
observed: true,
|
|
1650
|
+
reason: `Every link this run followed loaded a new document (the ${hardPhases.join(", ")} phase(s)).${corroboration}`,
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
if (markers.length > 0) {
|
|
1654
|
+
return {
|
|
1655
|
+
...base,
|
|
1656
|
+
mode: "client-routed-suspected",
|
|
1657
|
+
observed: false,
|
|
1658
|
+
reason: `No navigation was classified in any phase, so how this site changes pages was not observed. The served markup names ${named}, which ships a client-side router — a suspicion about the markup, not a finding about behaviour.`,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
return {
|
|
1662
|
+
...base,
|
|
1663
|
+
mode: "unobserved",
|
|
1664
|
+
observed: false,
|
|
1665
|
+
reason: "No navigation was classified in any phase and the served markup names no framework, so how this site changes pages was not observed.",
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function classifyShape(phases, normalBundleConfigured, cookielessBundleConfigured) {
|
|
1669
|
+
const anyPresent = phases.some((p) => p.script.present);
|
|
1670
|
+
const anyRequest = phases.some((p) => p.network.requests.length > 0);
|
|
1671
|
+
if (!anyPresent && !anyRequest && !normalBundleConfigured && !cookielessBundleConfigured) {
|
|
1672
|
+
return {
|
|
1673
|
+
shape: "nothing-installed",
|
|
1674
|
+
shapeReason: "No Dreamdata bundle, request or tag-manager tag was found in any phase.",
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
if (!normalBundleConfigured && !cookielessBundleConfigured && phases.some((p) => p.script.helperScriptOnly)) {
|
|
1678
|
+
return {
|
|
1679
|
+
shape: "helper-only",
|
|
1680
|
+
shapeReason: "The only Dreamdata script found was a helper (identify-form), not a tracking bundle.",
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
if (normalBundleConfigured && cookielessBundleConfigured) {
|
|
1684
|
+
return {
|
|
1685
|
+
shape: "consent-gated-dual",
|
|
1686
|
+
shapeReason: "Both bundles belong to this deployment, so which one runs is expected to follow the visitor's consent choice.",
|
|
1687
|
+
};
|
|
1688
|
+
}
|
|
1689
|
+
if (normalBundleConfigured) {
|
|
1690
|
+
// Said with the container's authority when there is one: "only the
|
|
1691
|
+
// standard bundle is installed" reads as a gap on its own, and on a
|
|
1692
|
+
// tag-manager install it is a published configuration.
|
|
1693
|
+
const byDesign = cookielessAbsentByDesign(phases);
|
|
1694
|
+
return {
|
|
1695
|
+
shape: "normal-only",
|
|
1696
|
+
shapeReason: byDesign
|
|
1697
|
+
? `Only the standard cookie-based bundle is installed, by configuration: the published tag-manager ${byDesign.containers} ships its tag and no cookieless one.`
|
|
1698
|
+
: "Only the standard cookie-based bundle is installed.",
|
|
1699
|
+
};
|
|
1700
|
+
}
|
|
1701
|
+
return {
|
|
1702
|
+
shape: "cookieless-by-design",
|
|
1703
|
+
shapeReason: "Only the cookieless bundle is installed: no normal-bundle tag, tag-manager tag, blocked tag, loader companion reference or request was found in any phase.",
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* The page URL a `page` request reported, or null if this is not one.
|
|
1708
|
+
*
|
|
1709
|
+
* Scoped to the dedicated endpoint on purpose: a `batch` envelope can carry
|
|
1710
|
+
* page events too, but reading duplicates out of one would mean deciding
|
|
1711
|
+
* whether two events inside a single envelope are two page views or one
|
|
1712
|
+
* delivery — and a missed duplicate is a far cheaper mistake here than an
|
|
1713
|
+
* invented one.
|
|
1714
|
+
*/
|
|
1715
|
+
function pageRequestUrl(request) {
|
|
1716
|
+
if (request.endpointName !== "page")
|
|
1717
|
+
return null;
|
|
1718
|
+
const payload = request.payload;
|
|
1719
|
+
const url = payload?.properties?.url ?? payload?.context?.page?.url;
|
|
1720
|
+
return typeof url === "string" && url.length > 0 ? url : null;
|
|
1721
|
+
}
|
|
1722
|
+
/** The `name@version` build(s) this request's own payload says sent it. */
|
|
1723
|
+
function librariesOf(request) {
|
|
1724
|
+
return extractLibraries(request.payload).map(formatLibraryBuild);
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* The build VERSIONS behind a set of requests — the part that separates two
|
|
1728
|
+
* copies of the script from one install's two bundles.
|
|
1729
|
+
*
|
|
1730
|
+
* One install ships its normal and cookieless bundles at the same version
|
|
1731
|
+
* under two different `library.name`s, so the name alone would read a
|
|
1732
|
+
* correctly gated pair (paytronix.com) as a duplicate install. A second
|
|
1733
|
+
* COPY is a second thing someone added — an npm build inside the app next to
|
|
1734
|
+
* a CDN build from a tag manager — and that shows in the version.
|
|
1735
|
+
*/
|
|
1736
|
+
function libraryVersionsOf(requests) {
|
|
1737
|
+
return [...new Set(requests.flatMap((r) => extractLibraries(r.payload).map((build) => build.version)))];
|
|
1738
|
+
}
|
|
1739
|
+
function observedPageViews(phase) {
|
|
1740
|
+
const views = [
|
|
1741
|
+
{
|
|
1742
|
+
phase: phase.phase,
|
|
1743
|
+
label: `${phase.phase} phase, first page`,
|
|
1744
|
+
requests: phase.network.requests,
|
|
1745
|
+
loadedTwiceAroundConsent: phase.network.spansConsentAnswer,
|
|
1746
|
+
},
|
|
1747
|
+
];
|
|
1748
|
+
const second = phase.secondPage;
|
|
1749
|
+
if (secondPageObserved(second)) {
|
|
1750
|
+
// A page the run walked to with the consent state already settled: one
|
|
1751
|
+
// load, one chance for each bundle to fire, and the page view where a
|
|
1752
|
+
// duplicate loader has nowhere left to hide.
|
|
1753
|
+
views.push({
|
|
1754
|
+
phase: phase.phase,
|
|
1755
|
+
label: `${phase.phase} phase, ${second.toUrl}`,
|
|
1756
|
+
requests: second.network.requests,
|
|
1757
|
+
loadedTwiceAroundConsent: false,
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
// The document load taken when the walk did not produce one. On a site that
|
|
1761
|
+
// routes internally this is the phase's ONLY unambiguous page view — the
|
|
1762
|
+
// entry page was loaded twice around the consent answer and the walked page
|
|
1763
|
+
// never re-ran a script, so without this a doubled page view has nowhere it
|
|
1764
|
+
// can be seen. See SecondPageEvidence.hardLoad.
|
|
1765
|
+
const hardLoad = second?.hardLoad;
|
|
1766
|
+
if (hardLoad && hardLoad.failedReason === null) {
|
|
1767
|
+
views.push({
|
|
1768
|
+
phase: phase.phase,
|
|
1769
|
+
label: `${phase.phase} phase, ${hardLoad.url} (document load)`,
|
|
1770
|
+
requests: hardLoad.network.requests,
|
|
1771
|
+
loadedTwiceAroundConsent: false,
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
return views;
|
|
1775
|
+
}
|
|
1776
|
+
function findDuplicatePageViews({ phase, label, requests, loadedTwiceAroundConsent }) {
|
|
1777
|
+
const byUrl = new Map();
|
|
1778
|
+
for (const request of requests) {
|
|
1779
|
+
const url = pageRequestUrl(request);
|
|
1780
|
+
if (url === null)
|
|
1781
|
+
continue;
|
|
1782
|
+
byUrl.set(url, [...(byUrl.get(url) ?? []), request]);
|
|
1783
|
+
}
|
|
1784
|
+
return [...byUrl.entries()]
|
|
1785
|
+
.filter(([, sent]) => sent.length > 1)
|
|
1786
|
+
.map(([url, sent]) => {
|
|
1787
|
+
const libraryVersions = libraryVersionsOf(sent);
|
|
1788
|
+
return {
|
|
1789
|
+
phase,
|
|
1790
|
+
where: label,
|
|
1791
|
+
url,
|
|
1792
|
+
modes: sent.map((r) => r.mode),
|
|
1793
|
+
messageIds: sent.map((r) => extractMessageId(r.payload)).filter((id) => id !== null),
|
|
1794
|
+
libraries: [...new Set(sent.flatMap(librariesOf))],
|
|
1795
|
+
libraryVersions,
|
|
1796
|
+
// Two loads around a consent answer are two runs of one install, at
|
|
1797
|
+
// one version. Two versions cannot be that, whatever the timing, so
|
|
1798
|
+
// they are judged even on the page where the banner was answered.
|
|
1799
|
+
judged: !loadedTwiceAroundConsent || libraryVersions.length > 1,
|
|
1800
|
+
};
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Which mechanism produced one double-counted page view.
|
|
1805
|
+
*
|
|
1806
|
+
* Split out of explainDuplicate's prose so the answer is available as DATA:
|
|
1807
|
+
* the diagnosis layer needs to pick a root cause and a fix from it, and the
|
|
1808
|
+
* four mechanisms are fixed in four different places — a tag manager, a
|
|
1809
|
+
* snippet's placement, an app's own bundle, a second copy that must be
|
|
1810
|
+
* removed rather than reconfigured. The branch order is unchanged, and
|
|
1811
|
+
* explainDuplicate still produces the identical sentence for each.
|
|
1812
|
+
*/
|
|
1813
|
+
function classifyDuplicate(duplicate) {
|
|
1814
|
+
const { modes, messageIds, libraryVersions } = duplicate;
|
|
1815
|
+
if (messageIds.length > 1 && new Set(messageIds).size === 1)
|
|
1816
|
+
return "replayed-buffer";
|
|
1817
|
+
if (libraryVersions.length > 1)
|
|
1818
|
+
return "two-builds";
|
|
1819
|
+
if (modes.includes("normal") && modes.includes("cookieless"))
|
|
1820
|
+
return "mixed-modes";
|
|
1821
|
+
return "same-build";
|
|
1822
|
+
}
|
|
1823
|
+
/**
|
|
1824
|
+
* Names the mechanism behind one double-counted page view, because the
|
|
1825
|
+
* causes are fixed in different places — a tag manager, a snippet's
|
|
1826
|
+
* placement, an app's own bundle — and the numbers alone do not say which
|
|
1827
|
+
* one this is.
|
|
1828
|
+
*/
|
|
1829
|
+
function explainDuplicate(duplicate) {
|
|
1830
|
+
const { where, url, modes, messageIds, libraries, libraryVersions } = duplicate;
|
|
1831
|
+
const head = `${where}: ${modes.length} page events for ${url}`;
|
|
1832
|
+
switch (classifyDuplicate(duplicate)) {
|
|
1833
|
+
case "replayed-buffer":
|
|
1834
|
+
return `${head} sharing one messageId (${messageIds[0]}) — the SAME event delivered twice, which no single healthy instance does: a second bundle replayed a buffer the first had already sent`;
|
|
1835
|
+
case "two-builds":
|
|
1836
|
+
return `${head} from ${libraryVersions.length} different builds of the script (${libraries.join(", ")}) — one install ships every bundle it has at ONE version, so this page is running ${libraryVersions.length} separate copies, each firing its own page() on load. The usual pair is a copy bundled into the site's own app (\`npm:\` versions) next to a CDN copy a tag manager injects. The messageIds differ (${messageIds.join(", ") || "unavailable"}), so ingest cannot collapse them: every document load is counted ${libraryVersions.length} times. A soft (router) navigation hides it, since only the copy wired to the router fires there`;
|
|
1837
|
+
case "mixed-modes":
|
|
1838
|
+
return `${head}, one from each bundle — the page view was counted twice, once identified and once anonymous. The two are built to be mutually exclusive, so this is a snippet PLACEMENT error: the cookieless snippet sits above the normal one, or a tag manager injects them in that order, and the cookieless guards ran before the normal tag existed to be seen`;
|
|
1839
|
+
case "same-build":
|
|
1840
|
+
return `${head} from the ${modes[0]} bundle alone (messageIds ${messageIds.join(", ") || "unavailable"}) — one loader cannot produce two page() calls for one load, so more than one copy of the same snippet is running, typically one hardcoded and one injected by a tag manager`;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* What the duplicate actually costs, which is not the same answer for each
|
|
1845
|
+
* kind.
|
|
1846
|
+
*
|
|
1847
|
+
* The blanket "every affected page view is inflated in reporting" this
|
|
1848
|
+
* replaced was wrong for mixed-modes, and wrong in the direction that gets a
|
|
1849
|
+
* report disbelieved: ingest recognises a cookieless event arriving for a
|
|
1850
|
+
* session that already has an anonymous id and deduplicates it — Dreamdata's
|
|
1851
|
+
* own debugger calls it a Tracking Reset and says in as many words that the
|
|
1852
|
+
* cookieless tracks "will be deduplicated but should not happen". A customer
|
|
1853
|
+
* who checks nuvei.com's events and finds no duplicates there has just been
|
|
1854
|
+
* told something they can see is untrue, about a defect that IS real.
|
|
1855
|
+
*
|
|
1856
|
+
* The two-builds text already carries its own reasoning about whether ingest
|
|
1857
|
+
* can collapse the events (it cannot: the messageIds differ), so the rule
|
|
1858
|
+
* this follows is not new — it is applied consistently now.
|
|
1859
|
+
*/
|
|
1860
|
+
/**
|
|
1861
|
+
* True when every duplicate here is the pair ingest is known to collapse.
|
|
1862
|
+
*
|
|
1863
|
+
* This is the whole basis for warning rather than failing (see
|
|
1864
|
+
* CrossPhaseCheck): a warn is a real configuration defect that this run showed
|
|
1865
|
+
* is currently held in check by something else, which is exactly a mixed-mode
|
|
1866
|
+
* pair — the page is wrong, and the numbers a customer reads are not. A single
|
|
1867
|
+
* duplicate of any OTHER kind in the same run sinks it back to a failure,
|
|
1868
|
+
* because nothing is holding those in check.
|
|
1869
|
+
*/
|
|
1870
|
+
function isCollapsedByIngest(duplicates) {
|
|
1871
|
+
const kinds = new Set(duplicates.map(classifyDuplicate));
|
|
1872
|
+
return kinds.size === 1 && kinds.has("mixed-modes");
|
|
1873
|
+
}
|
|
1874
|
+
function duplicateConsequence(duplicates) {
|
|
1875
|
+
if (isCollapsedByIngest(duplicates)) {
|
|
1876
|
+
return ("Ingest deduplicates this pair, so the page-view counts a customer reads are not inflated by it — but the visitor's " +
|
|
1877
|
+
"session is reset and re-stitched on every affected page view, each one costs an ingested event, and the correction " +
|
|
1878
|
+
"depends on a safety net rather than on the page behaving. It stops when the extra loader stops firing.");
|
|
1879
|
+
}
|
|
1880
|
+
return "Every affected page view is inflated in reporting until the extra loader is removed";
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* The duplicates page-view-counted-once actually judges, as data.
|
|
1884
|
+
*
|
|
1885
|
+
* Called by that check and, separately, by analyzeDeployment for the
|
|
1886
|
+
* diagnosis. Computing it twice rather than threading it through is
|
|
1887
|
+
* deliberate: it is a pure function of the phases, so the two callers cannot
|
|
1888
|
+
* disagree, whereas a parameter carrying a second derivation could drift out
|
|
1889
|
+
* of step with the check's own prose.
|
|
1890
|
+
*/
|
|
1891
|
+
export function findJudgedDuplicates(phases) {
|
|
1892
|
+
return phases
|
|
1893
|
+
.flatMap(observedPageViews)
|
|
1894
|
+
.filter((view) => view.requests.some((r) => pageRequestUrl(r) !== null))
|
|
1895
|
+
.flatMap(findDuplicatePageViews)
|
|
1896
|
+
.filter((duplicate) => duplicate.judged)
|
|
1897
|
+
.map((duplicate) => ({ phase: duplicate.phase, kind: classifyDuplicate(duplicate) }));
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Whether one page view produced exactly one page event.
|
|
1901
|
+
*
|
|
1902
|
+
* Deliberately scoped to page events sharing a URL WITHIN a single observed
|
|
1903
|
+
* page view, rather than to a phase's mode counts. A phase's counts span two
|
|
1904
|
+
* page views, so a cookieless entry page followed by a normal second page
|
|
1905
|
+
* adds up to "both modes fired" while being nothing of the sort — that
|
|
1906
|
+
* combination is a consent question, and second-page-honours-consent asks it.
|
|
1907
|
+
* Only the same page, counted more than once, is this check's business.
|
|
1908
|
+
*
|
|
1909
|
+
* And "one page view" mostly excludes the entry page of a phase that
|
|
1910
|
+
* ANSWERED the banner, because there the run loaded one URL twice: once
|
|
1911
|
+
* unanswered, then again with the decision in force. A correct consent-gated
|
|
1912
|
+
* install tracks both — the cookieless bundle anonymously before the answer,
|
|
1913
|
+
* the normal bundle with an anonymousId after it — so two page events for
|
|
1914
|
+
* that URL are the upgrade the visitor just consented to, not a second
|
|
1915
|
+
* loader. Reading them as a duplicate failed real installs (maxio.com among
|
|
1916
|
+
* them) for behaving exactly as designed.
|
|
1917
|
+
*
|
|
1918
|
+
* "Mostly", because that story has a limit: two loads are two runs of the
|
|
1919
|
+
* SAME install and report one `context.library` build. When the page events
|
|
1920
|
+
* name two different builds, no ordering of loads, queued requests or retried
|
|
1921
|
+
* deliveries can produce them — two copies of the script are installed — so
|
|
1922
|
+
* those are judged on the answered entry page too. getmoss.com is the case:
|
|
1923
|
+
* an npm copy in the site's own Next.js bundle firing alongside the CDN copy
|
|
1924
|
+
* GTM injects, two page events per document load with different messageIds,
|
|
1925
|
+
* and every other field on both looking perfectly healthy.
|
|
1926
|
+
*
|
|
1927
|
+
* A duplicate the check does leave unjudged still shows elsewhere, since a
|
|
1928
|
+
* second loader fires on EVERY load: on the unanswered entry page, and on
|
|
1929
|
+
* the page each phase walks to afterwards.
|
|
1930
|
+
*/
|
|
1931
|
+
function buildPageViewCountedOnceCheck(phases) {
|
|
1932
|
+
const id = "page-view-counted-once";
|
|
1933
|
+
const title = "Each page view is counted once";
|
|
1934
|
+
const views = phases.flatMap(observedPageViews);
|
|
1935
|
+
const tracked = views.filter((view) => view.requests.some((r) => pageRequestUrl(r) !== null));
|
|
1936
|
+
const judged = tracked.filter((view) => !view.loadedTwiceAroundConsent);
|
|
1937
|
+
const consentTransition = tracked.filter((view) => view.loadedTwiceAroundConsent);
|
|
1938
|
+
const duplicates = tracked.flatMap(findDuplicatePageViews).filter((duplicate) => duplicate.judged);
|
|
1939
|
+
const describe = ({ label, requests, loadedTwiceAroundConsent }) => {
|
|
1940
|
+
const sent = requests.filter((r) => pageRequestUrl(r) !== null);
|
|
1941
|
+
const builds = [...new Set(sent.flatMap(librariesOf))];
|
|
1942
|
+
const note = loadedTwiceAroundConsent
|
|
1943
|
+
? " — this URL was loaded twice, either side of the consent answer, so a second event from the same build is not judged"
|
|
1944
|
+
: "";
|
|
1945
|
+
const from = builds.length > 0 ? ` from ${builds.join(", ")}` : "";
|
|
1946
|
+
return `${label}: ${sent.length} page request(s) [${sent.map((r) => r.mode).join(", ")}]${from}${note}`;
|
|
1947
|
+
};
|
|
1948
|
+
const evidence = tracked.map(describe).join("; ");
|
|
1949
|
+
if (judged.length === 0 && duplicates.length === 0) {
|
|
1950
|
+
return {
|
|
1951
|
+
id,
|
|
1952
|
+
title,
|
|
1953
|
+
status: "not_applicable",
|
|
1954
|
+
detail: consentTransition.length === 0
|
|
1955
|
+
? "No automatic page view reached the wire in any phase, so there was nothing that could be counted twice. Whether one should have fired is core-tracking's question."
|
|
1956
|
+
: `The only page views that tracked were entry pages loaded twice around a consent answer (${consentTransition.length}), where a second page event is the expected consent upgrade rather than a duplicate. No single-load page view was observed, so exclusivity could not be judged — run the clean phase, or let the second-page probe run, to get one.`,
|
|
1957
|
+
evidence: evidence || `page requests observed: 0 across ${views.length} observed page view(s)`,
|
|
1958
|
+
phases: phases.map((p) => p.phase),
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
const scope = consentTransition.length === 0
|
|
1962
|
+
? `${judged.length} page view(s) observed`
|
|
1963
|
+
: `${judged.length} single-load page view(s) judged; ${consentTransition.length} entry page(s) loaded twice around a consent answer judged only on whether they name more than one build, since a second event from ONE build is the consent upgrade working`;
|
|
1964
|
+
return {
|
|
1965
|
+
id,
|
|
1966
|
+
title,
|
|
1967
|
+
status: duplicates.length === 0 ? "pass" : isCollapsedByIngest(duplicates) ? "warn" : "fail",
|
|
1968
|
+
detail: duplicates.length === 0
|
|
1969
|
+
? `Every page view that tracked produced exactly one page event (${scope}).`
|
|
1970
|
+
: `${duplicates.map(explainDuplicate).join("; ")}. ${duplicateConsequence(duplicates)} (${scope}).`,
|
|
1971
|
+
evidence,
|
|
1972
|
+
phases: [...new Set((duplicates.length === 0 ? judged : duplicates).map((v) => v.phase))],
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
/** The handoff is confirmed by either signal; neither one's absence condemns it. */
|
|
1976
|
+
function handoffCompleted(s) {
|
|
1977
|
+
return s.initialized || s.readyFired;
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Only two states are actually broken: the global is still the loader's stub
|
|
1981
|
+
* array, or the normal bundle sent requests and left the cookieless global it
|
|
1982
|
+
* deletes on a successful handoff in place while never confirming its own.
|
|
1983
|
+
*/
|
|
1984
|
+
function handoffBroken(s) {
|
|
1985
|
+
if (handoffCompleted(s))
|
|
1986
|
+
return false;
|
|
1987
|
+
return s.isStub || s.cookielessGlobalSurvived;
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* The global a phase is answerable for, picked from the bundle that
|
|
1991
|
+
* demonstrably ran there rather than from the deployment shape.
|
|
1992
|
+
*
|
|
1993
|
+
* A phase where nothing sent a request is not judged at all: a gated bundle
|
|
1994
|
+
* that never loaded has no handoff to complete, and holding it to one is the
|
|
1995
|
+
* failure mode this check exists to avoid.
|
|
1996
|
+
*/
|
|
1997
|
+
function handoffSubject(phase, segmentCompat) {
|
|
1998
|
+
const g = phase.globals;
|
|
1999
|
+
const base = { phase: phase.phase, cookielessGlobalSurvived: false };
|
|
2000
|
+
if (phase.network.normalRequestCount > 0) {
|
|
2001
|
+
const key = segmentCompat ? SEGMENT_COMPAT_GLOBAL_KEY : "dreamdata";
|
|
2002
|
+
const exists = segmentCompat ? g.segmentCompatGlobalExists : g.dreamdataExists;
|
|
2003
|
+
const initialized = segmentCompat ? g.segmentCompatInitialized : g.initialized;
|
|
2004
|
+
const isStub = segmentCompat ? g.segmentCompatIsStubQueue : g.dreamdataIsStubQueue;
|
|
2005
|
+
return {
|
|
2006
|
+
...base,
|
|
2007
|
+
key,
|
|
2008
|
+
exists,
|
|
2009
|
+
initialized,
|
|
2010
|
+
readyFired: g.readyCallbackFired && g.readyCallbackGlobal === key,
|
|
2011
|
+
isStub,
|
|
2012
|
+
cookielessGlobalSurvived: g.cookielessGlobalExists,
|
|
2013
|
+
evidence: `${phase.phase}: normalRequestCount ${phase.network.normalRequestCount}, window.${key} exists ${exists}, ` +
|
|
2014
|
+
`initialized ${initialized}, ready() fired ${g.readyCallbackFired && g.readyCallbackGlobal === key}, ` +
|
|
2015
|
+
`stub array ${isStub}, window['${COOKIELESS_GLOBAL_KEY}'] still present ${g.cookielessGlobalExists}`,
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
if (phase.network.cookielessRequestCount > 0) {
|
|
2019
|
+
return {
|
|
2020
|
+
...base,
|
|
2021
|
+
key: COOKIELESS_GLOBAL_KEY,
|
|
2022
|
+
exists: g.cookielessGlobalExists,
|
|
2023
|
+
initialized: g.cookielessInitialized,
|
|
2024
|
+
readyFired: g.readyCallbackFired && g.readyCallbackGlobal === COOKIELESS_GLOBAL_KEY,
|
|
2025
|
+
isStub: false,
|
|
2026
|
+
evidence: `${phase.phase}: cookielessRequestCount ${phase.network.cookielessRequestCount}, ` +
|
|
2027
|
+
`window['${COOKIELESS_GLOBAL_KEY}'] exists ${g.cookielessGlobalExists}, initialized ${g.cookielessInitialized}`,
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
return null;
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Whether the bundle that ran finished becoming its global.
|
|
2034
|
+
*
|
|
2035
|
+
* Computed here rather than argued from the evidence each run, because the
|
|
2036
|
+
* absence of a global has two opposite meanings and the model reliably picked
|
|
2037
|
+
* the wrong one. A loader-based install that never becomes its global is
|
|
2038
|
+
* broken in a way the wire cannot show: the automatic page view looks
|
|
2039
|
+
* perfect and every `dreamdata.track()` the site makes goes into a dead
|
|
2040
|
+
* array. A BUNDLED install has no global by construction — the site imported
|
|
2041
|
+
* the SDK and calls the instance directly — and elliptic.co was failed for
|
|
2042
|
+
* exactly that, an install whose every other check passed.
|
|
2043
|
+
*
|
|
2044
|
+
* So the question is asked as "did the global this phase's own requests imply
|
|
2045
|
+
* actually initialize", and only where a global was there to initialize.
|
|
2046
|
+
*/
|
|
2047
|
+
function buildGlobalHandoffCheck(phases, install, segmentCompat) {
|
|
2048
|
+
const id = "global-handoff";
|
|
2049
|
+
const title = "The bundle finished handing off to its global";
|
|
2050
|
+
const subjects = phases
|
|
2051
|
+
.map((phase) => handoffSubject(phase, segmentCompat))
|
|
2052
|
+
.filter((s) => s !== null);
|
|
2053
|
+
const evidence = subjects.length > 0
|
|
2054
|
+
? subjects.map((s) => s.evidence).join("; ")
|
|
2055
|
+
: `no bundle sent a request in any phase (${phases.map((p) => p.phase).join(", ") || "none collected"})`;
|
|
2056
|
+
if (install.method === "bundled-no-global") {
|
|
2057
|
+
// The strongest corroboration available: the SDK has no route-change
|
|
2058
|
+
// tracking of its own, so a soft navigation that still tracked can only
|
|
2059
|
+
// be the site's own code calling page() on a reference it holds — which
|
|
2060
|
+
// is the very call the "missing global" reading claims is impossible.
|
|
2061
|
+
const softNavTracked = phases.filter((p) => secondPageObserved(p.secondPage) &&
|
|
2062
|
+
p.secondPage.kind === "soft" &&
|
|
2063
|
+
p.secondPage.network.normalRequestCount + p.secondPage.network.cookielessRequestCount > 0);
|
|
2064
|
+
const corroboration = softNavTracked.length > 0
|
|
2065
|
+
? ` A page event also fired on a route change the site's own router handled (${softNavTracked
|
|
2066
|
+
.map((p) => p.phase)
|
|
2067
|
+
.join(", ")}), which the SDK cannot do by itself — so the site demonstrably holds a working reference and can call track/identify on it.`
|
|
2068
|
+
: "";
|
|
2069
|
+
return {
|
|
2070
|
+
id,
|
|
2071
|
+
title,
|
|
2072
|
+
status: "not_applicable",
|
|
2073
|
+
detail: "The SDK is built into the site's own bundle and never touches window, so there is no handoff to complete and no global to look for." +
|
|
2074
|
+
corroboration +
|
|
2075
|
+
" Nothing is missing from the wire; what is missing is only the window handle, which costs no events but does mean console debugging and any window.dreamdata call from outside the bundle (a GTM custom HTML tag, a third-party script) will not find it.",
|
|
2076
|
+
evidence,
|
|
2077
|
+
phases: subjects.map((s) => s.phase),
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
const broken = subjects.filter((s) => s.exists && handoffBroken(s));
|
|
2081
|
+
if (broken.length > 0) {
|
|
2082
|
+
const mechanism = broken
|
|
2083
|
+
.map((s) => {
|
|
2084
|
+
const shape = s.isStub
|
|
2085
|
+
? `window.${s.key} is still the loader's stub ARRAY, so every call the site makes is pushed into a buffer nothing drains`
|
|
2086
|
+
: `window.${s.key} exists but neither reported initialized nor ran its ready() callback`;
|
|
2087
|
+
const proof = s.cookielessGlobalSurvived
|
|
2088
|
+
? `, and window['${COOKIELESS_GLOBAL_KEY}'] survived, which a completed handoff deletes`
|
|
2089
|
+
: "";
|
|
2090
|
+
return `${s.phase}: the bundle sent requests and ${shape}${proof}`;
|
|
2091
|
+
})
|
|
2092
|
+
.join("; ");
|
|
2093
|
+
return {
|
|
2094
|
+
id,
|
|
2095
|
+
title,
|
|
2096
|
+
status: "fail",
|
|
2097
|
+
detail: `${mechanism}. The automatic page view looks fine on the wire while every dreamdata.track/identify call the site's own code makes is silently dropped — the usual causes are a write key that never reached the bundle, two copies of the snippet on the page, or a CSP blocking the bundle's CDN.`,
|
|
2098
|
+
evidence,
|
|
2099
|
+
phases: broken.map((s) => s.phase),
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
const completed = subjects.filter(handoffCompleted);
|
|
2103
|
+
if (completed.length > 0) {
|
|
2104
|
+
return {
|
|
2105
|
+
id,
|
|
2106
|
+
title,
|
|
2107
|
+
status: "pass",
|
|
2108
|
+
detail: `The bundle that ran finished handing off to its global (${completed
|
|
2109
|
+
.map((s) => `${s.phase}: window.${s.key}`)
|
|
2110
|
+
.join(", ")}), so calls the site's own code makes reach the real SDK.`,
|
|
2111
|
+
evidence,
|
|
2112
|
+
phases: completed.map((s) => s.phase),
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
if (subjects.length === 0) {
|
|
2116
|
+
return {
|
|
2117
|
+
id,
|
|
2118
|
+
title,
|
|
2119
|
+
status: "not_applicable",
|
|
2120
|
+
detail: "No bundle sent a request in any phase, so no global was expected to appear. Whether one should have is script-presence and core-tracking's question.",
|
|
2121
|
+
evidence,
|
|
2122
|
+
phases: phases.map((p) => p.phase),
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
return {
|
|
2126
|
+
id,
|
|
2127
|
+
title,
|
|
2128
|
+
status: "inconclusive",
|
|
2129
|
+
detail: `Requests reached Dreamdata (${subjects
|
|
2130
|
+
.map((s) => s.phase)
|
|
2131
|
+
.join(", ")}), and the global they imply neither confirmed its handoff (no initialized flag, no ready() callback) nor showed any of the broken states — so this is neither a confirmed handoff nor a confirmed stall. Check by hand whether window.${subjects[0].key} is the real SDK once the page has settled.`,
|
|
2132
|
+
evidence,
|
|
2133
|
+
phases: subjects.map((s) => s.phase),
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
const BUNDLE_LABEL = {
|
|
2137
|
+
normal: "the standard cookie-based bundle",
|
|
2138
|
+
cookieless: "the cookieless bundle",
|
|
2139
|
+
helper: "a helper script (identify-form)",
|
|
2140
|
+
unknown: "a Dreamdata script whose bundle could not be read off the tag",
|
|
2141
|
+
};
|
|
2142
|
+
function bundleList(kinds) {
|
|
2143
|
+
return kinds.map((k) => BUNDLE_LABEL[k]).join(" and ");
|
|
2144
|
+
}
|
|
2145
|
+
function sentenceCase(text) {
|
|
2146
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Which bundles the DOM carried, across every phase.
|
|
2150
|
+
*
|
|
2151
|
+
* A DOM tag does NOT mean the tag is hardcoded: `page.content()` is the live
|
|
2152
|
+
* document, so a tag GTM injected is in it too. That ambiguity is resolved
|
|
2153
|
+
* below by the container scan, which is the only thing that can say a tag was
|
|
2154
|
+
* published through GTM.
|
|
2155
|
+
*/
|
|
2156
|
+
function domBundlesSeen(phases) {
|
|
2157
|
+
const kinds = new Set();
|
|
2158
|
+
for (const p of phases)
|
|
2159
|
+
for (const m of p.script.matches)
|
|
2160
|
+
kinds.add(m.kind);
|
|
2161
|
+
return [...kinds];
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* True when no Dreamdata global was ever on `window` — not the real SDK
|
|
2165
|
+
* object, not the loader's stub array, in any phase.
|
|
2166
|
+
*
|
|
2167
|
+
* This is the one thing that separates a bundled (npm) install from a loader
|
|
2168
|
+
* this run could not name. Every loader-based install touches `window`: the
|
|
2169
|
+
* snippet creates its global as a stub array before the bundle even arrives,
|
|
2170
|
+
* and a bare CDN tag creates it on load. So requests on the wire with nothing
|
|
2171
|
+
* on `window` anywhere means the SDK was imported as a module and lives in
|
|
2172
|
+
* the site's own bundle scope, where there is no global to create.
|
|
2173
|
+
*/
|
|
2174
|
+
function noGlobalInAnyPhase(phases) {
|
|
2175
|
+
return phases.every((p) => !p.globals.dreamdataExists && !p.globals.cookielessGlobalExists && !p.globals.segmentCompatGlobalExists);
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Tags that load BOTH bundles from one GTM tag.
|
|
2179
|
+
*
|
|
2180
|
+
* The single definition, because three readers need it and must agree: the
|
|
2181
|
+
* install `detail`, the `bundles-share-one-tag` cause, and the check that
|
|
2182
|
+
* cause claims. One tag has one `consent` field and one trigger set, so a
|
|
2183
|
+
* combined tag cannot hold the two bundles to opposite consent states however
|
|
2184
|
+
* it is configured — the gate necessarily falls to the snippet's own JS.
|
|
2185
|
+
*/
|
|
2186
|
+
export function combinedBundleTags(containers) {
|
|
2187
|
+
return containers.flatMap((container) => container.tags
|
|
2188
|
+
.filter((tag) => tag.bundles.includes("normal") && tag.bundles.includes("cookieless"))
|
|
2189
|
+
.map((tag) => ({ containerId: container.containerId, tag })));
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* The consent signal a Dreamdata tag has to be gated on, and the only one.
|
|
2193
|
+
*
|
|
2194
|
+
* A gate on any other signal is either inert (telaid.com's normal tag
|
|
2195
|
+
* requires `targeted_advertising`, which nothing on that page grants, and it
|
|
2196
|
+
* never fires at all) or right by coincidence (tumodo.io's requires
|
|
2197
|
+
* `ad_storage`, which Cookiebot happens to grant alongside analytics — and
|
|
2198
|
+
* which stops being true for a visitor who accepts analytics and declines
|
|
2199
|
+
* marketing).
|
|
2200
|
+
*/
|
|
2201
|
+
const REQUIRED_CONSENT_SIGNAL = "analytics_storage";
|
|
2202
|
+
/** Dreamdata tags gated on some consent signal, but not on analytics_storage. */
|
|
2203
|
+
export function tagsGatedOnWrongSignal(containers) {
|
|
2204
|
+
return containers.flatMap((container) => container.tags
|
|
2205
|
+
.filter((tag) => tag.bundles.includes("normal") &&
|
|
2206
|
+
tag.consent !== null &&
|
|
2207
|
+
tag.consent.length > 0 &&
|
|
2208
|
+
!tag.consent.includes(REQUIRED_CONSENT_SIGNAL))
|
|
2209
|
+
.map((tag) => ({ containerId: container.containerId, tag })));
|
|
2210
|
+
}
|
|
2211
|
+
/** The cookieless tag's own gate, when the container shows it has one. */
|
|
2212
|
+
export function cookielessTagGates(containers) {
|
|
2213
|
+
return containers.flatMap((container) => container.tags
|
|
2214
|
+
.filter((tag) => tag.bundles.includes("cookieless") && ((tag.consent?.length ?? 0) > 0 || tag.blockedWhen.length > 0))
|
|
2215
|
+
.map((tag) => ({ containerId: container.containerId, tag })));
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Which container ships which bundle, in words.
|
|
2219
|
+
*
|
|
2220
|
+
* Replaces "ships from one of them", which was all the flat fields could say
|
|
2221
|
+
* about a page loading several containers — and which hid that checkproof.com
|
|
2222
|
+
* loads two and only GTM-WXSP5VK carries any Dreamdata tag at all.
|
|
2223
|
+
*/
|
|
2224
|
+
function describeContainerAttribution(containers) {
|
|
2225
|
+
const parts = containers.map((container) => {
|
|
2226
|
+
if (!container.read)
|
|
2227
|
+
return `${container.containerId} could not be read`;
|
|
2228
|
+
if (container.tags.length === 0)
|
|
2229
|
+
return `${container.containerId} carries no Dreamdata tag`;
|
|
2230
|
+
const combined = container.tags.filter((t) => t.bundles.length > 1).length;
|
|
2231
|
+
const shipped = combined > 0 ? `${bundleList(container.bundles)} from one combined tag` : bundleList(container.bundles);
|
|
2232
|
+
// The gate belongs in the same clause as the container that carries it.
|
|
2233
|
+
// Naming it once at the end of the sentence instead produced "GTM-TKR4CP3
|
|
2234
|
+
// carries no Dreamdata tag, gated in GTM on analytics_storage", which
|
|
2235
|
+
// attributes checkproof.com's gate to the container that has no tag.
|
|
2236
|
+
const normal = container.tags.find((tag) => tag.bundles.includes("normal"));
|
|
2237
|
+
const consent = normal?.consent?.length ? ` gated on ${normal.consent.join(" + ")}` : "";
|
|
2238
|
+
const blocked = normal?.blockedWhen.length
|
|
2239
|
+
? `${consent ? " and" : ""} blocked whenever ${normal.blockedWhen.join("; or ")}`
|
|
2240
|
+
: "";
|
|
2241
|
+
// The event requirement is the gate a reader is least likely to guess at
|
|
2242
|
+
// and the one most likely to be the actual fault: verda.com's tag fires
|
|
2243
|
+
// only on virtualPageView or cookie_consent_update, so it is not on "all
|
|
2244
|
+
// pages" at all and a route change that skips the push goes untracked.
|
|
2245
|
+
const events = normal?.requiresEvents?.length
|
|
2246
|
+
? `${consent || blocked ? " and" : ""} fired only on the ${normal.requiresEvents.join(" or ")} dataLayer event${normal.requiresEvents.length > 1 ? "s" : ""}`
|
|
2247
|
+
: "";
|
|
2248
|
+
const gate = consent || blocked || events ? `,${consent}${blocked}${events}` : "";
|
|
2249
|
+
return `${container.containerId} ships ${shipped}${gate}`;
|
|
2250
|
+
});
|
|
2251
|
+
return parts.join("; ");
|
|
2252
|
+
}
|
|
2253
|
+
function summarizeInstall(phases) {
|
|
2254
|
+
const containerIds = [...new Set(phases.flatMap((p) => p.script.tagManager.containerIds))];
|
|
2255
|
+
const tagManagerScanned = phases.some((p) => p.script.tagManager.scanned);
|
|
2256
|
+
const tagManagerBundles = [];
|
|
2257
|
+
if (phases.some((p) => p.script.tagManager.normalBundleTagFound))
|
|
2258
|
+
tagManagerBundles.push("normal");
|
|
2259
|
+
if (phases.some((p) => p.script.tagManager.cookielessBundleTagFound))
|
|
2260
|
+
tagManagerBundles.push("cookieless");
|
|
2261
|
+
const tagManagerNormalConsentGate = phases.find((p) => p.script.tagManager.normalBundleConsentGate !== null)?.script.tagManager
|
|
2262
|
+
.normalBundleConsentGate ?? null;
|
|
2263
|
+
const tagManagerNormalBlockingTriggers = phases.find((p) => p.script.tagManager.normalBundleBlockingTriggers !== null)?.script.tagManager
|
|
2264
|
+
.normalBundleBlockingTriggers ?? null;
|
|
2265
|
+
const tagManagerErrors = [...new Set(phases.flatMap((p) => p.script.tagManager.errors))];
|
|
2266
|
+
// One phase's scan, not a merge across phases: the published container does
|
|
2267
|
+
// not change between them, and the phase that read the most containers is
|
|
2268
|
+
// the one whose read completed. Merging would have to reconcile tag indexes
|
|
2269
|
+
// across separately-parsed bodies for no gain.
|
|
2270
|
+
const tagManagerContainers = phases
|
|
2271
|
+
.map((p) => p.script.tagManager.containers ?? [])
|
|
2272
|
+
.reduce((best, current) => (current.length > best.length ? current : best), []);
|
|
2273
|
+
const domBundles = domBundlesSeen(phases);
|
|
2274
|
+
const domTracking = domBundles.filter((k) => k !== "helper");
|
|
2275
|
+
const segmentCompatSnippet = phases.some((p) => p.script.segmentCompatSnippet);
|
|
2276
|
+
const blockedTagVendors = [
|
|
2277
|
+
...new Set(phases.flatMap((p) => p.script.matches.map((m) => m.blockedBy)).filter((v) => v !== null)),
|
|
2278
|
+
];
|
|
2279
|
+
// script.writeKey is only ever extracted from a matched snippet, so it is
|
|
2280
|
+
// null for every install that has no snippet to read — a bundled-no-global
|
|
2281
|
+
// (npm) install has no tag in the DOM at all, and a tag-manager-injected
|
|
2282
|
+
// one carries the key inside the container rather than in the page. The key
|
|
2283
|
+
// those installs DO expose is the one their requests put on the wire, which
|
|
2284
|
+
// is the same key and is directly observed. Falling back to it here is what
|
|
2285
|
+
// the checklist has so far been asking the model to do in prose ("check
|
|
2286
|
+
// network.observedWriteKeys before calling it a failure"); a field that is
|
|
2287
|
+
// null while the run plainly observed the key is a fact the report should
|
|
2288
|
+
// not be leaving to a language model to reconstruct.
|
|
2289
|
+
const writeKey = phases.find((p) => p.script.writeKey !== null)?.script.writeKey ??
|
|
2290
|
+
phases.flatMap((p) => p.network.observedWriteKeys)[0] ??
|
|
2291
|
+
null;
|
|
2292
|
+
const requests = phases.some((p) => p.network.requests.length > 0);
|
|
2293
|
+
const containers = containerIds.join(", ");
|
|
2294
|
+
const gtmShipsDreamdata = tagManagerBundles.length > 0;
|
|
2295
|
+
// Per-container attribution when the scan recorded it, so a page loading
|
|
2296
|
+
// several containers says WHICH one ships the tag rather than "one of
|
|
2297
|
+
// them". Falls back to the old phrasing for evidence collected before
|
|
2298
|
+
// TagManagerScan.containers existed.
|
|
2299
|
+
const gtmNote = tagManagerScanned
|
|
2300
|
+
? `GTM container${containerIds.length > 1 ? "s" : ""} ${containers} read; ${tagManagerContainers.length > 0
|
|
2301
|
+
? describeContainerAttribution(tagManagerContainers)
|
|
2302
|
+
: gtmShipsDreamdata
|
|
2303
|
+
? `${bundleList(tagManagerBundles)} ${tagManagerBundles.length > 1 ? "ship" : "ships"} from ${containerIds.length > 1 ? "one of them" : "it"}`
|
|
2304
|
+
: "no Dreamdata tag in it"}`
|
|
2305
|
+
: "no GTM container was found on the page (or --no-tag-manager-scan skipped the read)";
|
|
2306
|
+
const base = {
|
|
2307
|
+
tagManagerContainerIds: containerIds,
|
|
2308
|
+
tagManagerScanned,
|
|
2309
|
+
tagManagerBundles,
|
|
2310
|
+
tagManagerContainers,
|
|
2311
|
+
tagManagerNormalConsentGate,
|
|
2312
|
+
tagManagerNormalBlockingTriggers,
|
|
2313
|
+
tagManagerErrors,
|
|
2314
|
+
domBundles,
|
|
2315
|
+
segmentCompatSnippet,
|
|
2316
|
+
blockedTagVendors,
|
|
2317
|
+
writeKey,
|
|
2318
|
+
};
|
|
2319
|
+
// Only the container can prove a tag is published through GTM, so it is
|
|
2320
|
+
// read first: a hardcoded verdict is what is left when it says no.
|
|
2321
|
+
if (gtmShipsDreamdata) {
|
|
2322
|
+
// The container ships one bundle and the DOM carries another: the second
|
|
2323
|
+
// one cannot have come from the container, so something else placed it.
|
|
2324
|
+
const extraInDom = domTracking.filter((k) => k === "normal" || k === "cookieless").filter((k) => !tagManagerBundles.includes(k));
|
|
2325
|
+
// Both halves of the gate are named, because a tag whose consent list is
|
|
2326
|
+
// satisfied can still be held back by an exception — and then the thing
|
|
2327
|
+
// keeping tracking off before consent is not Consent Mode at all.
|
|
2328
|
+
const consentGate = tagManagerNormalConsentGate?.length
|
|
2329
|
+
? `, gated in GTM on ${tagManagerNormalConsentGate.join(" + ")}`
|
|
2330
|
+
: "";
|
|
2331
|
+
const blockingGate = tagManagerNormalBlockingTriggers?.length
|
|
2332
|
+
? `${consentGate ? " and" : ","} blocked in GTM whenever ${tagManagerNormalBlockingTriggers.join("; or ")}`
|
|
2333
|
+
: "";
|
|
2334
|
+
// Suppressed when describeContainerAttribution already named the gate
|
|
2335
|
+
// against its own container — otherwise the same clause appears twice.
|
|
2336
|
+
const gate = tagManagerContainers.length > 0 ? "" : `${consentGate}${blockingGate}`;
|
|
2337
|
+
if (extraInDom.length > 0) {
|
|
2338
|
+
return {
|
|
2339
|
+
...base,
|
|
2340
|
+
method: "hardcoded-and-tag-manager",
|
|
2341
|
+
label: `Google Tag Manager (${containers}) + a snippet on the page`,
|
|
2342
|
+
detail: `${gtmNote}${gate}. On top of that, ${bundleList(extraInDom)} was in the DOM without the container shipping it, so that one is placed on the page directly. Two loaders for one account is the usual cause of double-counted page views.`,
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
return {
|
|
2346
|
+
...base,
|
|
2347
|
+
method: "google-tag-manager",
|
|
2348
|
+
label: `Google Tag Manager (${containers})`,
|
|
2349
|
+
detail: `${gtmNote}${gate}. ${domTracking.length > 0
|
|
2350
|
+
? `The matching tag was also in the live DOM, which is what GTM injecting it looks like — a hardcoded second copy cannot be ruled out from the DOM alone.`
|
|
2351
|
+
: `The tag was never in the DOM in any phase, so it is installed but did not fire under the conditions this run created.`}`,
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
if (domTracking.length > 0) {
|
|
2355
|
+
return {
|
|
2356
|
+
...base,
|
|
2357
|
+
method: "hardcoded-snippet",
|
|
2358
|
+
label: segmentCompatSnippet
|
|
2359
|
+
? "Hardcoded on the page — Segment-compatible inline snippet"
|
|
2360
|
+
: "Hardcoded on the page — <script> tag",
|
|
2361
|
+
detail: `${sentenceCase(bundleList(domTracking))} is in the page's own markup as ${segmentCompatSnippet ? "an inline loader snippet" : "a script tag"}, and ${gtmNote}, so no tag manager is placing it.${blockedTagVendors.length > 0
|
|
2362
|
+
? ` ${blockedTagVendors.join(", ")} neutralised the tag rather than removing it — installed, gated.`
|
|
2363
|
+
: ""}`,
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
if (requests && noGlobalInAnyPhase(phases)) {
|
|
2367
|
+
return {
|
|
2368
|
+
...base,
|
|
2369
|
+
method: "bundled-no-global",
|
|
2370
|
+
label: "Built into the site's own bundle — module-scoped SDK, no window global",
|
|
2371
|
+
detail: `Requests reached Dreamdata, no Dreamdata tag was in the DOM in any phase, ${gtmNote}, and no Dreamdata global — not the SDK object, not the loader's stub array — was ever on window. That combination is what importing the SDK as a package (npm) and holding the instance in module scope looks like: there is no loader, so there is nothing to create a global. The install is working; the only cost is that code outside that bundle (a browser console, a GTM custom HTML tag, a third-party script) cannot reach it through window.dreamdata, and the bundle version is pinned at build time rather than following the CDN.`,
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
if (requests) {
|
|
2375
|
+
return {
|
|
2376
|
+
...base,
|
|
2377
|
+
method: "injected-unidentified",
|
|
2378
|
+
label: "Injected at runtime — no snippet and no GTM tag found",
|
|
2379
|
+
detail: `Requests reached Dreamdata, but no Dreamdata tag was in the DOM in any phase and ${gtmNote}. Something injects the loader that this run cannot name — another tag manager, a CMP-managed loader, or a bundler that inlines it after load.`,
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
if (domBundles.includes("helper")) {
|
|
2383
|
+
return {
|
|
2384
|
+
...base,
|
|
2385
|
+
method: "not-installed",
|
|
2386
|
+
label: "Helper script only — no tracking bundle",
|
|
2387
|
+
detail: `The only Dreamdata script on the page is a helper (identify-form). ${gtmNote}.`,
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
// Nothing anywhere is only a real answer once the container was actually
|
|
2391
|
+
// readable; an unread container is a gap, not an absence.
|
|
2392
|
+
if (!tagManagerScanned && containerIds.length === 0 && tagManagerErrors.length > 0) {
|
|
2393
|
+
return {
|
|
2394
|
+
...base,
|
|
2395
|
+
method: "undetermined",
|
|
2396
|
+
label: "Could not tell how the script is installed",
|
|
2397
|
+
detail: `No tag was found in the DOM and the tag-manager read failed: ${tagManagerErrors.join("; ")}.`,
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
return {
|
|
2401
|
+
...base,
|
|
2402
|
+
method: "not-installed",
|
|
2403
|
+
label: "No Dreamdata script installed",
|
|
2404
|
+
detail: `No Dreamdata tag in the DOM, no request on the wire, and ${gtmNote}.`,
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
function summarizeCmp(phases) {
|
|
2408
|
+
const detected = phases.some((p) => p.consent.cmpDetected);
|
|
2409
|
+
// A vendor recognised in one phase and only text-matched in another is that
|
|
2410
|
+
// vendor: prefer the confirmed reading over the heuristic one.
|
|
2411
|
+
const withVendor = phases.find((p) => p.consent.cmpVendorConfirmed) ?? phases.find((p) => p.consent.cmpVendor !== null);
|
|
2412
|
+
const vendor = withVendor?.consent.cmpVendor ?? null;
|
|
2413
|
+
const vendorConfirmed = withVendor?.consent.cmpVendorConfirmed ?? false;
|
|
2414
|
+
const driven = phases.filter((p) => p.consentAcceptance.succeeded && p.consentAcceptance.vendor !== null);
|
|
2415
|
+
const drivenVendor = driven[0]?.consentAcceptance.vendor ?? null;
|
|
2416
|
+
const drivenPhases = driven.map((p) => p.phase);
|
|
2417
|
+
const blockedTagVendors = [
|
|
2418
|
+
...new Set(phases.flatMap((p) => p.script.matches.map((m) => m.blockedBy)).filter((v) => v !== null)),
|
|
2419
|
+
];
|
|
2420
|
+
const corroboration = blockedTagVendors.length > 0 ? ` A Dreamdata tag on the page is neutralised by ${blockedTagVendors.join(", ")}.` : "";
|
|
2421
|
+
if (!detected) {
|
|
2422
|
+
return {
|
|
2423
|
+
detected: false,
|
|
2424
|
+
vendor: null,
|
|
2425
|
+
vendorConfirmed: false,
|
|
2426
|
+
label: "No CMP detected",
|
|
2427
|
+
detail: `No recognised consent platform global, no known banner element, and no banner-shaped accept/reject control in any phase.${corroboration}`,
|
|
2428
|
+
drivenVendor,
|
|
2429
|
+
drivenPhases,
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
if (vendor === "unknown-custom" || !vendorConfirmed) {
|
|
2433
|
+
return {
|
|
2434
|
+
detected: true,
|
|
2435
|
+
vendor,
|
|
2436
|
+
vendorConfirmed: false,
|
|
2437
|
+
label: "Custom banner — vendor not identified",
|
|
2438
|
+
detail: `A consent banner is on the page, but it exposes no recognised vendor global or element id: it was matched only by its accept/reject button text, so the vendor cannot be named.${corroboration}`,
|
|
2439
|
+
drivenVendor,
|
|
2440
|
+
drivenPhases,
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
return {
|
|
2444
|
+
detected: true,
|
|
2445
|
+
vendor,
|
|
2446
|
+
vendorConfirmed: true,
|
|
2447
|
+
label: vendor ?? "unrecognised",
|
|
2448
|
+
detail: `Recognised by its own global/banner element.${drivenVendor !== null ? ` This run drove it successfully in the ${drivenPhases.join(" and ")} phase(s).` : " This run never managed to drive it."}${corroboration}`,
|
|
2449
|
+
drivenVendor,
|
|
2450
|
+
drivenPhases,
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
/**
|
|
2454
|
+
* Every behavioural check re-stated as "we could not look".
|
|
2455
|
+
*
|
|
2456
|
+
* Each check's own status was derived from the page the browser was shown, so
|
|
2457
|
+
* on a blocked run every one of them is describing a document that does not
|
|
2458
|
+
* belong to this site — including the not_applicable ones, which reached that
|
|
2459
|
+
* status from the same blindness ("no CMP was detected, so there is no gate").
|
|
2460
|
+
* Leaving any of them standing is what turned a wall into a verdict, so they
|
|
2461
|
+
* all go, uniformly, rather than being triaged one by one.
|
|
2462
|
+
*/
|
|
2463
|
+
/**
|
|
2464
|
+
* A Dreamdata bundle is part of this deployment, and it knows which account
|
|
2465
|
+
* it belongs to.
|
|
2466
|
+
*
|
|
2467
|
+
* Every trap in this question is about an ABSENCE that is not a missing
|
|
2468
|
+
* install, and each one is already resolved upstream — which is why this
|
|
2469
|
+
* check reads `shape` rather than any phase's `script.present`:
|
|
2470
|
+
*
|
|
2471
|
+
* - a consent-gated bundle is absent from the DOM until the banner is
|
|
2472
|
+
* answered, so a per-phase reading of `script.present` says "not installed"
|
|
2473
|
+
* about a healthy install. The shape is a union across every phase.
|
|
2474
|
+
* - a CMP-neutralised tag is installed and deliberately not executing;
|
|
2475
|
+
* install.blockedTagVendors names who did it.
|
|
2476
|
+
* - a bundled-no-global (npm) install has no tag in the DOM in ANY phase and
|
|
2477
|
+
* never will, because the SDK is imported as a package.
|
|
2478
|
+
* - a tag-manager-injected bundle lives in the container, not the page.
|
|
2479
|
+
*
|
|
2480
|
+
* The write key is the second half: a bundle that loaded but names no account
|
|
2481
|
+
* cannot deliver anything. It is only ever "warn" here, never "fail" — the
|
|
2482
|
+
* install is present either way, and a run that saw requests has already
|
|
2483
|
+
* proven delivery works regardless of where the key was read from.
|
|
2484
|
+
*/
|
|
2485
|
+
function buildScriptInstalledCheck(phases, shape, shapeReason, install) {
|
|
2486
|
+
const id = "script-installed";
|
|
2487
|
+
const title = "A Dreamdata bundle is installed, with a write key";
|
|
2488
|
+
const everyPhase = phases.map((p) => p.phase);
|
|
2489
|
+
if (shape === "nothing-installed" || shape === "helper-only") {
|
|
2490
|
+
const helperOnly = phases.length > 0 && phases.every((p) => p.script.helperScriptOnly);
|
|
2491
|
+
return {
|
|
2492
|
+
id,
|
|
2493
|
+
title,
|
|
2494
|
+
status: "fail",
|
|
2495
|
+
detail: helperOnly
|
|
2496
|
+
? `The only Dreamdata script found in any phase was a helper (identify-form), which is not a tracking bundle, so nothing on this site can send an event. ${shapeReason}`
|
|
2497
|
+
: `No Dreamdata tracking bundle was found in any phase — not in the DOM, not in a tag-manager container, and no request left the page. ${shapeReason}`,
|
|
2498
|
+
evidence: `deployment.shape: ${shape}, install.method: ${install.method}, install.writeKey: ${install.writeKey ?? "null"}`,
|
|
2499
|
+
phases: everyPhase,
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
if (shape === "undetermined" || shape === "third-party-segment-undetermined" || shape === "page-unreadable") {
|
|
2503
|
+
return {
|
|
2504
|
+
id,
|
|
2505
|
+
title,
|
|
2506
|
+
status: "inconclusive",
|
|
2507
|
+
detail: `Whether a Dreamdata bundle is installed could not be settled by this run. ${shapeReason}`,
|
|
2508
|
+
evidence: `deployment.shape: ${shape}, install.method: ${install.method}`,
|
|
2509
|
+
phases: everyPhase,
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
const blocked = install.blockedTagVendors.length > 0
|
|
2513
|
+
? ` A tag neutralised by ${install.blockedTagVendors.join(" and ")} is installed and gated, not absent.`
|
|
2514
|
+
: "";
|
|
2515
|
+
if (install.writeKey === null) {
|
|
2516
|
+
return {
|
|
2517
|
+
id,
|
|
2518
|
+
title,
|
|
2519
|
+
status: "warn",
|
|
2520
|
+
detail: `${install.label} is installed, but no write key was found — neither in a snippet nor on any request this run saw — so which Dreamdata account it reports to could not be confirmed.${blocked}`,
|
|
2521
|
+
evidence: `deployment.shape: ${shape}, install.method: ${install.method}, install.writeKey: null`,
|
|
2522
|
+
phases: everyPhase,
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
return {
|
|
2526
|
+
id,
|
|
2527
|
+
title,
|
|
2528
|
+
status: "pass",
|
|
2529
|
+
detail: `${install.label} is installed and configured with a write key.${blocked}`,
|
|
2530
|
+
evidence: `deployment.shape: ${shape}, install.method: ${install.method}, install.writeKey: ${install.writeKey}`,
|
|
2531
|
+
phases: everyPhase,
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* Every check on a run where the browser was never served the page.
|
|
2536
|
+
*
|
|
2537
|
+
* This must stay ORDERED BEFORE the diagnosis (see analyzeDeployment): the
|
|
2538
|
+
* diagnosis reads statuses to pick a root cause, and voiding first is what
|
|
2539
|
+
* leaves it nothing to claim, so the only cause that can fire is
|
|
2540
|
+
* `page-unreadable`. Diagnose the pre-void array and the report confidently
|
|
2541
|
+
* tells a customer whose GTM container ships both bundles to install the
|
|
2542
|
+
* script.
|
|
2543
|
+
*/
|
|
2544
|
+
function voidChecks(checks) {
|
|
2545
|
+
return checks.map((check) => ({
|
|
2546
|
+
...check,
|
|
2547
|
+
status: "inconclusive",
|
|
2548
|
+
// Points at pageReadability rather than restating it: the reason is a
|
|
2549
|
+
// paragraph, and repeating it under all thirteen checks buries the report
|
|
2550
|
+
// it is supposed to qualify.
|
|
2551
|
+
detail: "Not evaluated: the browser was never served this site's page, so there was no behaviour to observe. See deployment.pageReadability for what was found instead and what to do about it.",
|
|
2552
|
+
evidence: `deployment.pageReadability.unreadable: true (was: ${check.status} — ${check.evidence})`,
|
|
2553
|
+
}));
|
|
2554
|
+
}
|
|
2555
|
+
export function analyzeDeployment(evidence, requested) {
|
|
2556
|
+
const phases = evidence.phases;
|
|
2557
|
+
const byPhase = new Map(phases.map((p) => [p.phase, p]));
|
|
2558
|
+
// Union across phases: a bundle absent from the DOM pre-consent and present
|
|
2559
|
+
// post-consent is one deployment, not two.
|
|
2560
|
+
const normalBundleSignals = [...new Map(phases.flatMap((p) => p.script.normalBundleSignals).map((s) => [s.detail, s])).values()];
|
|
2561
|
+
const normalBundleConfigured = normalBundleSignals.length > 0;
|
|
2562
|
+
const cookielessBundleConfigured = phases.some((p) => p.script.cookielessSnippet ||
|
|
2563
|
+
p.network.cookielessRequestCount > 0 ||
|
|
2564
|
+
p.globals.cookielessGlobalExists ||
|
|
2565
|
+
p.script.tagManager.cookielessBundleTagFound);
|
|
2566
|
+
// Asked before the shape, because a shape derived from a page the browser
|
|
2567
|
+
// never saw is the exact failure this guards against.
|
|
2568
|
+
const pageReadability = assessPageReadability(phases, evidence.httpProbe);
|
|
2569
|
+
const routing = classifyRouting(phases, evidence.httpProbe);
|
|
2570
|
+
let { shape, shapeReason } = classifyShape(phases, normalBundleConfigured, cookielessBundleConfigured);
|
|
2571
|
+
// A blocked run can still have real install evidence — the plain-HTTP probe
|
|
2572
|
+
// recovers the served markup and the tag-manager container it names, and
|
|
2573
|
+
// neither depends on the browser having rendered anything. So the shape is
|
|
2574
|
+
// only overridden when there is nothing left to conclude it from: with the
|
|
2575
|
+
// install known, "consent-gated-dual" stands as a statement about what is
|
|
2576
|
+
// installed, and pageReadability is what says its BEHAVIOUR is unverified.
|
|
2577
|
+
if (pageReadability.unreadable && !normalBundleConfigured && !cookielessBundleConfigured) {
|
|
2578
|
+
shape = "page-unreadable";
|
|
2579
|
+
shapeReason = pageReadability.reason;
|
|
2580
|
+
}
|
|
2581
|
+
else if (pageReadability.unreadable) {
|
|
2582
|
+
shapeReason = `${shapeReason} This is what is INSTALLED, recovered from the markup the site serves a plain HTTP GET and the tag-manager container that markup names; how it BEHAVES was not observed, because the browser was never served this site's page — see deployment.pageReadability.`;
|
|
2583
|
+
}
|
|
2584
|
+
// A run that would otherwise read "nothing tracking-related is here" can
|
|
2585
|
+
// still have found a REAL Segment install — not a Dreamdata bundle, but
|
|
2586
|
+
// evidence the site sends events somewhere. If Dreamdata receives a copy at
|
|
2587
|
+
// all it is via a server-side Segment destination this harness cannot see
|
|
2588
|
+
// (see thirdPartySegmentSignals), so this is reported as its own
|
|
2589
|
+
// undetermined shape rather than as "nothing-installed" — which would
|
|
2590
|
+
// wrongly read as "go install the script" on a site that already has one,
|
|
2591
|
+
// just not Dreamdata's. Skipped on a blocked run: pageReadability already
|
|
2592
|
+
// has the more fundamental explanation for why nothing showed up.
|
|
2593
|
+
const thirdPartySegmentEvidence = thirdPartySegmentSignals(phases);
|
|
2594
|
+
if (thirdPartySegmentEvidence.length > 0 && !pageReadability.unreadable && (shape === "nothing-installed" || shape === "helper-only")) {
|
|
2595
|
+
shapeReason =
|
|
2596
|
+
`Would otherwise read as "${shape}", but a real Segment install was found instead of a Dreamdata bundle: ` +
|
|
2597
|
+
`${thirdPartySegmentEvidence.join("; ")}. If Dreamdata receives these events at all, it is via a ` +
|
|
2598
|
+
"server-side Segment destination/connection, which happens entirely outside the browser and cannot be " +
|
|
2599
|
+
"confirmed or denied by this run. Check Segment's own source/destination configuration, or Dreamdata's " +
|
|
2600
|
+
"ingestion records, directly.";
|
|
2601
|
+
shape = "third-party-segment-undetermined";
|
|
2602
|
+
}
|
|
2603
|
+
// Both remaining shapes this run could still land on hinge on "the normal
|
|
2604
|
+
// bundle was never seen" — which is not trustworthy evidence when the
|
|
2605
|
+
// banner itself was never successfully answered. Downgrade to
|
|
2606
|
+
// "undetermined" rather than asserting either one with confidence.
|
|
2607
|
+
const consentGateUndetermined = isConsentGateUndetermined(phases);
|
|
2608
|
+
if (consentGateUndetermined && (shape === "nothing-installed" || shape === "cookieless-by-design")) {
|
|
2609
|
+
shapeReason =
|
|
2610
|
+
`Would otherwise read as "${shape}", but a consent banner was detected and this run could not ` +
|
|
2611
|
+
`successfully drive it (grant/reject) in any phase — so the absence of a normal bundle cannot be ` +
|
|
2612
|
+
`trusted. This may be a genuine ${shape === "cookieless-by-design" ? "cookieless-by-design install" : "install with nothing tracking"}, ` +
|
|
2613
|
+
"or the normal bundle may simply be gated behind a banner this harness could not click. Verify the banner manually.";
|
|
2614
|
+
shape = "undetermined";
|
|
2615
|
+
}
|
|
2616
|
+
const segmentCompat = phases.some((p) => p.globals.segmentCompatGlobalExists || p.cookies.usesAjsCompatPrefix);
|
|
2617
|
+
// Read before the checks, because global-handoff's whole question turns on
|
|
2618
|
+
// whether a loader was ever involved.
|
|
2619
|
+
const install = summarizeInstall(phases);
|
|
2620
|
+
const checks = [
|
|
2621
|
+
...buildChecks(byPhase, { normalBundleConfigured, cookielessBundleConfigured, normalBundleSignals }),
|
|
2622
|
+
...buildSecondPageChecks(phases),
|
|
2623
|
+
buildPageViewCountedOnceCheck(phases),
|
|
2624
|
+
buildGlobalHandoffCheck(phases, install, segmentCompat),
|
|
2625
|
+
buildRequestPayloadCheck(phases),
|
|
2626
|
+
buildRequestDeliveryCheck(phases),
|
|
2627
|
+
buildConsoleCheck(phases),
|
|
2628
|
+
buildCollectionCompleteCheck(phases),
|
|
2629
|
+
buildScriptInstalledCheck(phases, shape, shapeReason, install),
|
|
2630
|
+
buildFirstPartyDeliveryCheck(phases, install),
|
|
2631
|
+
];
|
|
2632
|
+
// Built in two steps so the diagnosis sees the RESOLVED checks. Diagnosing
|
|
2633
|
+
// the pre-void array on a blocked run would produce a confident root cause
|
|
2634
|
+
// ("no tracking script is installed") about a bot wall's challenge
|
|
2635
|
+
// document, which is the one thing this layer must never do.
|
|
2636
|
+
const analysis = {
|
|
2637
|
+
shape,
|
|
2638
|
+
shapeReason,
|
|
2639
|
+
pageReadability,
|
|
2640
|
+
consentGateUndetermined,
|
|
2641
|
+
segmentCompat,
|
|
2642
|
+
normalBundleConfigured,
|
|
2643
|
+
normalBundleSignals,
|
|
2644
|
+
cookielessBundleConfigured,
|
|
2645
|
+
thirdPartySegmentSignals: thirdPartySegmentEvidence,
|
|
2646
|
+
cmpVendor: phases.find((p) => p.consent.cmpVendor !== null)?.consent.cmpVendor ?? null,
|
|
2647
|
+
install,
|
|
2648
|
+
cmp: summarizeCmp(phases),
|
|
2649
|
+
consentModeV2: judgeConsentModeV2(phases),
|
|
2650
|
+
routing,
|
|
2651
|
+
phasesObserved: phases.map((p) => p.phase),
|
|
2652
|
+
phasesMissing: requested.filter((name) => !byPhase.has(name)),
|
|
2653
|
+
matrix: phases.map(summarizePhase),
|
|
2654
|
+
checks: pageReadability.unreadable ? voidChecks(checks) : checks,
|
|
2655
|
+
};
|
|
2656
|
+
return { ...analysis, diagnosis: diagnose(analysis, { duplicates: findJudgedDuplicates(phases) }) };
|
|
2657
|
+
}
|
|
2658
|
+
//# sourceMappingURL=deployment.js.map
|