Files
OpenFrame/lib/analytics/scoreboard.ts
T
yusufipk 7ca5abd041 feat(analytics): record where paying customers actually came from
Adds first-party acquisition attribution and a sixteen-event funnel, written to
this deployment's own database and read back on /admin/growth. Nothing is sent
anywhere else, and the whole subsystem is off unless OPENFRAME_ENABLE_ANALYTICS
is set, so a self-hosted instance carries the tables empty and pays nothing.

The proxy gives a visitor an anonymous id and stores what brought them in two
first-party cookies; signup copies that onto the account and claims the events
the visitor produced before they had one, which is what joins the two halves of
the funnel. Recording happens where each step actually happens rather than in
the browser: an ad blocker cannot undercount landing views, and blocking rates
differ by channel, so an undercounted denominator would have made GitHub traffic
look like it converts better than it does.

Every event carries a dedupe key on a UNIQUE column, so "recorded exactly once"
is a property of the schema rather than of fifteen call sites. Subscription
events are derived by comparing the row being overwritten with the row being
written inside the existing Stripe sync, which makes them order-independent and
replay-safe.

The scoreboard reports step-to-step conversion with the denominator beside it,
and splits by source over a rolling 28-day window rather than a week: at this
volume a weekly per-source cell holds single digits, and a percentage computed
from three visits reads exactly as confidently as one computed from three
hundred.

"How did you hear about us?" is asked on the first onboarding screen, not on the
registration form. The number being measured is the signup conversion rate, and
a question added to that form would move it.
2026-08-01 20:00:27 +03:00

324 lines
10 KiB
TypeScript

// The Monday scoreboard, as queries.
//
// Two decisions here are worth stating, because they are what make the numbers
// readable rather than merely present:
//
// 1. Rates, not just counts. A funnel is a set of ratios; the step with the
// worst ratio is the thing to fix, and a column of absolute numbers hides it.
// 2. Every rate carries its denominator. At this volume a weekly per-channel
// cell holds single digits, and 1 out of 3 renders as "33%" exactly as
// confidently as 340 out of 1020. The channel view therefore runs on a
// rolling 28-day window rather than a week, and still reports `n`.
import type { AcquisitionChannel } from '@prisma/client';
import { db } from '@/lib/db';
import { getCachedStripeStats } from '@/lib/admin-stats';
/** What "using the product" means for a paying account. */
export const VALUE_EVENT_NAMES = [
'VIDEO_ADDED',
'SHARE_LINK_CREATED',
'FIRST_GUEST_COMMENT',
'APPROVAL_COMPLETED',
'PROJECT_CREATED',
] as const;
/** A paid account that has produced nothing for this long is drifting away. */
export const AT_RISK_SILENT_DAYS = 14;
const DEFAULT_WEEKS = 12;
const CHANNEL_WINDOW_DAYS = 28;
export interface WeeklyRow {
weekStart: Date;
visitors: number;
ctaClicks: number;
signupStarted: number;
signups: number;
emailVerified: number;
firstVideo: number;
shareLinks: number;
externalFeedback: number;
trials: number;
newPaid: number;
canceled: number;
/** Running net of started minus canceled. Derived, not a Stripe snapshot. */
activePaid: number;
mrrCents: number;
}
export interface ChannelRow {
channel: AcquisitionChannel;
visitors: number;
signups: number;
trials: number;
paid: number;
}
export interface PaidAccountRow {
userId: string;
name: string | null;
email: string | null;
status: string;
valueEvents7: number;
valueEvents30: number;
lastValueEventAt: Date | null;
channel: AcquisitionChannel | null;
selfReported: AcquisitionChannel | null;
}
export interface Scoreboard {
weeks: WeeklyRow[];
channels: ChannelRow[];
channelWindowDays: number;
paidAccounts: PaidAccountRow[];
atRisk: PaidAccountRow[];
currentActivePaid: number | null;
currentMrrCents: number | null;
currency: string;
}
interface WeeklyQueryRow {
week: Date;
name: string;
subjects: number;
}
interface ChannelQueryRow {
channel: AcquisitionChannel | null;
name: string;
subjects: number;
}
interface PaidQueryRow {
user_id: string;
name: string | null;
email: string | null;
status: string;
channel: AcquisitionChannel | null;
self_reported: AcquisitionChannel | null;
value_events_7: number;
value_events_30: number;
last_value_event_at: Date | null;
}
function startOfWeek(date: Date): Date {
const copy = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)
);
// Postgres date_trunc('week') starts on Monday; match it so the two halves of
// the table line up.
const isoDayIndex = (copy.getUTCDay() + 6) % 7;
copy.setUTCDate(copy.getUTCDate() - isoDayIndex);
return copy;
}
function emptyWeek(weekStart: Date): WeeklyRow {
return {
weekStart,
visitors: 0,
ctaClicks: 0,
signupStarted: 0,
signups: 0,
emailVerified: 0,
firstVideo: 0,
shareLinks: 0,
externalFeedback: 0,
trials: 0,
newPaid: 0,
canceled: 0,
activePaid: 0,
mrrCents: 0,
};
}
const WEEK_COLUMN_BY_EVENT: Record<string, keyof WeeklyRow> = {
LANDING_VIEW: 'visitors',
CTA_CLICKED: 'ctaClicks',
SIGNUP_STARTED: 'signupStarted',
SIGNUP_COMPLETED: 'signups',
EMAIL_VERIFIED: 'emailVerified',
VIDEO_ADDED: 'firstVideo',
SHARE_LINK_CREATED: 'shareLinks',
FIRST_GUEST_COMMENT: 'externalFeedback',
TRIAL_STARTED: 'trials',
SUBSCRIPTION_STARTED: 'newPaid',
SUBSCRIPTION_CANCELED: 'canceled',
};
export interface FunnelRates {
visitorToSignup: number | null;
signupToFirstVideo: number | null;
firstVideoToShare: number | null;
shareToFeedback: number | null;
trialToPaid: number | null;
}
/**
* Step-to-step conversion, or null when the denominator is zero.
*
* Null rather than 0 on purpose: "no visitors, so no rate" and "visitors, none
* of whom converted" are different facts, and showing the first as 0% invents a
* problem that is not there.
*/
export function conversionRates(row: {
visitors: number;
signups: number;
firstVideo: number;
shareLinks: number;
externalFeedback: number;
trials: number;
newPaid: number;
}): FunnelRates {
const ratio = (numerator: number, denominator: number) =>
denominator > 0 ? numerator / denominator : null;
return {
visitorToSignup: ratio(row.signups, row.visitors),
signupToFirstVideo: ratio(row.firstVideo, row.signups),
firstVideoToShare: ratio(row.shareLinks, row.firstVideo),
shareToFeedback: ratio(row.externalFeedback, row.shareLinks),
trialToPaid: ratio(row.newPaid, row.trials),
};
}
export async function getScoreboard(options?: { weeks?: number }): Promise<Scoreboard> {
const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52);
const now = new Date();
const firstWeekStart = startOfWeek(now);
firstWeekStart.setUTCDate(firstWeekStart.getUTCDate() - (weeks - 1) * 7);
const channelWindowStart = new Date(now);
channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS);
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats] = await Promise.all([
// COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing
// view is deduped per visitor per day, so a visitor who came back on three
// days would otherwise be three weekly visitors. Rows with no anonymous id
// fall back to their own primary key and stay distinct.
db.$queryRaw<WeeklyQueryRow[]>`
SELECT date_trunc('week', occurred_at) AS week,
name::text AS name,
COUNT(DISTINCT COALESCE(anonymous_id, id))::int AS subjects
FROM analytics_events
WHERE occurred_at >= ${firstWeekStart}
GROUP BY 1, 2
`,
db.$queryRaw<ChannelQueryRow[]>`
SELECT COALESCE(ua.channel, e.channel) AS channel,
e.name::text AS name,
COUNT(DISTINCT COALESCE(e.anonymous_id, e.id))::int AS subjects
FROM analytics_events e
LEFT JOIN user_acquisitions ua ON ua.user_id = e.user_id
WHERE e.occurred_at >= ${channelWindowStart}
GROUP BY 1, 2
`,
db.$queryRaw<Array<{ started: number; canceled: number }>>`
SELECT
COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_STARTED')::int AS started,
COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_CANCELED')::int AS canceled
FROM analytics_events
WHERE occurred_at < ${firstWeekStart}
`,
db.$queryRaw<PaidQueryRow[]>`
SELECT u.id AS user_id,
u.name,
u.email,
u."subscriptionStatus"::text AS status,
ua.channel,
ua.self_reported,
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '7 days')::int
AS value_events_7,
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '30 days')::int
AS value_events_30,
MAX(e.occurred_at) AS last_value_event_at
FROM users u
LEFT JOIN user_acquisitions ua ON ua.user_id = u.id
LEFT JOIN analytics_events e
ON e.user_id = u.id
AND e.name::text = ANY(${[...VALUE_EVENT_NAMES]}::text[])
WHERE u."subscriptionStatus"::text IN ('ACTIVE', 'TRIALING')
GROUP BY u.id, u.name, u.email, u."subscriptionStatus", ua.channel, ua.self_reported
ORDER BY MAX(e.occurred_at) ASC NULLS FIRST
`,
getCachedStripeStats(),
]);
const byWeek = new Map<number, WeeklyRow>();
for (let index = 0; index < weeks; index += 1) {
const weekStart = new Date(firstWeekStart);
weekStart.setUTCDate(weekStart.getUTCDate() + index * 7);
byWeek.set(weekStart.getTime(), emptyWeek(weekStart));
}
for (const row of weekRows) {
const bucket = byWeek.get(startOfWeek(row.week).getTime());
const column = WEEK_COLUMN_BY_EVENT[row.name];
if (!bucket || !column) continue;
(bucket[column] as number) = row.subjects;
}
// One flat plan, so a per-subscription price is enough to turn a subscriber
// count into MRR. Taken from Stripe rather than hardcoded, and zero when
// billing is not configured at all.
const unitAmountCents =
stripeStats && stripeStats.activeSubscribers > 0
? Math.round(stripeStats.mrrCents / stripeStats.activeSubscribers)
: 0;
let running = (priorPaid[0]?.started ?? 0) - (priorPaid[0]?.canceled ?? 0);
const orderedWeeks = [...byWeek.values()].sort(
(a, b) => a.weekStart.getTime() - b.weekStart.getTime()
);
for (const week of orderedWeeks) {
running += week.newPaid - week.canceled;
week.activePaid = Math.max(running, 0);
week.mrrCents = week.activePaid * unitAmountCents;
}
const channelBuckets = new Map<AcquisitionChannel, ChannelRow>();
for (const row of channelRows) {
const channel = row.channel ?? 'OTHER';
const bucket = channelBuckets.get(channel) ?? {
channel,
visitors: 0,
signups: 0,
trials: 0,
paid: 0,
};
if (row.name === 'LANDING_VIEW') bucket.visitors += row.subjects;
if (row.name === 'SIGNUP_COMPLETED') bucket.signups += row.subjects;
if (row.name === 'TRIAL_STARTED') bucket.trials += row.subjects;
if (row.name === 'SUBSCRIPTION_STARTED') bucket.paid += row.subjects;
channelBuckets.set(channel, bucket);
}
const accounts: PaidAccountRow[] = paidAccounts.map((row) => ({
userId: row.user_id,
name: row.name,
email: row.email,
status: row.status,
channel: row.channel,
selfReported: row.self_reported,
valueEvents7: row.value_events_7,
valueEvents30: row.value_events_30,
lastValueEventAt: row.last_value_event_at,
}));
const silentBefore = new Date(now);
silentBefore.setUTCDate(silentBefore.getUTCDate() - AT_RISK_SILENT_DAYS);
return {
weeks: orderedWeeks,
channels: [...channelBuckets.values()].sort((a, b) => b.visitors - a.visitors),
channelWindowDays: CHANNEL_WINDOW_DAYS,
paidAccounts: accounts,
atRisk: accounts.filter(
(account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore
),
currentActivePaid: stripeStats?.activeSubscribers ?? null,
currentMrrCents: stripeStats?.mrrCents ?? null,
currency: stripeStats?.currency ?? 'usd',
};
}