Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
735dcb8
first draft
fretchen Aug 9, 2026
1cc72b6
Cleaner CORS
fretchen Aug 10, 2026
4592f48
first notebooks
fretchen Aug 10, 2026
c1880be
cleaner CLAUDE and local testing
fretchen Aug 10, 2026
a162e25
make things prettier
fretchen Aug 10, 2026
65ce55c
Update hit.ts
fretchen Aug 10, 2026
3b928df
Update 01_smoke_test.ipynb
fretchen Aug 10, 2026
17e4e8f
clean it
fretchen Aug 10, 2026
969adcf
Update serverless.yml
fretchen Aug 10, 2026
f82a211
Update analytics-implementation-plan.md
fretchen Aug 10, 2026
d2289cb
Update 01_smoke_test.ipynb
fretchen Aug 10, 2026
60e882e
more cleaning
fretchen Aug 10, 2026
f161410
right analytics
fretchen Aug 10, 2026
a285bf0
Merge branch 'main' into replace_umami
fretchen Aug 10, 2026
065f3fd
Update analytics-implementation-plan.md
fretchen Aug 10, 2026
4213e8d
Merge branch 'main' into replace_umami
fretchen Aug 10, 2026
2e46fe9
Clean Umami
fretchen Aug 10, 2026
02b0c92
first import worked
fretchen Aug 10, 2026
105f568
first try
fretchen Aug 11, 2026
fd9a4b3
further improvement of the dashboard
fretchen Aug 11, 2026
6b941c3
KISSier
fretchen Aug 11, 2026
15e27eb
Simplify the deployed functions
fretchen Aug 11, 2026
4e2bb06
finishing up
fretchen Aug 11, 2026
56139bb
Clean the layout
fretchen Aug 11, 2026
403e436
Update growth_api.test.ts
fretchen Aug 11, 2026
9962483
Small cleaning
fretchen Aug 11, 2026
15b7bd5
Merge branch 'main' into replace_umami
fretchen Aug 11, 2026
e194809
Merge branch 'main' into replace_umami
fretchen Aug 15, 2026
ac2af83
Cleaner Umami compare
fretchen Aug 15, 2026
5f2db55
Some analytics
fretchen Aug 15, 2026
c4d1b3a
Clean out the crawlers
fretchen Aug 15, 2026
7b10daf
Better following
fretchen Aug 15, 2026
c488e91
Update hitTracker.ts
fretchen Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion analytics/buckets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,20 @@ export const HOURLY_FALLBACK_DAYS = 14;

export interface HourBucket {
hits: number;
/** Fresh page loads only (onHydrationEnd), not in-app navigations — see hit.ts. */
landings: number;
pages: Record<string, number>;
}

export interface DayBucket {
hits: number;
/**
* Absent on any day written before this field existed — every "umami" day
* and any older "beacon" day. Not retrofittable (see
* analytics/notebooks/umami_backfill.py's scoping note); treat a missing
* value as unknown, not zero.
*/
landings?: number;
pages: Record<string, number>;
/** `"beacon"` for anything this service counted; `"umami"` for backfilled history. */
source: string;
Expand Down Expand Up @@ -121,6 +130,7 @@ export async function readDayFromHourly(store: HitStorage, site: string, day: st
const results = await Promise.all(hourKeys(site, day).map((key) => store.getWithMeta(key)));

let hits = 0;
let landings = 0;
let found = false;
const pages: Record<string, number> = {};

Expand All @@ -131,10 +141,11 @@ export async function readDayFromHourly(store: HitStorage, site: string, day: st
found = true;
const bucket = JSON.parse(result.body) as HourBucket;
hits += bucket.hits ?? 0;
landings += bucket.landings ?? 0; // 0 for hours written before this field existed
mergePages(pages, bucket.pages ?? {});
}

return found ? { hits, pages: topPages(pages), source: "beacon" } : null;
return found ? { hits, landings, pages: topPages(pages), source: "beacon" } : null;
}

async function readRollup(store: HitStorage, site: string, month: string): Promise<MonthRollup | null> {
Expand Down
63 changes: 60 additions & 3 deletions analytics/hit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,37 @@ const MAX_PATH_LENGTH = 200;
const MAX_PAGES_PER_BUCKET = 200; // caps distinct paths tracked per hour bucket
const MAX_CAS_ATTEMPTS = 3;

/**
* Self-identifying crawlers only — catches honest bots, not the chronic
* evasive crawler found in analytics/notebooks/05_traffic_bursts.ipynb (that
* one never announces itself; see the notebook's "Tier 2" note on why an IP/CIDR
* approach was parked instead of built speculatively). Nothing is stored: the
* UA is inspected per-request to decide whether to write, then discarded —
* same privacy posture as everything else here.
*/
const BOT_USER_AGENTS = [
"googlebot",
"bingbot",
"ahrefsbot",
"semrushbot",
"mj12bot",
"gptbot",
"ccbot",
"claudebot",
"perplexitybot",
"yandexbot",
"petalbot",
"bytespider",
];

function isKnownBot(userAgent: string | undefined): boolean {
if (!userAgent) {
return false;
}
const lower = userAgent.toLowerCase();
return BOT_USER_AGENTS.some((bot) => lower.includes(bot));
}

// `vike dev` serves on 3000; 5173 covers a plain `vite dev` fallback.
const ALLOWED_ORIGINS = ["https://www.fretchen.eu", "http://localhost:3000", "http://localhost:5173"];

Expand All @@ -45,6 +76,8 @@ function getCorsHeaders(origin?: string): Record<string, string> {

interface HourBucket {
hits: number;
/** Fresh page loads only (onHydrationEnd), not in-app navigations — see hitTracker.ts. */
landings: number;
pages: Record<string, number>;
}

Expand All @@ -71,14 +104,25 @@ function hourKey(site: string, now: Date = new Date()): string {
* read on a CAS conflict; after MAX_CAS_ATTEMPTS gives up silently — a lost
* count is fine, never worth failing the request over.
*/
async function incrementHit(store: HitStorage, site: string, path: string): Promise<void> {
async function incrementHit(store: HitStorage, site: string, path: string, landing: boolean): Promise<void> {
const key = hourKey(site);

for (let attempt = 1; attempt <= MAX_CAS_ATTEMPTS; attempt++) {
const existing = await store.getWithMeta(key);
const bucket: HourBucket = existing ? (JSON.parse(existing.body) as HourBucket) : { hits: 0, pages: {} };
// Stored data can predate `landings` even though the type says it's
// always there — read it as partial and default explicitly, rather than
// asserting the parsed JSON matches HourBucket outright.
const parsed = existing ? (JSON.parse(existing.body) as Partial<HourBucket>) : null;
const bucket: HourBucket = {
hits: parsed?.hits ?? 0,
landings: parsed?.landings ?? 0,
pages: parsed?.pages ?? {},
};

bucket.hits += 1;
if (landing) {
bucket.landings += 1;
}
if (bucket.pages[path] !== undefined || Object.keys(bucket.pages).length < MAX_PAGES_PER_BUCKET) {
bucket.pages[path] = (bucket.pages[path] ?? 0) + 1;
}
Expand Down Expand Up @@ -109,6 +153,14 @@ export async function handleHit(event: ScalewayEvent, _context: unknown): Promis
};
}

if (isKnownBot(event.headers?.["user-agent"] ?? event.headers?.["User-Agent"])) {
return {
statusCode: 400,
headers: corsHeaders,
body: JSON.stringify({ error: "Not tracked" }),
};
}

if (!event.body) {
return {
statusCode: 400,
Expand Down Expand Up @@ -137,7 +189,12 @@ export async function handleHit(event: ScalewayEvent, _context: unknown): Promis
};
}

await incrementHit(defaultStorage, ALLOWED_SITE, path);
// Defaults to false rather than rejecting the request: an old cached client
// bundle without this field should keep counting hits, just without the
// landing/navigation split.
const landing = parsed.landing === true;

await incrementHit(defaultStorage, ALLOWED_SITE, path, landing);

return { statusCode: 204, headers: corsHeaders, body: "" };
}
Loading
Loading