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.
1721const 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
2425let checks = 0 ;
2526const ok = ( msg ) => { checks ++ ; console . log ( ` ok ${ msg } ` ) ; } ;
2627
2728async 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