Skip to content

Commit db73be0

Browse files
committed
fix(measurement): cache robots.txt for 0s, name the property, stitch to gtag
Four measurement defects, three of them silent. ROBOTS.TXT WAS CACHED FOR FOUR HOURS. Measured live on both zones: `cache-control: public, max-age=14400, must-revalidate`. That is Cloudflare Pages' default, not ours — the string 14400 appears in this repo only inside two explanatory comments, and the built _headers had no /robots.txt rule at all, so no zone Cache Rule was involved either. robots.txt is the one file where being wrong for four hours can cost a crawl of everything: a bad Disallow, a missing Allow for a new AI crawler, a corrected Sitemap line. Now max-age=0, must-revalidate — the edge copy and the ETag are kept, the 304s already worked, every fetch just asks first. Same for /llms.txt, /llms-full.txt and the mirrors: they are the ingestion surface, regenerated on every deploy, and an agent holding a four-hour-old index is following links to content that may have moved. Consolidated to one block per path so this does not depend on Cloudflare merging rules that match the same URL. `mp=` IN x-agent-analytics. The hardest question to answer from outside was "events are being sent, but to WHICH property?" — and it is not academic: the two editions shared one property by accident until 2026-08-02, with imqueue.org's traffic landing in the one named imqueue.com. A repo cannot tell you which property owns an id, which is exactly why the id lives in the deployment and its value now appears in the header. G-… ids are public. The api_secret is never named, only its presence. GTAG STITCHING. The edge used a salted IP+UA digest; gtag uses its own `_ga` client id. Both are fine identifiers and they are different ones — so a person reading three pages was one user to gtag and a second, unrelated user to this middleware, and no per-user metric in the property was true for anyone appearing in both streams. The `_ga` cookie is now read when it is already there. Nothing is created, no consent state is touched, and a crawler never borrows one even if a cookie is presented — asserted, because merging GPTBot into a real user's history would corrupt exactly the numbers this exists to make true. probe:agent-analytics WAS BROKEN AND ITS PREMISE WAS BACKWARDS. * `buildEvent(...).events[0]` — buildEvent became async when the visitor digest was added, so this read `.events` off a promise and the probe threw before sending anything. * It REFUSED to run against the site's own property, to keep crawler events out of the human numbers. Rule 1 reversed that on 2026-08-03: both streams share one property on purpose and the separation is the srv_page_view event name, since GA4's `Views` counts only page_view. The refusal was rejecting the only supported configuration. It is now the inverse — a non-site id is probably a stale copy — and the closing advice says to look for srv_page_view, not page_view. * A fourth case added: a browser arriving from a Perplexity answer, which is the outcome the whole programme exists to produce and the one row nobody had seen arrive. Verified against GA4's validation endpoint: four payloads, empty validationMessages, `ai=perplexity` on the fourth. npm test green, 11 analytics checks.
1 parent 5623017 commit db73be0

4 files changed

Lines changed: 200 additions & 29 deletions

File tree

lib/agent-analytics.js

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,36 @@ export function classifyKind(found, surface, browser = false, botish = false) {
385385
return AGENT_ONLY.has(surface) ? 'assistant.other' : 'unknown';
386386
}
387387

388+
/**
389+
* GA4's own client id for this browser, from the `_ga` cookie, or null.
390+
*
391+
* gtag writes `_ga=GA1.1.<client_id>.<timestamp>` (the first two labels are the
392+
* cookie's own version and domain-depth, not part of the id). Handing that same
393+
* value to the Measurement Protocol is what makes a server-sent event and a
394+
* gtag-sent event belong to the SAME GA4 user.
395+
*
396+
* Without it they never do. The edge was using a salted IP+UA digest, which is a
397+
* perfectly good pseudonymous id and a completely different one — so a person
398+
* reading three pages appeared as one user to gtag and a second, unrelated user to
399+
* this middleware, and no per-user metric in the property was true for anyone who
400+
* appeared in both streams.
401+
*
402+
* Falls back to the digest when there is no cookie, which is the common case and
403+
* the whole point: a crawler has no cookie, and a visitor who declined consent has
404+
* none either. Nothing here CREATES a cookie or changes consent — it reads one that
405+
* gtag already set, and only when it is there.
406+
*
407+
* @param {string|null} cookieHeader The raw Cookie header.
408+
* @returns {string|null} GA4's client id, or null.
409+
*/
410+
export function ga4ClientId(cookieHeader) {
411+
if (!cookieHeader) return null;
412+
413+
const match = /(?:^|;\s*)_ga=GA\d+\.\d+\.(\d+\.\d+)/.exec(cookieHeader);
414+
415+
return match ? match[1] : null;
416+
}
417+
388418
/**
389419
* Does this request look like a browser fetching a DOCUMENT?
390420
*
@@ -483,7 +513,7 @@ export function classifySurface(pathname) {
483513
* (`page_referrer` needs nothing: GA4 reads it natively.)
484514
*/
485515
export async function buildEvent({
486-
url, userAgent, status, edition, ip, salt, referrer = null,
516+
url, userAgent, status, edition, ip, salt, referrer = null, gaClientId = null,
487517
isDocument = false, now = Date.now(),
488518
}) {
489519
const seen = describe({ url, userAgent, isDocument });
@@ -511,7 +541,10 @@ export async function buildEvent({
511541
const session = `${visitor ? visitor.slice(0, 16) : slug(crawler)}-${Math.floor(now / 1800000)}`;
512542

513543
return {
514-
client_id: visitor || `${slug(operator)}.${slug(crawler)}`,
544+
// gtag's own client id when the browser has one, so the two streams describe
545+
// the same user; otherwise the visitor digest for a person, or a stable family
546+
// label for a crawler. See ga4ClientId().
547+
client_id: (browser && gaClientId) || visitor || `${slug(operator)}.${slug(crawler)}`,
515548
events: [{
516549
// NOT page_view. gtag owns that name, and GA4's `Views` metric counts only
517550
// page_view/screen_view — so a browser that consents is reported twice, once by
@@ -620,9 +653,17 @@ export function headerNote({ request, env, url, status, edition }) {
620653
// waiting for a real one and then trusting a dashboard.
621654
const { ai_source } = classifyReferrer(request.headers.get('referer'), url);
622655

656+
// mp= names WHICH GA4 property this deployment is feeding. The single hardest
657+
// question to answer from outside was "the events are being sent, but to which
658+
// property?" — and it is not academic here: the two editions shared one property
659+
// by accident until 2026-08-02, with imqueue.org's traffic landing in the one
660+
// named imqueue.com. A repo cannot tell you which property owns an id, which is
661+
// exactly why the id belongs in the deployment and its VALUE belongs in this
662+
// header. G-… ids are public (they ship in every page's source), so there is
663+
// nothing to redact. The api_secret is never named here, only its presence.
623664
return `sent kind=${seen.kind} crawler=${seen.crawler} surface=${seen.surface} ` +
624665
`status=${status} edition=${edition} ai=${ai_source} ` +
625-
`salt=${salted ? 'set' : 'MISSING'}`;
666+
`mp=${env.GA4_MP_MEASUREMENT_ID} salt=${salted ? 'set' : 'MISSING'}`;
626667
}
627668

628669
/**
@@ -657,6 +698,8 @@ export async function trackRequest({ request, env, url, status, edition }) {
657698
// buildEvent, never stored — like the IP and the UA, it only leaves here as a
658699
// hostname and a brand slug.
659700
referrer: request.headers.get('referer'),
701+
// Read, never written, and only used when gtag already set it.
702+
gaClientId: ga4ClientId(request.headers.get('cookie')),
660703
isDocument: isDocumentRequest(request),
661704
status,
662705
edition,

scripts/check-agent-analytics.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,55 @@ async function main() {
340340
}
341341
ok('AI referrals are attributed by brand, and our own navigation is not one');
342342

343+
// --- 12. gtag stitching ---------------------------------------------------
344+
// The edge used a salted IP+UA digest and gtag uses its own `_ga` client id, so a
345+
// person appearing in both streams was two unrelated users and no per-user metric
346+
// in the property was true for them. Reading gtag's id when it is already there
347+
// fixes it; the three assertions are that it is read, that a crawler never
348+
// borrows one, and that nothing is invented when there is no cookie.
349+
{
350+
const { ga4ClientId } = await import('../lib/agent-analytics.js');
351+
352+
assert.strictEqual(
353+
ga4ClientId('foo=1; _ga=GA1.1.1234567890.1699999999; _ga_ABC=x'),
354+
'1234567890.1699999999',
355+
'the _ga cookie\'s client id must be extracted without its version/depth labels',
356+
);
357+
assert.strictEqual(ga4ClientId(null), null, 'no cookie header must give null');
358+
assert.strictEqual(ga4ClientId('_ga=nonsense'), null, 'a malformed _ga must give null');
359+
360+
const browserEvent = await buildEvent({
361+
url: new URL('https://imqueue.org/docs/'),
362+
userAgent: BROWSER, ip: '203.0.113.9', salt: 'test-salt', isDocument: true,
363+
gaClientId: '1234567890.1699999999', status: 200, edition: 'org',
364+
});
365+
366+
assert.strictEqual(browserEvent.client_id, '1234567890.1699999999',
367+
'a browser with a _ga cookie must be reported under gtag\'s client id');
368+
369+
// A crawler has no cookie in practice, but if one is ever presented it must not
370+
// be adopted: GPTBot is not a person, and merging it into a real user's history
371+
// would corrupt exactly the numbers this exists to make true.
372+
const crawlerEvent = await buildEvent({
373+
url: new URL('https://imqueue.org/llms.txt'),
374+
userAgent: GPTBOT, ip: '203.0.113.9', salt: 'test-salt',
375+
gaClientId: '1234567890.1699999999', status: 200, edition: 'org',
376+
});
377+
378+
assert.strictEqual(crawlerEvent.client_id, 'openai.gptbot',
379+
'a crawler must keep its family label even if a _ga cookie is present');
380+
381+
const noCookie = await buildEvent({
382+
url: new URL('https://imqueue.org/docs/'),
383+
userAgent: BROWSER, ip: '203.0.113.9', salt: 'test-salt', isDocument: true,
384+
status: 200, edition: 'org',
385+
});
386+
387+
assert.ok(noCookie.client_id && noCookie.client_id !== '1234567890.1699999999',
388+
'with no cookie the visitor digest must still be used');
389+
}
390+
ok('server events share gtag\'s client id when there is one, and never borrow one');
391+
343392
console.log(`\nAll ${checks} agent-analytics checks passed.`);
344393
}
345394

scripts/probe-agent-analytics.js

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
//
1010
// This is that other half, and it is deliberately opt-in:
1111
//
12-
// export GA4_MP_MEASUREMENT_ID='G-…' # the AGENT property, not the site's
12+
// export GA4_MP_MEASUREMENT_ID='G-…' # the site's own property for this edition
1313
// export GA4_MP_API_SECRET='…' # from that property's own data stream
14+
//
15+
// One property, not two: see the note above sitePropertyIds().
1416
// npm run probe:agent-analytics
1517
//
1618
// It calls the same lib/agent-analytics.js the Cloudflare middleware calls, so a pass
@@ -26,11 +28,20 @@ const path = require('node:path');
2628

2729
const ROOT = path.resolve(__dirname, '..');
2830

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.
31+
// The site's own GA4 property ids, read from where they actually live.
32+
//
33+
// THIS USED TO BE A REFUSAL. The probe would exit 1 if pointed at the site's own
34+
// property, on the grounds that crawler events must not mix into the human numbers
35+
// and GA4 has no selective delete. That reasoning was superseded on 2026-08-03 by
36+
// rule 1 in lib/agent-analytics.js: both streams land in the SAME property on
37+
// purpose, and the separation is done by EVENT NAME — gtag sends page_view,
38+
// everything server-side sends srv_page_view, and GA4's `Views` metric counts only
39+
// the former, so the two can never be summed by accident.
40+
//
41+
// So the site's property is now the CORRECT target and the refusal was rejecting the
42+
// only supported configuration. What remains is worth saying out loud, because a
43+
// probe writes real rows into real reports: it names which property it is about to
44+
// write to, and whether that is the site's.
3445
function sitePropertyIds() {
3546
try {
3647
const cfg = fs.readFileSync(path.join(ROOT, 'eleventy.config.js'), 'utf8');
@@ -41,10 +52,20 @@ function sitePropertyIds() {
4152
}
4253
}
4354

55+
// The fourth case is a BROWSER arriving from a Perplexity answer, which is the
56+
// outcome the whole programme exists to produce and the one row nobody had ever seen
57+
// arrive. It needs an ip and a salt, because a browser with no salt is dropped by
58+
// design.
4459
const CASES = [
4560
['GPTBot/1.2 (+https://openai.com/gptbot)', '/llms.txt', 200],
4661
['ClaudeBot/1.0 (+claudebot@anthropic.com)', '/tutorial/index.md', 200],
4762
['PerplexityBot/1.0', '/probe-missing/index.md', 404],
63+
[
64+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36',
65+
'/compare/',
66+
200,
67+
{ referrer: 'https://www.perplexity.ai/search/imqueue-vs-nats', document: true },
68+
],
4869
];
4970

5071
async function main() {
@@ -68,13 +89,19 @@ async function main() {
6889
}
6990

7091
const siteIds = sitePropertyIds();
92+
const isSiteProperty = siteIds.has(env.GA4_MP_MEASUREMENT_ID);
7193

72-
if (siteIds.has(env.GA4_MP_MEASUREMENT_ID) && !force) {
94+
if (!isSiteProperty && siteIds.size && !force) {
95+
// The inverse of the old refusal, and the one that is now worth having: an id
96+
// that is NOT one of the site's is probably a stale copy from before the
97+
// one-property decision, or someone else's property entirely. Sending there
98+
// produces a clean run and no data where anyone will look for it.
7399
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.',
100+
`${env.GA4_MP_MEASUREMENT_ID} is not a property this site reports to.\n`
101+
+ ` eleventy.config.js names: ${[...siteIds].join(', ')}\n\n`
102+
+ 'Both streams share one property by design (rule 1 in lib/agent-analytics.js);\n'
103+
+ 'the separation is the srv_page_view event name, not a second property. If you\n'
104+
+ 'do mean to write somewhere else, pass --force.',
78105
);
79106
process.exit(1);
80107
}
@@ -84,28 +111,51 @@ async function main() {
84111

85112
console.log(`property: ${env.GA4_MP_MEASUREMENT_ID}${debug ? ' (GA4_MP_DEBUG — validating, NOT recording)' : ''}\n`);
86113

87-
for (const [userAgent, pathname, status] of CASES) {
114+
for (const [userAgent, pathname, status, extra = {}] of CASES) {
88115
const url = new URL(`https://imqueue.org${pathname}`);
89-
const { params } = buildEvent({ url, userAgent, status, edition: 'org' }).events[0];
116+
// AWAIT. buildEvent became async when the visitor digest was added (crypto.subtle
117+
// returns a promise), and this line was reading `.events` off the promise —
118+
// so `npm run probe:agent-analytics` threw before sending anything at all.
119+
const headers = {
120+
'user-agent': userAgent,
121+
...(extra.referrer ? { referer: extra.referrer } : {}),
122+
...(extra.document ? { 'sec-fetch-dest': 'document' } : {}),
123+
};
124+
const probeEnv = extra.document
125+
? { ...env, VISITOR_SALT: env.VISITOR_SALT || 'probe-salt' }
126+
: env;
127+
const built = await buildEvent({
128+
url, userAgent, status, edition: 'org',
129+
referrer: extra.referrer || null,
130+
isDocument: Boolean(extra.document),
131+
ip: extra.document ? '203.0.113.9' : null,
132+
salt: probeEnv.VISITOR_SALT,
133+
});
134+
const { params } = built.events[0];
90135

91136
await trackRequest({
92-
request: { headers: { get: (h) => (h.toLowerCase() === 'user-agent' ? userAgent : null) } },
93-
env,
137+
request: { headers: { get: (h) => headers[h.toLowerCase()] ?? null } },
138+
env: probeEnv,
94139
url,
95140
status,
96141
edition: 'org',
97142
});
98143

99-
console.log(` sent ${params.crawler.padEnd(15)} ${params.surface.padEnd(16)} ${params.status} ${pathname}`);
144+
console.log(
145+
` sent ${params.crawler.padEnd(15)} ${params.surface.padEnd(16)} ${params.status}`
146+
+ ` ai=${(params.ai_source || '-').padEnd(11)} ${pathname}`,
147+
);
100148
}
101149

102150
console.log(
103151
debug
104152
? '\nValidation output is above: an empty validationMessages array means the payload'
105153
+ '\nis well-formed. Nothing was recorded — unset GA4_MP_DEBUG and re-run to send.'
106154
: '\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.'
155+
+ '\nnothing — the proof is the data. Open GA4 → Reports → Realtime on that property'
156+
+ '\nand look for srv_page_view — NOT page_view, which is gtag\'s and is what the'
157+
+ '\n`Views` metric counts. Three of them should appear within a minute or two,'
158+
+ '\nincluding the 404.'
109159
+ '\nIf Realtime stays empty: wrong property, a secret from a different stream, or'
110160
+ '\nGA4 filtering. Re-run with GA4_MP_DEBUG=1 to see what it says about the payload.',
111161
);

src/headers.liquid

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,6 @@ eleventyExcludeFromCollections: true
1212
# while leaving them fully fetchable by AI crawlers and agents — robots.txt
1313
# allows them, noindex only stops SERP indexing, not retrieval.
1414

15-
/*.md
16-
X-Robots-Tag: noindex
17-
18-
/llms.txt
19-
X-Robots-Tag: noindex
20-
21-
/llms-full.txt
22-
X-Robots-Tag: noindex
2315

2416
# ---- caching -------------------------------------------------------------
2517
# Fonts are the one asset class safe to cache forever unasked: the filenames come
@@ -44,6 +36,43 @@ eleventyExcludeFromCollections: true
4436
/js/*
4537
Cache-Control: public, max-age=31536000, immutable
4638

39+
# robots.txt and the agent-facing index files must be CHEAP TO CORRECT.
40+
#
41+
# Measured live on both zones 2026-08-03:
42+
#
43+
# cache-control: public, max-age=14400, must-revalidate
44+
# cf-cache-status: REVALIDATED
45+
#
46+
# That is Cloudflare Pages' default, not ours — the string 14400 appears in this
47+
# repo only in the two comments above, and no zone Cache Rule is involved (checked:
48+
# the built _headers had no /robots.txt entry at all). The consequence is that a
49+
# wrong Disallow, a missing Allow for a new AI crawler, or a corrected Sitemap line
50+
# takes FOUR HOURS to reach the crawler that needs it — and robots.txt is the one
51+
# file on the site where being wrong for four hours can cost a crawl of everything.
52+
#
53+
# Same reasoning for /llms.txt, /llms-full.txt and the mirrors: they are the
54+
# ingestion surface, they are regenerated on every deploy, and an agent holding a
55+
# four-hour-old copy of the index is following links to content that may have moved.
56+
# `must-revalidate` with max-age=0 keeps the edge copy and the ETag — the 304s
57+
# already work — while making every fetch ask first.
58+
# One block per path rather than two, so this does not depend on Cloudflare merging
59+
# rules that match the same URL — the noindex and the Cache-Control belong to the
60+
# same decision anyway.
61+
/robots.txt
62+
Cache-Control: public, max-age=0, must-revalidate
63+
64+
/llms.txt
65+
X-Robots-Tag: noindex
66+
Cache-Control: public, max-age=0, must-revalidate
67+
68+
/llms-full.txt
69+
X-Robots-Tag: noindex
70+
Cache-Control: public, max-age=0, must-revalidate
71+
72+
/*.md
73+
X-Robots-Tag: noindex
74+
Cache-Control: public, max-age=0, must-revalidate
75+
4776
# ---- security ------------------------------------------------------------
4877
# Not ranking factors, but cheap and worth having. Left out on purpose:
4978
# * Strict-Transport-Security — a year-long max-age is effectively

0 commit comments

Comments
 (0)