Skip to content

Commit 71c73d1

Browse files
Add passive page-load beacon
The worker previously only wrote analytics rows on user-triggered actions (chat, council, summary, playback). There was no event between page render and the first interaction, so a "landed and bounced" session was indistinguishable from "never reached the page at all". - New /v1/page endpoint on agora-cosmica-llm: anonymous, no auth, IP rate-limited (100/h), validates path against a tight regex, writes a row with index1='page' to agora_llm. - New client beacon at utils/pageBeacon.ts mirrors playbackBeacon: fires once at module load from index.tsx, right after captureGclid(). - Path is the only added blob slot. Slots 4-7 stay aligned with the existing schema (language, status, marketing_source, country) so dashboard queries can aggregate page rows the same way. - MEASUREMENT.md updated to disclose the new endpoint + path field.
1 parent d848c71 commit 71c73d1

6 files changed

Lines changed: 168 additions & 1 deletion

File tree

client/src/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,19 @@ import { useDomainStore } from './stores/domainStore';
1212
import { initializeSeedsCache } from './services/seedCacheInitializer';
1313
import { LocalStorageAdapter } from './storage/localAdapter';
1414
import { captureGclid } from './utils/public/gclidCapture';
15+
import { sendPageBeacon } from './utils/pageBeacon';
1516

1617
// Capture utm_source / gclid from the landing URL before any router redirect
1718
// can strip them. Must run synchronously at module load — running inside a
1819
// React effect is too late, because the catch-all <Navigate> in App.tsx
1920
// rewrites the URL before App's effect fires.
2021
captureGclid();
22+
23+
// Page-load beacon: count this arrival in analytics with the marketing source
24+
// captureGclid just resolved. Anonymous, fire-and-forget. Closes the gap
25+
// between landing-page render and the existing engagement events (chat,
26+
// playback) so we can measure true bounce rate per channel.
27+
sendPageBeacon();
2128
// Service Worker registration (DISABLED until Q1 2026 - Offline Mode implementation)
2229
// Currently causes 404 errors since service-worker.js doesn't exist yet
2330
// Roadmap: CLAUDE.md Q1 2026 - Offline Mode with service worker

client/src/utils/pageBeacon.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Anonymous page-load beacon
2+
// Fires once at App module load (from index.tsx) so we count arrivals,
3+
// not just post-engagement events. Same fire-and-forget posture as the
4+
// playback beacon — see utils/playbackBeacon.ts.
5+
//
6+
// Privacy: aggregate counter only. No user dimension. No IP retention.
7+
// Disclosed in docs/MEASUREMENT.md alongside the other event counters.
8+
9+
import { getMarketingSource } from './public/gclidCapture';
10+
11+
const API_BASE = import.meta.env.VITE_FREE_TIER_API_URL || '';
12+
13+
/**
14+
* Detect the current UI language from the document or localStorage. Mirrors
15+
* the playbackBeacon helper so language labels stay consistent across event
16+
* types. Falls back to 'en' if nothing is set.
17+
*/
18+
function detectLanguage(): 'en' | 'de' {
19+
try {
20+
const docLang = typeof document !== 'undefined' ? document.documentElement.lang : '';
21+
if (docLang && docLang.toLowerCase().startsWith('de')) return 'de';
22+
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('language') : null;
23+
if (stored && stored.toLowerCase().startsWith('de')) return 'de';
24+
if (typeof navigator !== 'undefined' && navigator.language && navigator.language.toLowerCase().startsWith('de')) return 'de';
25+
} catch {
26+
// Ignore — fall through to 'en'
27+
}
28+
return 'en';
29+
}
30+
31+
/**
32+
* Send a page-load beacon. Fire-and-forget — never throws, never blocks the
33+
* caller, never breaks app boot on network failure. Captures only:
34+
* - path (no query string, validated server-side against a regex)
35+
* - language (en/de)
36+
* - marketing source (X-Marketing-Source header, closed allowlist)
37+
* - country (CF-edge two-letter code, server-side)
38+
*
39+
* No user dimension, no message content, no fingerprint.
40+
*/
41+
export function sendPageBeacon(): void {
42+
try {
43+
const path = typeof window !== 'undefined' ? window.location.pathname : '';
44+
const body = JSON.stringify({
45+
path,
46+
language: detectLanguage(),
47+
});
48+
49+
fetch(`${API_BASE}/v1/page`, {
50+
method: 'POST',
51+
headers: {
52+
'Content-Type': 'application/json',
53+
'X-Marketing-Source': getMarketingSource(),
54+
},
55+
body,
56+
keepalive: true,
57+
}).catch(() => {
58+
// Silent fail — beacons must never surface to the user.
59+
});
60+
} catch {
61+
// Silent fail
62+
}
63+
}

docs/MEASUREMENT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Per anonymous request, written to Cloudflare Analytics Engine:
1010

1111
| Counter | Example values | Why |
1212
|---|---|---|
13-
| Endpoint | `chat`, `council`, `summary`, `session`, `speech`, `transcriptions`, `playback` | See which features are used |
13+
| Endpoint | `chat`, `council`, `summary`, `session`, `speech`, `transcriptions`, `playback`, `page` | See which features are used |
14+
| Path | Sanitized landing path on `page` events (`/`, `/de/`, `/figures/<slug>`, ...) | Distinguish home arrivals from deep-link arrivals |
1415
| Figure | `aurelius`, `kahlo`, `rumi`, ... | See which figures resonate |
1516
| Mode | `story`, `wisdom`, `prism`, `quest`, `freetalk`, `council` | See which chapters land |
1617
| Language | `en` or `de` | See bilingual reach |

workers/llm-proxy/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { handleQuota } from './routes/quota';
99
import { handleSummary } from './routes/summary';
1010
import { handleConversions, handleConversionStats } from './routes/conversions';
1111
import { handlePlayback } from './routes/playback';
12+
import { handlePage } from './routes/page';
1213
import type { Env } from './utils/types';
1314

1415
function getCorsHeaders(request: Request, env: Env): Record<string, string> {
@@ -91,6 +92,8 @@ export default {
9192
response = await handleConversionStats(request, env);
9293
} else if (path === '/v1/playback' && request.method === 'POST') {
9394
response = await handlePlayback(request, env);
95+
} else if (path === '/v1/page' && request.method === 'POST') {
96+
response = await handlePage(request, env);
9497
} else {
9598
response = new Response(
9699
JSON.stringify({ error: 'Not found' }),
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Anonymous page-load beacon
2+
// Fires from the client at App module load, before any user interaction.
3+
// Counts arrivals (vs. the existing playback/chat events which only fire
4+
// post-engagement), so we can measure true bounce rate per channel.
5+
//
6+
// Privacy: aggregate counter only. No user dimension. No IP retention.
7+
// Same legal posture as the rest of analytics — see docs/MEASUREMENT.md.
8+
9+
import { trackPageView, readMarketingSource, readCountry } from '../utils/analytics';
10+
import type { Env } from '../utils/types';
11+
12+
interface PagePayload {
13+
path?: string;
14+
language?: string;
15+
}
16+
17+
// Path is sanitized server-side to a safe shape. Anything else becomes empty
18+
// in blob2 so the row is still recorded but the path slot stays clean.
19+
const PATH_RE = /^\/[A-Za-z0-9/_-]{0,60}$/;
20+
const VALID_LANGS = new Set(['en', 'de']);
21+
22+
// Rate limit: 100 page beacons per IP per hour. A real user generates 1-10
23+
// hard page loads per session; this caps abuse without blocking real use.
24+
const RATE_LIMIT_WINDOW = 3600;
25+
const RATE_LIMIT_MAX = 100;
26+
27+
export async function handlePage(request: Request, env: Env): Promise<Response> {
28+
if (request.method !== 'POST') {
29+
return new Response('Method not allowed', { status: 405 });
30+
}
31+
32+
let payload: PagePayload;
33+
try {
34+
payload = await request.json();
35+
} catch {
36+
// Be quiet on malformed beacons — the client doesn't get to know.
37+
return new Response(null, { status: 204 });
38+
}
39+
40+
// Rate limit per IP. The IP itself is hashed via KV key namespace and
41+
// never retained beyond the 1-hour window.
42+
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
43+
const rateLimitKey = `page_rl:${ip}`;
44+
const currentCount = parseInt(await env.RATE_LIMITS.get(rateLimitKey) || '0', 10);
45+
if (currentCount >= RATE_LIMIT_MAX) {
46+
return new Response(null, { status: 204 });
47+
}
48+
await env.RATE_LIMITS.put(rateLimitKey, String(currentCount + 1), { expirationTtl: RATE_LIMIT_WINDOW });
49+
50+
const path = (typeof payload.path === 'string' && PATH_RE.test(payload.path))
51+
? payload.path
52+
: '';
53+
const lang = (typeof payload.language === 'string') && VALID_LANGS.has(payload.language.slice(0, 2).toLowerCase())
54+
? payload.language.slice(0, 2).toLowerCase()
55+
: '';
56+
57+
trackPageView(env, {
58+
path,
59+
language: lang,
60+
marketingSource: readMarketingSource(request),
61+
country: readCountry(request),
62+
});
63+
64+
return new Response(null, { status: 204 });
65+
}

workers/llm-proxy/src/utils/analytics.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,34 @@ export function trackPlayback(
146146
}
147147
}
148148

149+
/**
150+
* Track a page-load beacon. Fires once on App mount in the client, before any
151+
* user interaction. Lets the dashboard show arrivals per channel, not just
152+
* post-engagement events.
153+
* dataset: agora_llm
154+
* blobs: ['page', path, '', language, '200', marketing_source, country]
155+
* indexes: ['page']
156+
*/
157+
export function trackPageView(
158+
env: Env,
159+
data: {
160+
path: string;
161+
language: string;
162+
marketingSource: string;
163+
country: string;
164+
}
165+
): void {
166+
try {
167+
env.ANALYTICS.writeDataPoint({
168+
blobs: ['page', data.path, '', data.language, '200', data.marketingSource, data.country],
169+
doubles: [0],
170+
indexes: ['page'],
171+
});
172+
} catch {
173+
// Analytics must never break the request path
174+
}
175+
}
176+
149177
/**
150178
* Track a rate limit hit (429).
151179
* dataset: agora_llm

0 commit comments

Comments
 (0)