mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
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:
@@ -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’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’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’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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user