Examples
Product analytics
Recording events cheaply and querying them fast, without a second database.
Record
The endpoint must be fast and forgiving. A slow analytics call on the request path is how an analytics system becomes an outage.
import { createAPI } from "yatta/api";import { realtime } from "../func/realtime";import { withSession } from "../func/middleware"; const route = createAPI("/events"); route.use(withSession); route.post("/", async (ctx) => { const user = ctx.state.user as { id: string }; const body = await ctx.json(); // Accept and queue. Never await analytics inside a request handler. await jobs.enqueue("event:record", { userId: user.id, name: String(body.name).slice(0, 64), props: body.props ?? {}, sessionId: ctx.cookies().sid ?? null, }, { priority: "low" }); // Live counters via SSE, so an internal dashboard updates without polling. realtime.to("analytics:live").send("event.received", { name: body.name, at: Date.now(), }); return new Response(null, { status: 202 });});Raw and rolled up
Querying raw events gets slow at about a million rows. Roll daily aggregates into their own table and query those instead.
export const schema = { events: { id: col.uuid(), userId: col.text().nullable(), sessionId: col.text().nullable(), name: col.text(), props: col.json<Record<string, unknown>>().default({}), at: col.createdAt(), }, // One row per (day, name, prop). Pre-aggregated, so a query touches // thousands of rows rather than millions. eventRollups: { day: col.text(), // YYYY-MM-DD name: col.text(), prop: col.text(), // "" for no prop value: col.text(), count: col.integer(), },};Aggregate
cron.schedule("5 0 * * *", async () => { const yesterday = new Date(Date.now() - 86_400_000) .toISOString().slice(0, 10); // ON CONFLICT DO UPDATE makes this idempotent: re-running the rollup for a // day replaces it rather than double-counting. db.run( `INSERT INTO event_rollups (day, name, prop, value, count) SELECT ?, name, '', '', COUNT(*) FROM events WHERE substr(at, 1, 10) = ? GROUP BY name ON CONFLICT(day, name, prop, value) DO UPDATE SET count = count + excluded.count`, [yesterday, yesterday], ); // Same shape for the one prop that matters most, here: the plan. db.run( `INSERT INTO event_rollups (day, name, prop, value, count) SELECT ?, name, 'plan', COALESCE(json_extract(props, '$.plan'), 'unknown'), COUNT(*) FROM events WHERE substr(at, 1, 10) = ? AND name = 'checkout.completed' GROUP BY name, value ON CONFLICT(day, name, prop, value) DO UPDATE SET count = count + excluded.count`, [yesterday, yesterday], ); observer.log.info("Rolled up analytics", { day: yesterday });});Query
import { cache } from "../func/cache"; route.get("/summary", async (ctx) => { const days = Number(ctx.query().days ?? 30); const since = new Date(Date.now() - days * 86_400_000) .toISOString().slice(0, 10); return Response.json( await cache.remember( `analytics:summary:${days}`, async () => { const rows = db.run( `SELECT name, SUM(count) AS total FROM event_rollups WHERE day >= ? AND prop = '' GROUP BY name ORDER BY total DESC`, [since], "all", ); return { days, events: rows }; }, // Safe to cache for a minute: a dashboard does not need second-level // freshness, and this keeps a popular route off the database. { ttl: "60s", tags: ["analytics"] }, ), );});A funnel
const FUNNEL = ["signup", "onboarding", "first_project", "invite_teammate"]; route.get("/funnel", async (ctx) => { const days = Number(ctx.query().days ?? 30); const since = new Date(Date.now() - days * 86_400_000) .toISOString().slice(0, 10); const rows = db.run( `SELECT name, SUM(count) AS total FROM event_rollups WHERE day >= ? AND prop = '' AND name IN (${FUNNEL.map(() => "?").join(",")}) GROUP BY name`, [since, ...FUNNEL], "all", ); const counts = Object.fromEntries(rows.map((r) => [r.name, Number(r.total)])); // Each step is relative to the first, so the drop-off is readable directly. const top = counts[FUNNEL[0]] ?? 0; return Response.json({ steps: FUNNEL.map((name, i) => ({ name, count: counts[name] ?? 0, // Conversion from the previous step, which is what locates the leak. stepConversion: i === 0 ? 1 : (counts[name] ?? 0) / Math.max(counts[FUNNEL[i - 1]] ?? 0, 1), overallConversion: (counts[name] ?? 0) / Math.max(top, 1), })), });});Tip
Roll up with
ON CONFLICT DO UPDATE SET count = count + excluded.count so a re-run is additive and a partial re-run is safe. Deleting and re-inserting means a crash mid-rollup loses the day.