From aa7c2bfe29ac9682d621e65d4b5e5de2e9f5ccb3 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Tue, 26 May 2026 12:27:26 -0400 Subject: [PATCH] fix: upgrade stats cache from fixed TTL to stale-while-revalidate via `waitUntil` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the simple 1-hour hard TTL with a soft/hard split: cache hits younger than 1h are returned immediately with no work; hits between 1h and 24h are returned stale while a background `refreshAndCache` call (via `waitUntil`) updates the cache for the next visitor; only a true cold miss (first request per region or age ≥ 24h) blocks on the PostHog round-trip - Extract `refreshAndCache` helper so the "fetch → conditionally write" logic is shared between the background and cold-miss paths; failed fetches still never pollute the cache - Move the "Last refreshed" timestamp from the bottom of the telemetry section to directly under the page header so it's visible without scrolling - Inline `HeroStat` and `TelemetrySection` subcomponents into `StatsPage`; they were single-use and the indirection added no value --- apps/web/src/routes/stats.tsx | 165 ++++++++++++------------- apps/web/src/server/stats.functions.ts | 51 ++++++-- 2 files changed, 122 insertions(+), 94 deletions(-) diff --git a/apps/web/src/routes/stats.tsx b/apps/web/src/routes/stats.tsx index 49bd57f..664ac45 100644 --- a/apps/web/src/routes/stats.tsx +++ b/apps/web/src/routes/stats.tsx @@ -62,17 +62,42 @@ function StatsPage() {

Stats

- What modules people actually pick. Aggregated from anonymous CLI telemetry —{" "} + What modules people actually pick, aggregated from anonymous CLI telemetry —{" "} see exactly what’s collected .

+

+ Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC +

- - + + + + Projects scaffolded + + + +

+ {formatCount(stats.projectsScaffolded)} +

+
+
+ + + + Modules installed + + + +

+ {formatCount(stats.modulesInstalled)} +

+
+
@@ -139,86 +164,60 @@ function StatsPage() {
- +
+

Telemetry policy

+
+ + + + What we save + + + +
    +
  • + Which command you ran (init, add, remove, list, search), how long it took, and + whether it succeeded. +
  • +
  • CLI version, Node version, OS, and architecture.
  • +
  • For installs/removes: the module id and its category.
  • +
  • An ephemeral UUID generated per process to deduplicate events.
  • +
+
+
+ + + + What we don’t + + + +
    +
  • File paths, project names, environment variables, or template contents.
  • +
  • Your IP address (PostHog ingest is server-proxied through this site).
  • +
  • Any persistent identifier — the process UUID is discarded on exit.
  • +
  • + Anything from CI runs (telemetry auto-skips when CI is set). +
  • +
+
+
+
+

+ Opt out per-invocation with{" "} + --no-telemetry, persistently with STANZA_TELEMETRY=0 or{" "} + DO_NOT_TRACK=1. More in the{" "} + + CLI docs + + . +

+
); } - -function HeroStat({ label, value }: { label: string; value: number }) { - return ( - - - - {label} - - - -

- {formatCount(value)} -

-
-
- ); -} - -function TelemetrySection({ generatedAt }: { generatedAt: string }) { - return ( -
-

Telemetry policy

-
- - - - What we save - - - -
    -
  • - Which command you ran (init, add, remove, list, search), how long it took, and - whether it succeeded. -
  • -
  • CLI version, Node version, OS, and architecture.
  • -
  • For installs/removes: the module id and its category.
  • -
  • An ephemeral UUID generated per process to deduplicate events.
  • -
-
-
- - - - What we don’t - - - -
    -
  • File paths, project names, environment variables, or template contents.
  • -
  • Your IP address (PostHog ingest is server-proxied through this site).
  • -
  • Any persistent identifier — the process UUID is discarded on exit.
  • -
  • - Anything from CI runs (telemetry auto-skips when CI is set). -
  • -
-
-
-
-

- Opt out per-invocation with{" "} - --no-telemetry, persistently with STANZA_TELEMETRY=0 or{" "} - DO_NOT_TRACK=1. More in the{" "} - - CLI docs - - . -

-

- Last refreshed {formatGeneratedAt(generatedAt)} UTC. -

-
- ); -} diff --git a/apps/web/src/server/stats.functions.ts b/apps/web/src/server/stats.functions.ts index b870129..dace46c 100644 --- a/apps/web/src/server/stats.functions.ts +++ b/apps/web/src/server/stats.functions.ts @@ -1,7 +1,7 @@ import type { CategoryId } from "@stanza/registry"; import { KNOWN_CATEGORIES } from "@stanza/registry"; import { createServerFn } from "@tanstack/react-start"; -import { getCache } from "@vercel/functions"; +import { getCache, waitUntil } from "@vercel/functions"; import { getQueryConfig, runQuery } from "@/server/posthog-query.server"; @@ -18,7 +18,8 @@ export type Stats = { }; const CACHE_KEY = "stats:v1"; -const CACHE_TTL = 3600; // 1 hour +const SOFT_TTL_MS = 60 * 60 * 1000; // 1 hour +const HARD_TTL_SEC = 24 * 60 * 60; // 24 hours /** * Empty shell returned when no PostHog read key is configured (local dev, @@ -171,22 +172,50 @@ function isKnownCategory(value: string): value is CategoryId { return KNOWN_CATEGORIES.some((id) => id === value); } +/** + * Refetch from PostHog and write to the cache on success. Only successful + * fetches are cached; a failed query returns the zero-shape without polluting + * the cache so a transient PostHog outage doesn't pin em-dashes for an hour. + */ +async function refreshAndCache(cache: ReturnType): Promise { + const { stats, ok } = await fetchStats(); + if (ok) { + await cache.set(CACHE_KEY, stats, { ttl: HARD_TTL_SEC, tags: ["stats"] }).catch(() => {}); + } + return stats; +} + /** * Server function powering `/stats`. Wraps the PostHog HogQL queries in - * Vercel's Runtime Cache (per-region, 1h TTL, tag `stats`). First request per - * region per hour pays the query round-trip; everything else is instant. + * Vercel's Runtime Cache (per-region) with stale-while-revalidate semantics: + * + * - Cache hit, age < 1h → return immediately, no work + * - Cache hit, 1h ≤ age < 24h → return STALE immediately, refresh in + * background via `waitUntil` so the next + * request sees fresh data + * - Cache miss or age ≥ 24h → block on a fresh fetch (the only path + * that pays the ~1-3s PostHog latency) + * + * Once warm, every visitor sees ~10ms reads — including the unlucky one whose + * request happens to land just after the soft TTL expires. */ export const getStats = createServerFn({ method: "GET" }).handler(async (): Promise => { const cache = getCache(); // oxlint-disable-next-line typescript/no-unsafe-type-assertion const cached = (await cache.get(CACHE_KEY).catch(() => null)) as Stats | null; - if (cached) return cached; - const { stats, ok } = await fetchStats(); - // Only cache successful fetches — caching a zero-shape from a failed query - // would pin em-dashes for an hour even after PostHog recovers. - if (ok) { - await cache.set(CACHE_KEY, stats, { ttl: CACHE_TTL, tags: ["stats"] }).catch(() => {}); + if (cached) { + const age = Date.now() - new Date(cached.generatedAt).getTime(); + if (age < SOFT_TTL_MS) { + // Fresh — nothing to do. + return cached; + } + // Stale-but-usable: serve it, refresh in the background so we don't + // block the response on the PostHog round-trip. + waitUntil(refreshAndCache(cache).catch(() => {})); + return cached; } - return stats; + + // Truly cold: block and fetch. Only happens once per region per HARD_TTL. + return refreshAndCache(cache); });