Skip to content

Commit 2385fc6

Browse files
committed
refactor(test): no test may reach the send path; delivery gets its own probe
The middleware fail-open assertion needed fake credentials and a stubbed global fetch just to reach a catch block. Before 1e6c94c the stub was missing and the suite POSTed junk to google-analytics.com on every `npm test`, pre-commit and CI run; with the stub it was harmless but still a test that had to be defused to be safe. Replaced with a request the middleware cannot parse — `new URL('://not-a-url')` throws inside the analytics block — which reaches the same catch with no credentials, no stub and no send path. Nothing in this suite now goes near fetch: verified by running it under a global fetch spy, which records nothing. Delivery is the other half, and it cannot be asserted offline. `npm run probe:agent-analytics` sends three events (one of them a 404) through lib/agent-analytics.js itself, so a pass means the module, the credential and the property agree rather than that a hand-written payload happens to be valid. Opt-in, never in `npm test`: it needs a credential and a network, neither of which belongs in a gate that runs at pre-commit and on pull requests. Three things the probe refuses to do: run without both variables exported (with a hint about the shell-variable-vs-export trap that costs an hour the first time), send to the property eleventy.config.js reports to unless --force, and print the secret — including in error messages, where a fetch failure would otherwise echo a URL that carries api_secret. The site's property is READ from the config, not copied here.
1 parent 1e6c94c commit 2385fc6

4 files changed

Lines changed: 148 additions & 41 deletions

File tree

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,21 @@ Setup, all free:
114114
`GA4_MP_MEASUREMENT_ID` and `GA4_MP_API_SECRET` (encrypt the secret). With either
115115
missing the module does nothing at all, which is also what keeps forks and preview
116116
deploys silent.
117-
4. **Verify once** by also setting `GA4_MP_DEBUG=1`: hits go to GA4's validation
118-
endpoint and the result is logged to the project's function logs, where an empty
119-
`validationMessages` means the payload is good. **Then unset it** — the validation
120-
endpoint reports but records nothing.
117+
4. **Verify delivery**`npm test` proves the logic offline and can prove nothing about
118+
a real property, so this is a separate, opt-in step:
119+
120+
```bash
121+
export GA4_MP_MEASUREMENT_ID='G-…' GA4_MP_API_SECRET=''
122+
npm run probe:agent-analytics # GA4_MP_DEBUG=1 to validate instead of send
123+
```
124+
125+
It sends three events through `lib/agent-analytics.js` itself — including a 404 — so
126+
a pass means the module, the credential and the property agree. GA4 answers 204 to
127+
valid and invalid hits alike, so the proof is **Realtime**, not the exit code. The
128+
probe refuses to run against the property `eleventy.config.js` reports to, and never
129+
prints the secret. On Pages, `GA4_MP_DEBUG=1` does the same validation server-side
130+
and logs the verdict to the project's function logs; **unset it afterwards**, since
131+
the validation endpoint reports but records nothing.
121132
5. Optional, for slicing: Admin → Custom definitions → register `crawler`,
122133
`operator`, `surface`, `status` and `edition` as **event-scoped custom
123134
dimensions**. Events are sent as `page_view` with `page_location`, so the built-in

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"test": "npm run check:redirects && npm run check:agent-analytics && npm run check:dates && npm run check:links && npm run check:sitemap",
3131
"check:redirects": "node scripts/check-redirects.js",
3232
"check:agent-analytics": "node scripts/check-agent-analytics.js",
33+
"probe:agent-analytics": "node scripts/probe-agent-analytics.js",
3334
"check:dates": "node scripts/gen-page-dates.js --check",
3435
"check:sitemap": "node scripts/check-sitemap.js",
3536
"check:api-versions": "node scripts/check-api-versions.js",

scripts/check-agent-analytics.js

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -136,46 +136,22 @@ async function main() {
136136
ok('imqueue.net still 301s onto imqueue.org');
137137

138138
// Constraint 1 in functions/_middleware.js: this file runs in front of every
139-
// request to BOTH sites, so a throw here is an outage. A context missing
140-
// waitUntil is the cheapest way to simulate the runtime not behaving as expected.
139+
// request to BOTH sites, so a throw there is an outage, not a lost metric.
141140
//
142-
// This is the ONLY assertion that reaches the send path, and global fetch is
143-
// stubbed for it. Not tidiness: trackRequest starts the fetch BEFORE the caller
144-
// touches waitUntil, so without the stub this suite POSTs to Google on every
145-
// `npm test`, every pre-commit and every CI run — which contradicts the "offline
146-
// pure logic" claim at the top of this file and litters a stranger's property with
147-
// junk hits. Stubbing also makes the assertion stronger: the endpoint gets checked
148-
// without anyone being contacted.
149-
const realFetch = globalThis.fetch;
150-
const sends = [];
151-
globalThis.fetch = (target) => {
152-
sends.push(String(target));
153-
154-
return Promise.resolve(new Response('{}'));
155-
};
156-
157-
let broken;
158-
try {
159-
broken = await onRequest({
160-
request: new Request('https://imqueue.org/llms.txt'),
161-
env: { GA4_MP_MEASUREMENT_ID: 'G-X', GA4_MP_API_SECRET: 's' },
162-
next: async () => page,
163-
// no waitUntil at all
164-
});
165-
} finally {
166-
globalThis.fetch = realFetch;
167-
}
168-
169-
assert.strictEqual(broken, page, 'a broken analytics path must still serve the page');
141+
// Provoked with a request the middleware cannot even parse — `new URL()` throws
142+
// inside the analytics block — rather than by configuring credentials and stubbing
143+
// fetch to reach the same catch. Same invariant, no fake secrets, and nothing in
144+
// this suite goes anywhere near the send path: `npm test` runs at pre-commit and on
145+
// every pull request, and a gate has no business making network calls, valid or not.
146+
const unparseable = await onRequest({
147+
request: { url: '://not-a-url', headers: { get: () => null } },
148+
env: { GA4_MP_MEASUREMENT_ID: 'G-X', GA4_MP_API_SECRET: 's' },
149+
next: async () => page,
150+
waitUntil: () => {},
151+
});
152+
assert.strictEqual(unparseable, page, 'a broken analytics path must still serve the page');
170153
ok('analytics failure degrades to "no measurement", never to "no page"');
171154

172-
assert.strictEqual(sends.length, 1, 'exactly one send per tracked request');
173-
assert.ok(
174-
sends[0].startsWith('https://www.google-analytics.com/mp/collect?'),
175-
`the send must go to GA4's collect endpoint, got: ${sends[0].split('?')[0]}`,
176-
);
177-
ok('one send, to GA4\'s collect endpoint (verified without a network call)');
178-
179155
console.log(`\nAll ${checks} agent-analytics checks passed.`);
180156
}
181157

scripts/probe-agent-analytics.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
#!/usr/bin/env node
2+
// probe-agent-analytics.js — does the agent-analytics pipeline actually deliver?
3+
//
4+
// `npm test` proves the module's LOGIC offline: what gets sent, what gets skipped,
5+
// that the crawler's user-agent never reaches Google, that a failure cannot cost a
6+
// page view. What it cannot prove is delivery — that needs a real property, a real
7+
// Measurement Protocol secret and a network, none of which belong in a gate that
8+
// runs at pre-commit and on every pull request.
9+
//
10+
// This is that other half, and it is deliberately opt-in:
11+
//
12+
// export GA4_MP_MEASUREMENT_ID='G-…' # the AGENT property, not the site's
13+
// export GA4_MP_API_SECRET='…' # from that property's own data stream
14+
// npm run probe:agent-analytics
15+
//
16+
// It calls the same lib/agent-analytics.js the Cloudflare middleware calls, so a pass
17+
// here means the module, the credential and the property agree — not that a
18+
// hand-written payload happens to be valid.
19+
//
20+
// GA4_MP_DEBUG=1 routes to GA4's validation endpoint instead, which reports what is
21+
// wrong with a payload but RECORDS NOTHING. Useful once; useless as a habit.
22+
//
23+
// Prints no secret. The value never appears in output, and never in an error message.
24+
const fs = require('node:fs');
25+
const path = require('node:path');
26+
27+
const ROOT = path.resolve(__dirname, '..');
28+
29+
// The site's own GA4 property, read from where it actually lives rather than copied
30+
// here. Sending crawler events to it is the one mistake with no undo — GA4 has no
31+
// selective delete — so the probe refuses by default. Deriving it means this cannot
32+
// go stale when the site's id changes; if the read fails, the check is skipped rather
33+
// than guessed at.
34+
function sitePropertyIds() {
35+
try {
36+
const cfg = fs.readFileSync(path.join(ROOT, 'eleventy.config.js'), 'utf8');
37+
38+
return new Set([...cfg.matchAll(/ga4:\s*"(G-[A-Z0-9]+)"/g)].map((m) => m[1]));
39+
} catch {
40+
return new Set();
41+
}
42+
}
43+
44+
const CASES = [
45+
['GPTBot/1.2 (+https://openai.com/gptbot)', '/llms.txt', 200],
46+
['ClaudeBot/1.0 (+claudebot@anthropic.com)', '/tutorial/index.md', 200],
47+
['PerplexityBot/1.0', '/probe-missing/index.md', 404],
48+
];
49+
50+
async function main() {
51+
const force = process.argv.includes('--force');
52+
const env = {
53+
GA4_MP_MEASUREMENT_ID: process.env.GA4_MP_MEASUREMENT_ID,
54+
GA4_MP_API_SECRET: process.env.GA4_MP_API_SECRET,
55+
GA4_MP_DEBUG: process.env.GA4_MP_DEBUG,
56+
};
57+
58+
if (!env.GA4_MP_MEASUREMENT_ID || !env.GA4_MP_API_SECRET) {
59+
console.error(
60+
'Both GA4_MP_MEASUREMENT_ID and GA4_MP_API_SECRET must be EXPORTED in this shell.\n'
61+
+ ' ID: ' + (env.GA4_MP_MEASUREMENT_ID || '<not visible to child processes>') + '\n'
62+
+ ' SECRET: ' + (env.GA4_MP_API_SECRET ? 'visible' : '<not visible to child processes>') + '\n\n'
63+
+ 'Note `echo` finds a plain `FOO=bar` assignment but no child process does — only\n'
64+
+ 'exported variables are handed to one. If echo shows them but this does not:\n'
65+
+ ' export GA4_MP_MEASUREMENT_ID GA4_MP_API_SECRET',
66+
);
67+
process.exit(1);
68+
}
69+
70+
const siteIds = sitePropertyIds();
71+
72+
if (siteIds.has(env.GA4_MP_MEASUREMENT_ID) && !force) {
73+
console.error(
74+
`Refusing: ${env.GA4_MP_MEASUREMENT_ID} is the property the SITE reports to `
75+
+ '(eleventy.config.js).\nCrawler events there mix into the numbers that describe '
76+
+ 'humans, and GA4 has no\nselective delete. Point this at the agent property, '
77+
+ 'or pass --force if you mean it.',
78+
);
79+
process.exit(1);
80+
}
81+
82+
const { trackRequest, buildEvent } = await import('../lib/agent-analytics.js');
83+
const debug = Boolean(env.GA4_MP_DEBUG);
84+
85+
console.log(`property: ${env.GA4_MP_MEASUREMENT_ID}${debug ? ' (GA4_MP_DEBUG — validating, NOT recording)' : ''}\n`);
86+
87+
for (const [userAgent, pathname, status] of CASES) {
88+
const url = new URL(`https://imqueue.org${pathname}`);
89+
const { params } = buildEvent({ url, userAgent, status, edition: 'org' }).events[0];
90+
91+
await trackRequest({
92+
request: { headers: { get: (h) => (h.toLowerCase() === 'user-agent' ? userAgent : null) } },
93+
env,
94+
url,
95+
status,
96+
edition: 'org',
97+
});
98+
99+
console.log(` sent ${params.crawler.padEnd(15)} ${params.surface.padEnd(16)} ${params.status} ${pathname}`);
100+
}
101+
102+
console.log(
103+
debug
104+
? '\nValidation output is above: an empty validationMessages array means the payload'
105+
+ '\nis well-formed. Nothing was recorded — unset GA4_MP_DEBUG and re-run to send.'
106+
: '\nDone. GA4 answers 204 to valid and invalid hits alike, so no error here proves'
107+
+ '\nnothing — the proof is the data. Open GA4 → Reports → Realtime on that property;'
108+
+ '\nthree page_view events should appear within a minute or two, including the 404.'
109+
+ '\nIf Realtime stays empty: wrong property, a secret from a different stream, or'
110+
+ '\nGA4 filtering. Re-run with GA4_MP_DEBUG=1 to see what it says about the payload.',
111+
);
112+
}
113+
114+
main().catch((err) => {
115+
// Never interpolate the error blindly — a fetch failure can echo the request URL,
116+
// and that URL carries api_secret as a query parameter.
117+
console.error(`\nFAIL ${String(err.message).replace(/api_secret=[^&\s]+/g, 'api_secret=<redacted>')}`);
118+
process.exit(1);
119+
});

0 commit comments

Comments
 (0)