Skip to content

Commit 1fb4f83

Browse files
committed
refactor(test): trim check:agent-analytics to the five that earn a gate
161 lines -> 100, 13 assertions -> 5. Dropped everything that tested a pure function correct by inspection: which regex matches Applebot, that sessions bucket per half hour, that `edition` is copied into the payload, that client_id is stable. Those were ceremony, and ceremony has a maintenance cost — this suite has already produced two defects of its own. What survives is only what fails silently or takes a site down. This file is the only thing in the repo that loads functions/ at all (check-links and check-sitemap read built output), and that middleware runs in front of every request to both production sites: * the middleware returns next()'s response unchanged * imqueue.net still 301s onto imqueue.org * an analytics failure still serves the page * the crawler's user-agent never reaches the payload — GA4 bot-filters it, so forwarding it collects nothing, forever, while every request answers 2xx * nothing is sent unless BOTH env vars are set (now also covering secret-without-id) Still touches no network: verified under a global fetch spy.
1 parent 2385fc6 commit 1fb4f83

1 file changed

Lines changed: 56 additions & 117 deletions

File tree

scripts/check-agent-analytics.js

Lines changed: 56 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,34 @@
11
#!/usr/bin/env node
2-
// check-agent-analytics.js — guards lib/agent-analytics.js and the middleware that
3-
// carries it.
2+
// check-agent-analytics.js — the five things about lib/agent-analytics.js and
3+
// functions/_middleware.js that are worth a gate.
44
//
5-
// Everything here is offline pure logic, which is why it can live in `npm test`
6-
// alongside check:redirects. It exists because every invariant in that module fails
7-
// SILENTLY when broken:
5+
// This file is the ONLY place in the repo that loads functions/. check-links and
6+
// check-sitemap read built output; nothing else ever executes the middleware that
7+
// runs in front of every request to both production sites.
88
//
9-
// * forward the crawler's user-agent and GA4's bot filter discards the entire
10-
// dataset — no error, just an empty property
11-
// * start emitting events for ordinary browser page views and the agent property
12-
// quietly becomes a worse duplicate of the main one
13-
// * throw anywhere in the middleware and it is not analytics that breaks, it is
14-
// imqueue.org and imqueue.com
9+
// Deliberately small. An earlier version asserted thirteen things, including which
10+
// regex matches Applebot and that sessions bucket per half hour — pure functions
11+
// that are correct by inspection, and ceremony to maintain. What survives is only
12+
// what fails SILENTLY or takes a site down:
1513
//
16-
// None of those show up in a build log, so they are asserted here instead.
14+
// 1-3. the middleware always returns a response, whatever analytics does
15+
// 4. the crawler's user-agent never reaches Google (it would bot-filter the lot)
16+
// 5. nothing is sent unless the deployment is configured to send it
17+
//
18+
// Nothing here touches the network — verified by running it under a global fetch
19+
// spy. Delivery is deliberately NOT tested here; it needs a credential and a
20+
// network, which is what `npm run probe:agent-analytics` is for.
1721
const assert = require('node:assert');
1822

19-
const BROWSER = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36';
20-
const GPTBOT = 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2; +https://openai.com/gptbot';
21-
22-
const u = (p, host = 'imqueue.org') => new URL(`https://${host}${p}`);
23+
const GPTBOT = 'Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)';
2324

2425
let checks = 0;
2526
const ok = (msg) => { checks++; console.log(` ok ${msg}`); };
2627

2728
async function main() {
28-
const { buildEvent, classifyCrawler, classifySurface, trackRequest } =
29-
await import('../lib/agent-analytics.js');
30-
31-
// --- rule 2: only what gtag misses ---------------------------------------
32-
assert.strictEqual(
33-
buildEvent({ url: u('/tutorial/'), userAgent: BROWSER, status: 200, edition: 'org' }),
34-
null,
35-
'a browser reading an HTML page is gtag\'s job and must produce no event',
36-
);
37-
ok('browser + HTML page → no event (gtag already measures it)');
38-
39-
const mirror = buildEvent({ url: u('/tutorial/index.md'), userAgent: BROWSER, status: 200, edition: 'org' });
40-
assert.ok(mirror, 'the agent surface counts even from an unrecognised client');
41-
assert.strictEqual(mirror.events[0].params.surface, 'markdown-mirror');
42-
assert.strictEqual(mirror.events[0].params.crawler, 'unclassified');
43-
ok('any client + .md mirror → event, surface=markdown-mirror');
44-
45-
const botHtml = buildEvent({ url: u('/'), userAgent: GPTBOT, status: 200, edition: 'org' });
46-
assert.ok(botHtml, 'a crawler on an HTML page is invisible to gtag and must be counted');
47-
assert.strictEqual(botHtml.events[0].params.surface, 'html');
48-
assert.strictEqual(botHtml.events[0].params.crawler, 'GPTBot');
49-
assert.strictEqual(botHtml.events[0].params.operator, 'OpenAI');
50-
ok('crawler + HTML page → event, surface=html (gtag never fires for it)');
51-
52-
// --- rule 3: the crawler UA must never reach Google ----------------------
53-
for (const body of [mirror, botHtml]) {
54-
assert.ok(
55-
!JSON.stringify(body).includes('Mozilla'),
56-
'the crawler user-agent must not appear anywhere in the payload — GA4 would '
57-
+ 'bot-filter the hit and the whole dataset would silently vanish',
58-
);
59-
}
60-
ok('no user-agent string anywhere in the payload (GA4 bot filter would drop it)');
61-
62-
// --- rule 4: identity is a crawler family, never a visitor ---------------
63-
const a = buildEvent({ url: u('/llms.txt'), userAgent: GPTBOT, status: 200, edition: 'org' });
64-
const b = buildEvent({ url: u('/api/index.md'), userAgent: GPTBOT, status: 404, edition: 'org' });
65-
assert.strictEqual(a.client_id, b.client_id, 'same crawler → same client_id');
66-
const claude = buildEvent({ url: u('/llms.txt'), userAgent: 'ClaudeBot/1.0', status: 200, edition: 'org' });
67-
assert.notStrictEqual(a.client_id, claude.client_id, 'different crawler → different client_id');
68-
assert.ok(!/\d+\.\d+\.\d+\.\d+/.test(JSON.stringify(a)), 'no IP-shaped value in the payload');
69-
ok('client_id is stable per crawler family and carries nothing identifying');
70-
71-
// --- the fields the reports are built on ---------------------------------
72-
assert.strictEqual(b.events[0].params.status, '404', 'status is carried, as a string');
73-
assert.strictEqual(b.events[0].params.page_location, 'https://imqueue.org/api/index.md');
74-
assert.strictEqual(b.events[0].params.page_title, '/api/index.md');
75-
assert.strictEqual(b.events[0].name, 'page_view',
76-
'page_view is what populates GA4\'s built-in Pages reports without custom dimensions');
77-
assert.strictEqual(b.events[0].params.engagement_time_msec, 1);
78-
assert.ok(b.events[0].params.session_id, 'a session_id is required or engagement reads as zero');
79-
ok('page_view + page_location + status + session_id present');
80-
81-
const com = buildEvent({ url: u('/index.md', 'imqueue.com'), userAgent: GPTBOT, status: 200, edition: 'com' });
82-
assert.strictEqual(com.events[0].params.edition, 'com');
83-
ok('edition is carried, so one property can hold both sites');
84-
85-
// Sessions bucket by half-hour: a crawl burst is a session, and a crawler that
86-
// comes back tomorrow is not one long visit.
87-
const t0 = 1_800_000_000_000;
88-
const s1 = buildEvent({ url: u('/llms.txt'), userAgent: GPTBOT, status: 200, edition: 'org', now: t0 });
89-
const s2 = buildEvent({ url: u('/llms.txt'), userAgent: GPTBOT, status: 200, edition: 'org', now: t0 + 60_000 });
90-
const s3 = buildEvent({ url: u('/llms.txt'), userAgent: GPTBOT, status: 200, edition: 'org', now: t0 + 1_900_000 });
91-
assert.strictEqual(s1.events[0].params.session_id, s2.events[0].params.session_id);
92-
assert.notStrictEqual(s1.events[0].params.session_id, s3.events[0].params.session_id);
93-
ok('session_id buckets per half hour (a crawl burst is one session)');
94-
95-
// --- classifiers ---------------------------------------------------------
96-
assert.strictEqual(classifyCrawler(BROWSER), null);
97-
assert.strictEqual(classifyCrawler('').crawler, 'no-user-agent');
98-
assert.strictEqual(classifyCrawler('node').operator, 'Generic client');
99-
assert.strictEqual(classifySurface('/blog/topics/rpc/'), null);
100-
assert.strictEqual(classifySurface('/api/search-index.json'), 'symbol-index');
101-
assert.strictEqual(classifySurface('/sitemap-api.xml'), 'sitemap');
102-
ok('classifiers agree on browsers, empty UAs, HTTP clients and each surface');
103-
104-
// --- rule 5: inert without credentials -----------------------------------
105-
const req = { headers: { get: () => GPTBOT } };
106-
assert.strictEqual(
107-
trackRequest({ request: req, env: {}, url: u('/llms.txt'), status: 200, edition: 'org' }),
108-
null,
109-
'with no env vars this must do nothing — a fork or preview deploy sends nothing',
110-
);
111-
assert.strictEqual(
112-
trackRequest({ request: req, env: { GA4_MP_MEASUREMENT_ID: 'G-X' }, url: u('/llms.txt'), status: 200, edition: 'org' }),
113-
null,
114-
'half-configured is still inert',
115-
);
116-
ok('inert unless BOTH GA4_MP_MEASUREMENT_ID and GA4_MP_API_SECRET are set');
117-
118-
// --- the middleware contract: always returns, never throws ---------------
29+
const { buildEvent, trackRequest } = await import('../lib/agent-analytics.js');
11930
const { onRequest } = await import('../functions/_middleware.js');
31+
12032
const page = new Response('hi', { status: 200 });
12133
const ctx = (url, extra = {}) => ({
12234
request: new Request(url, { headers: { 'user-agent': GPTBOT } }),
@@ -126,23 +38,21 @@ async function main() {
12638
...extra,
12739
});
12840

129-
assert.strictEqual(await onRequest(ctx('https://imqueue.org/llms.txt')), page,
130-
'the response from next() must be passed through untouched');
41+
// --- the middleware contract ---------------------------------------------
42+
assert.strictEqual(
43+
await onRequest(ctx('https://imqueue.org/llms.txt')), page,
44+
'the response from next() must be passed through untouched',
45+
);
13146
ok('middleware returns next()\'s response unchanged');
13247

13348
const moved = await onRequest(ctx('https://imqueue.net/tutorial/'));
13449
assert.strictEqual(moved.status, 301);
13550
assert.strictEqual(moved.headers.get('location'), 'https://imqueue.org/tutorial/');
13651
ok('imqueue.net still 301s onto imqueue.org');
13752

138-
// Constraint 1 in functions/_middleware.js: this file runs in front of every
139-
// request to BOTH sites, so a throw there is an outage, not a lost metric.
140-
//
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.
53+
// A throw in this file is an outage, not a lost metric. Provoked with a request the
54+
// middleware cannot parse — new URL() throws inside the analytics block — so the
55+
// catch is reached with no credentials, no fetch stub and no send path.
14656
const unparseable = await onRequest({
14757
request: { url: '://not-a-url', headers: { get: () => null } },
14858
env: { GA4_MP_MEASUREMENT_ID: 'G-X', GA4_MP_API_SECRET: 's' },
@@ -152,6 +62,35 @@ async function main() {
15262
assert.strictEqual(unparseable, page, 'a broken analytics path must still serve the page');
15363
ok('analytics failure degrades to "no measurement", never to "no page"');
15464

65+
// --- the two silent failures ---------------------------------------------
66+
// GA4 discards traffic it identifies as an IAB bot, and the Measurement Protocol
67+
// only knows the user-agent if you send it. Forward it and the property collects
68+
// nothing, forever, while every request still answers 2xx.
69+
const event = buildEvent({
70+
url: new URL('https://imqueue.org/tutorial/index.md'),
71+
userAgent: GPTBOT,
72+
status: 200,
73+
edition: 'org',
74+
});
75+
assert.ok(event, 'a crawler fetching a mirror must produce an event');
76+
assert.ok(
77+
!JSON.stringify(event).includes('Mozilla'),
78+
'the crawler user-agent must not appear anywhere in the payload',
79+
);
80+
ok('no user-agent string in the payload (GA4 would bot-filter the whole dataset)');
81+
82+
// A fork, a preview deploy or a half-finished setup must send nothing anywhere.
83+
const req = { headers: { get: () => GPTBOT } };
84+
const url = new URL('https://imqueue.org/llms.txt');
85+
for (const env of [{}, { GA4_MP_MEASUREMENT_ID: 'G-X' }, { GA4_MP_API_SECRET: 's' }]) {
86+
assert.strictEqual(
87+
trackRequest({ request: req, env, url, status: 200, edition: 'org' }),
88+
null,
89+
`must be inert with env ${JSON.stringify(env)}`,
90+
);
91+
}
92+
ok('inert unless BOTH GA4_MP_MEASUREMENT_ID and GA4_MP_API_SECRET are set');
93+
15594
console.log(`\nAll ${checks} agent-analytics checks passed.`);
15695
}
15796

0 commit comments

Comments
 (0)