fix: upgrade stats cache from fixed TTL to stale-while-revalidate via waitUntil

- 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
This commit is contained in:
2026-05-26 12:27:26 -04:00
parent c1df786210
commit aa7c2bfe29
2 changed files with 122 additions and 94 deletions
+82 -83
View File
@@ -62,17 +62,42 @@ function StatsPage() {
<header className="mb-10 max-w-2xl">
<h1 className="text-3xl font-semibold tracking-tight">Stats</h1>
<p className="mt-2 text-pretty text-muted-foreground">
What modules people actually pick. Aggregated from anonymous CLI telemetry {" "}
What modules people actually pick, aggregated from anonymous CLI telemetry {" "}
<a href="#telemetry" className="text-primary underline underline-offset-4">
see exactly what&rsquo;s collected
</a>
.
</p>
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
</p>
</header>
<section className="mb-8 grid gap-4 sm:grid-cols-2">
<HeroStat label="Projects scaffolded" value={stats.projectsScaffolded} />
<HeroStat label="Modules installed" value={stats.modulesInstalled} />
<Card>
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
Projects scaffolded
</CardTitle>
</CardHeader>
<CardContent>
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
{formatCount(stats.projectsScaffolded)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
Modules installed
</CardTitle>
</CardHeader>
<CardContent>
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
{formatCount(stats.modulesInstalled)}
</p>
</CardContent>
</Card>
</section>
<section className="mb-8">
@@ -139,86 +164,60 @@ function StatsPage() {
</div>
</section>
<TelemetrySection generatedAt={stats.generatedAt} />
<section id="telemetry" className="scroll-mt-20">
<h2 className="mb-4 text-lg font-semibold tracking-tight">Telemetry policy</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
What we save
</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc space-y-1 pl-4 text-muted-foreground">
<li>
Which command you ran (init, add, remove, list, search), how long it took, and
whether it succeeded.
</li>
<li>CLI version, Node version, OS, and architecture.</li>
<li>For installs/removes: the module id and its category.</li>
<li>An ephemeral UUID generated per process to deduplicate events.</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
What we don&rsquo;t
</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc space-y-1 pl-4 text-muted-foreground">
<li>File paths, project names, environment variables, or template contents.</li>
<li>Your IP address (PostHog ingest is server-proxied through this site).</li>
<li>Any persistent identifier the process UUID is discarded on exit.</li>
<li>
Anything from CI runs (telemetry auto-skips when <code>CI</code> is set).
</li>
</ul>
</CardContent>
</Card>
</div>
<p className="mt-4 text-xs leading-relaxed text-muted-foreground">
<strong className="font-medium text-foreground">Opt out</strong> per-invocation with{" "}
<code>--no-telemetry</code>, persistently with <code>STANZA_TELEMETRY=0</code> or{" "}
<code>DO_NOT_TRACK=1</code>. More in the{" "}
<Link
to="/docs/$"
params={{ _splat: "cli" }}
hash="telemetry"
className="text-primary underline underline-offset-4"
>
CLI docs
</Link>
.
</p>
</section>
</div>
);
}
function HeroStat({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
{label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="font-mono text-4xl font-medium tracking-tight tabular-nums">
{formatCount(value)}
</p>
</CardContent>
</Card>
);
}
function TelemetrySection({ generatedAt }: { generatedAt: string }) {
return (
<section id="telemetry" className="scroll-mt-20">
<h2 className="mb-4 text-lg font-semibold tracking-tight">Telemetry policy</h2>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
What we save
</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc space-y-1 pl-4 text-muted-foreground">
<li>
Which command you ran (init, add, remove, list, search), how long it took, and
whether it succeeded.
</li>
<li>CLI version, Node version, OS, and architecture.</li>
<li>For installs/removes: the module id and its category.</li>
<li>An ephemeral UUID generated per process to deduplicate events.</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xs font-medium tracking-wider text-muted-foreground uppercase">
What we don&rsquo;t
</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc space-y-1 pl-4 text-muted-foreground">
<li>File paths, project names, environment variables, or template contents.</li>
<li>Your IP address (PostHog ingest is server-proxied through this site).</li>
<li>Any persistent identifier the process UUID is discarded on exit.</li>
<li>
Anything from CI runs (telemetry auto-skips when <code>CI</code> is set).
</li>
</ul>
</CardContent>
</Card>
</div>
<p className="mt-4 text-xs text-muted-foreground">
<strong className="font-medium text-foreground">Opt out</strong> per-invocation with{" "}
<code>--no-telemetry</code>, persistently with <code>STANZA_TELEMETRY=0</code> or{" "}
<code>DO_NOT_TRACK=1</code>. More in the{" "}
<Link
to="/docs/$"
params={{ _splat: "cli" }}
hash="telemetry"
className="text-primary underline underline-offset-4"
>
CLI docs
</Link>
.
</p>
<p className="mt-2 text-xs text-muted-foreground">
Last refreshed {formatGeneratedAt(generatedAt)} UTC.
</p>
</section>
);
}
+40 -11
View File
@@ -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<typeof getCache>): Promise<Stats> {
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<Stats> => {
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);
});