mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user