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:
parent
e53c51f044
commit
94d9b234f8
2 changed files with 58 additions and 22 deletions
|
|
@ -49,7 +49,19 @@ export default function AdminAnalytics() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/admin/engagement", { credentials: "include" })
|
fetch("/api/admin/engagement", { credentials: "include" })
|
||||||
.then(r => r.json())
|
.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));
|
.catch(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
{ 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);
|
].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`
|
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
|
SELECT
|
||||||
f.id,
|
f.id,
|
||||||
f.name,
|
f.name,
|
||||||
f.tier,
|
f.tier,
|
||||||
f.created_at,
|
f.created_at,
|
||||||
GREATEST(
|
GREATEST(
|
||||||
MAX(fd.logged_at),
|
MAX(fa.last_at),
|
||||||
MAX(dl.logged_at),
|
MAX(da.last_at),
|
||||||
MAX(sl.started_at),
|
MAX(sa.last_at),
|
||||||
MAX(mem.created_at)
|
MAX(ma.last_at)
|
||||||
) as last_activity,
|
) as last_activity,
|
||||||
COUNT(DISTINCT fd.id)::int as feed_count,
|
COALESCE(SUM(fa.cnt), 0)::int as feed_count,
|
||||||
COUNT(DISTINCT dl.id)::int as diaper_count,
|
COALESCE(SUM(da.cnt), 0)::int as diaper_count,
|
||||||
COUNT(DISTINCT sl.id)::int as sleep_count,
|
COALESCE(SUM(sa.cnt), 0)::int as sleep_count,
|
||||||
COUNT(DISTINCT v.id)::int as vaccination_count,
|
COALESCE(SUM(va.cnt), 0)::int as vaccination_count,
|
||||||
COUNT(DISTINCT g.id)::int as growth_count,
|
COALESCE(SUM(ga.cnt), 0)::int as growth_count,
|
||||||
COUNT(DISTINCT mem.id)::int as memory_count,
|
COALESCE(MAX(ma.cnt), 0)::int as memory_count,
|
||||||
COUNT(DISTINCT cs.id)::int as chat_count
|
COALESCE(SUM(ca.cnt), 0)::int as chat_count
|
||||||
FROM families f
|
FROM families f
|
||||||
LEFT JOIN children c ON c.family_id = f.id
|
LEFT JOIN children c ON c.family_id = f.id
|
||||||
LEFT JOIN feeds fd ON fd.child_id = c.id
|
LEFT JOIN feed_agg fa ON fa.child_id = c.id
|
||||||
LEFT JOIN diapers_logs dl ON dl.child_id = c.id
|
LEFT JOIN diaper_agg da ON da.child_id = c.id
|
||||||
LEFT JOIN sleeps sl ON sl.child_id = c.id
|
LEFT JOIN sleep_agg sa ON sa.child_id = c.id
|
||||||
LEFT JOIN vaccinations v ON v.child_id = c.id
|
LEFT JOIN vacc_agg va ON va.child_id = c.id
|
||||||
LEFT JOIN growth g ON g.child_id = c.id
|
LEFT JOIN growth_agg ga ON ga.child_id = c.id
|
||||||
LEFT JOIN memories mem ON mem.family_id = f.id
|
LEFT JOIN chat_agg ca ON ca.child_id = c.id
|
||||||
LEFT JOIN chat_sessions cs ON cs.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
|
GROUP BY f.id, f.name, f.tier, f.created_at
|
||||||
ORDER BY last_activity DESC NULLS LAST
|
ORDER BY last_activity DESC NULLS LAST
|
||||||
`;
|
`;
|
||||||
|
|
@ -157,6 +171,16 @@ export async function GET(request: Request) {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Admin engagement error:", 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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue