Fix admin analytics crash — cartesian join + undefended render

The /admin/analytics page calls /api/admin/engagement, whose per-family
query LEFT JOINed feeds × diapers_logs × sleeps × vaccinations × growth ×
chat_sessions before GROUP BY — a cartesian explosion that times out for
any family with real activity, 500ing the route. The page then ran
[...data.families] on the error object and white-screened.

- Pre-aggregate each log table to one row per child (CTEs) before joining,
  eliminating the row explosion; memories aggregated per family.
- Route error fallback now returns the full shape (safe empties) not {error}.
- Page normalizes the response into the full EngagementData shape so a bad
  response renders an empty state instead of crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-30 00:03:48 +05:30
parent e53c51f044
commit 94d9b234f8
2 changed files with 58 additions and 22 deletions

View file

@ -49,7 +49,19 @@ export default function AdminAnalytics() {
useEffect(() => {
fetch("/api/admin/engagement", { credentials: "include" })
.then(r => r.json())
.then(d => { setData(d); setLoading(false); })
.then(d => {
// Normalize into the full EngagementData shape so a malformed/error
// response can never crash the render (e.g. [...data.families]).
setData({
totalFamilies: Number(d?.totalFamilies) || 0,
featureAdoption: Array.isArray(d?.featureAdoption) ? d.featureAdoption : [],
families: Array.isArray(d?.families) ? d.families : [],
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0, ...(d?.activitySummary || {}) },
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0, ...(d?.aiUsage || {}) },
logsByDay: Array.isArray(d?.logsByDay) ? d.logsByDay : [],
});
setLoading(false);
})
.catch(() => setLoading(false));
}, []);

View file

@ -42,35 +42,49 @@ export async function GET(request: Request) {
{ name: "AI Chat", count: Number(adoption.families_ai) || 0, pct: Math.round((Number(adoption.families_ai) || 0) / total * 100) },
].sort((a, b) => b.pct - a.pct);
// B. Per-family engagement table
// B. Per-family engagement table.
//
// IMPORTANT: do NOT LEFT JOIN all the raw log tables directly — joining
// feeds × diapers_logs × sleeps × vaccinations × growth × chat_sessions in
// one query produces a cartesian product (millions of intermediate rows per
// active family) that times out / OOMs in production and 500s this route.
// Instead pre-aggregate each table to one row per child, then join those
// small aggregates. Memories are per-family, so aggregate by family_id.
const familyRows = await sql`
WITH feed_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM feeds GROUP BY child_id),
diaper_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(logged_at) AS last_at FROM diapers_logs GROUP BY child_id),
sleep_agg AS (SELECT child_id, COUNT(*)::int AS cnt, MAX(started_at) AS last_at FROM sleeps GROUP BY child_id),
vacc_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM vaccinations GROUP BY child_id),
growth_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM growth GROUP BY child_id),
chat_agg AS (SELECT child_id, COUNT(*)::int AS cnt FROM chat_sessions GROUP BY child_id),
mem_agg AS (SELECT family_id, COUNT(*)::int AS cnt, MAX(created_at) AS last_at FROM memories GROUP BY family_id)
SELECT
f.id,
f.name,
f.tier,
f.created_at,
GREATEST(
MAX(fd.logged_at),
MAX(dl.logged_at),
MAX(sl.started_at),
MAX(mem.created_at)
MAX(fa.last_at),
MAX(da.last_at),
MAX(sa.last_at),
MAX(ma.last_at)
) as last_activity,
COUNT(DISTINCT fd.id)::int as feed_count,
COUNT(DISTINCT dl.id)::int as diaper_count,
COUNT(DISTINCT sl.id)::int as sleep_count,
COUNT(DISTINCT v.id)::int as vaccination_count,
COUNT(DISTINCT g.id)::int as growth_count,
COUNT(DISTINCT mem.id)::int as memory_count,
COUNT(DISTINCT cs.id)::int as chat_count
COALESCE(SUM(fa.cnt), 0)::int as feed_count,
COALESCE(SUM(da.cnt), 0)::int as diaper_count,
COALESCE(SUM(sa.cnt), 0)::int as sleep_count,
COALESCE(SUM(va.cnt), 0)::int as vaccination_count,
COALESCE(SUM(ga.cnt), 0)::int as growth_count,
COALESCE(MAX(ma.cnt), 0)::int as memory_count,
COALESCE(SUM(ca.cnt), 0)::int as chat_count
FROM families f
LEFT JOIN children c ON c.family_id = f.id
LEFT JOIN feeds fd ON fd.child_id = c.id
LEFT JOIN diapers_logs dl ON dl.child_id = c.id
LEFT JOIN sleeps sl ON sl.child_id = c.id
LEFT JOIN vaccinations v ON v.child_id = c.id
LEFT JOIN growth g ON g.child_id = c.id
LEFT JOIN memories mem ON mem.family_id = f.id
LEFT JOIN chat_sessions cs ON cs.child_id = c.id
LEFT JOIN feed_agg fa ON fa.child_id = c.id
LEFT JOIN diaper_agg da ON da.child_id = c.id
LEFT JOIN sleep_agg sa ON sa.child_id = c.id
LEFT JOIN vacc_agg va ON va.child_id = c.id
LEFT JOIN growth_agg ga ON ga.child_id = c.id
LEFT JOIN chat_agg ca ON ca.child_id = c.id
LEFT JOIN mem_agg ma ON ma.family_id = f.id
GROUP BY f.id, f.name, f.tier, f.created_at
ORDER BY last_activity DESC NULLS LAST
`;
@ -157,6 +171,16 @@ export async function GET(request: Request) {
});
} catch (error) {
console.error("Admin engagement error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
// Return the full shape (with safe empties) instead of a bare {error} so the
// analytics page renders an empty state rather than crashing on data.families.
return NextResponse.json({
totalFamilies: 0,
featureAdoption: [],
families: [],
activitySummary: { active7d: 0, active30d: 0, neverActive: 0, total: 0 },
aiUsage: { totalCalls: 0, totalTokens: 0, totalCostPaise: 0, familiesUsingAI: 0 },
logsByDay: [],
error: String(error),
});
}
}