perf(insights,api): always answer instantly and regenerate in the background

The insights read path served a cold cache by generating inline — up to
ten seconds on first visit — and the nightly warm pass structurally
excluded evening visitors: their visit stamped the cache, the half-four
run skipped it as fresh, and it expired exactly at the next evening's
visit. Stale content now serves immediately with its timestamp while
one background job revalidates, the warm threshold drops to an hour,
forced warming fills both message locales, the analytics caches serve
stale-while-revalidate, the profile endpoint parallelises its reads,
the bug-report status drops a write per call, the umami proxy degrades
to a no-op script instead of an error, the insights route sheds its
heaviest static imports, and the medication page prefetches on intent.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 19:05:49 +02:00
1 parent ceeb818f41
commit 78fe82e348
19 files changed
+319 -101

No files matched your search

+11 -3
View File
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -55,7 +55,11 @@ export const GET = apiHandler(async (request?: Request) => {
// (userId, slice). The slim slice is the dashboard tile strip's hot
// path; multiple dashboard mounts inside a 60-second TTL all hit a
// warm cache.
const slim = await cached(
// v1.16.7 — SWR read: a sync-write marks the cell stale, and the
// tile strip's next mount should repaint from the prior summaries
// instantly while one background recompute refreshes them, instead
// of paying the 2-SQL-pass rebuild inline.
const slim = await cachedSwr(
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
`${user.id}|summaries`,
() => computeSummariesSlice(user.id),
@@ -74,7 +78,11 @@ export const GET = apiHandler(async (request?: Request) => {
// mount, and the Coach drawer all hit this endpoint within seconds
// of each other; the 60s TTL converts the 7.99s combined dashboard
// wait to a Map lookup on every subsequent caller.
const cachedBody = await cached(
// v1.16.7 — SWR read, same rationale as the slim slice above: the
// thick body is the 30-query chain feeding the dashboard + insights
// hero score; after a measurement sync the next mount serves the
// prior body instantly while one background recompute refreshes it.
const cachedBody = await cachedSwr(
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
+14 -14
View File
@@ -12,22 +12,22 @@ export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.4.22 W5 reconcile (Sr-H1) — fall-back resync for legacy
// sessions that predate the cookie. New sessions anchor the cookie
// inside `createSession` itself, so this is no longer the primary
// write path; it just makes sure pre-v1.4.22 sessions get their
// cookie set on the first /me roundtrip after the upgrade.
await setOnboardingPendingCookie(user.onboardingCompletedAt == null);
annotate({ action: { name: "auth.me" } });
// v1.15.0 — resolved cycle-tracking gate. Read the profile without
// forcing a row (a NULL toggle derives from gender); the resolver
// collapses both gates into the single boolean iOS hides the tab on.
const cycleProfile = await prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
});
// Two independent awaits, overlapped: the onboarding-cookie resync
// (v1.4.22 W5 Sr-H1 — fall-back for legacy sessions that predate the
// cookie; new sessions anchor it inside `createSession`) touches only
// the cookie store, while the cycle-profile read (v1.15.0 — resolved
// cycle-tracking gate; no row is forced, a NULL toggle derives from
// gender) is a Postgres round-trip. Running them sequentially added
// the cookie hop to every /me — and /me sits on every app boot path.
const [, cycleProfile] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
select: { cycleTrackingEnabled: true },
}),
]);
const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile);
// v1.7.0 — patient-identity fields for the health-record export. The
+7 -8
View File
@@ -8,9 +8,8 @@
* server-side (log annotation only) so we don't hand regular users a probe.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
@@ -47,12 +46,12 @@ async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
// client hammer Postgres in a loop.
const rl = await checkRateLimit(`bugreport-status:${user.id}`, 30, 60 * 1000);
if (!rl.allowed) {
return apiError("Rate limit exceeded", 429);
}
// No per-request rate limit: the payload rides the singleton server
// cache below, so a cache hit costs zero Postgres round-trips — while
// the former rate-limit bucket was an unconditional Postgres UPSERT
// per request, i.e. the limiter WAS the per-request DB load it claimed
// to prevent. The route stays authenticated; the only uncached work is
// one `appSettings` read per 60 s process-wide.
// Cache the global app-settings shape on a singleton key (per blueprint §3).
// `isAdmin` lives outside the cache because it varies per request. The
+13 -4
View File
@@ -25,7 +25,7 @@ import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import {
ensureUserMoodRollupsFresh,
@@ -52,9 +52,18 @@ export const GET = apiHandler(async () => {
// fans out to this endpoint alongside the Coach drawer + the
// recommendations grid; the 60s TTL converts every duplicate
// mount within the window to a Map lookup. Invalidation is handled
// by `invalidateUserMeasurements` which already evicts every key
// under the `${userId}|` prefix (see `lib/cache/invalidate.ts`).
const body = await cached(
// by `invalidateUserMeasurements`, which marks every key under the
// `${userId}|` prefix stale (see `lib/cache/invalidate.ts`).
//
// v1.16.7 — stale-while-revalidate. This is the single heaviest
// SQL-side aggregation on the /insights mount; serving the prior
// body instantly while ONE background recompute refreshes it keeps
// the page interactive across the bucket's 10-minute stale window
// (a measurement sync used to bust the entry into a blocking cold
// rebuild on the very next mount, all day). The truly-cold first
// read of the day still computes inline — there is no prior body to
// serve — but it no longer recurs per visit.
const body = await cachedSwr(
caches.analytics as ServerCache<Awaited<
ReturnType<typeof buildComprehensiveResponse>
>>,
@@ -443,7 +443,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
locale: "en",
} as never);
const res = await (GET as () => Promise<Response>)();
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { insights: unknown; cached: boolean };
@@ -464,7 +466,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
locale: "en",
} as never);
const res = await (GET as () => Promise<Response>)();
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
expect(res.status).toBe(200);
expect(enqueueForceWarm).toHaveBeenCalledWith({
userId: "u-1",
@@ -481,7 +485,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
locale: "en",
} as never);
const res = await (GET as () => Promise<Response>)();
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { insights: unknown; cached: boolean };
+13 -2
View File
@@ -257,7 +257,7 @@ async function buildComparisonSnapshotForUser(
*
* `userId` is narrowed from the session / Bearer — never a body field.
*/
export const GET = apiHandler(async () => {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// Same surface gate as the POST + the read-only status routes: a user
// with assessments enabled but Coach disabled still reads the cached
@@ -284,7 +284,18 @@ export const GET = apiHandler(async () => {
// empty / connect-AI state instead of a wasted enqueue).
let revalidating = false;
if (!isFresh && (await hasUsableStatusProvider(userId))) {
const locale = (dbUser?.locale ?? user.locale) === "en" ? "en" : "de";
// Resolve the locale the caller is actually reading (cookie /
// Accept-Language fall-back when `User.locale` is unset) and narrow
// non-German to English — the same convention the nightly's
// `normalizeLocale` and the status routes follow. The previous
// `(locale) === "en" ? "en" : "de"` collapsed a NULL `User.locale`
// to German even for a client reading the app in English, so the
// visit-triggered warm filled the wrong cache family.
const resolved = await resolveServerLocale({
request,
userLocale: dbUser?.locale ?? user.locale ?? null,
});
const locale = resolved === "de" ? "de" : "en";
void enqueueForceWarm({ userId, locale });
revalidating = true;
}
+27 -10
View File
@@ -26,16 +26,33 @@ export const GET = apiHandler(async () => {
});
}
const response = await safeFetch(
settings.umamiScriptUrl,
{
next: { revalidate: 3600 },
},
// Operator-configured host — pin the connect-time IP so a low-TTL
// DNS record cannot rebind the fetch at an internal range between
// the input-time `isPublicUrl` accept and the socket connect.
{ requirePublicHost: true },
);
// Fail-soft: an unreachable / slow / rebinding-refused upstream must
// degrade to the noop script, not 500 the proxy route — `safeFetch`
// THROWS on its 15 s timeout and on the connect-time public-host
// refusal, and an uncaught throw here surfaced as a 500 on every
// mount whenever the operator's Umami host was down or unresolvable.
let response: Response;
try {
response = await safeFetch(
settings.umamiScriptUrl,
{
next: { revalidate: 3600 },
},
// Operator-configured host — pin the connect-time IP so a low-TTL
// DNS record cannot rebind the fetch at an internal range between
// the input-time `isPublicUrl` accept and the socket connect.
{ requirePublicHost: true },
);
} catch {
annotate({ meta: { umami_script_fetch_failed: true } });
return new NextResponse(NOOP_SCRIPT, {
status: 200,
headers: {
"Content-Type": "application/javascript; charset=utf-8",
"Cache-Control": "public, max-age=60",
},
});
}
if (!response.ok) {
return new NextResponse(NOOP_SCRIPT, {
status: 200,