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:
yusufipk
2026-08-01 20:00:27 +03:00
parent 93e85683e9
commit 7ca5abd041
48 changed files with 3214 additions and 25 deletions
+88
View File
@@ -0,0 +1,88 @@
// Turning Stripe state into funnel events.
//
// These four events are derived from a before/after comparison inside the sync
// that already re-reads every subscription a customer has, rather than from the
// webhook event types. That is deliberate: webhooks arrive out of order and get
// replayed, and `customer.subscription.updated` fires for changes that mean
// nothing here. Comparing the row we are about to overwrite with the row we are
// writing is order-independent, and the dedupe keys make a replay a no-op.
import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
export interface SubscriptionStateBefore {
status: BillingSubscriptionStatus;
cancelAtPeriodEnd: boolean;
/** Whether this account had already consumed a trial before this sync. */
hadTrial: boolean;
}
export interface SubscriptionStateAfter {
status: BillingSubscriptionStatus;
cancelAtPeriodEnd: boolean;
trialEndsAt: Date | null;
currentPeriodEnd: Date | null;
}
/**
* A cancellation and the reactivation that may follow it both belong to a
* billing cycle. Keying them on the period end lets a customer cancel, come
* back, and cancel again in a later cycle without the second one being
* swallowed as a duplicate, while the two Stripe writes that describe a single
* cancellation (the `cancel_at_period_end` flag now, the `canceled` status
* later) collapse into one event.
*/
function cycleMarker(currentPeriodEnd: Date | null): string {
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0);
}
export async function recordSubscriptionTransition(params: {
userId: string;
subscriptionId: string;
before: SubscriptionStateBefore;
after: SubscriptionStateAfter;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
const { userId, subscriptionId, before, after } = params;
const cycle = cycleMarker(after.currentPeriodEnd);
// Once per account for its lifetime. A second trial is not a second start of
// the funnel, and Stripe will not grant one anyway.
if (after.trialEndsAt && !before.hadTrial) {
await recordEvent({
name: 'TRIAL_STARTED',
dedupeKey: eventKey('TRIAL_STARTED', userId),
userId,
});
}
// The paying moment. With a trial the status goes trialing -> active, so this
// fires on conversion rather than on signup for the trial.
if (after.status === 'ACTIVE' && before.status !== 'ACTIVE') {
await recordEvent({
name: 'SUBSCRIPTION_STARTED',
dedupeKey: eventKey('SUBSCRIPTION_STARTED', subscriptionId),
userId,
});
}
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
userId,
});
}
if (!after.cancelAtPeriodEnd && before.cancelAtPeriodEnd && after.status !== 'CANCELED') {
await recordEvent({
name: 'SUBSCRIPTION_REACTIVATED',
dedupeKey: `SUBSCRIPTION_REACTIVATED:${subscriptionId}:${cycle}`,
userId,
});
}
}
+42
View File
@@ -0,0 +1,42 @@
// Traffic that is not a person.
//
// This matters more than it looks. Visitors are the denominator of every
// conversion rate in the scoreboard, so counting a crawler as a visit does not
// add noise evenly: it quietly makes every channel look worse, and the channels
// that attract the most crawling (an indexed landing page, a GitHub README link)
// look worst of all.
const BOT_PATTERN =
/bot\b|bots\b|crawler|spider|crawl|slurp|facebookexternalhit|embedly|quora link preview|whatsapp|telegram|discordbot|slackbot|preview|monitor|uptime|pingdom|curl\/|wget\/|python-requests|python-urllib|scrapy|axios\/|node-fetch|go-http-client|okhttp|java\/|headlesschrome|phantomjs|lighthouse|semrush|ahrefs|mj12|dotbot|petalbot|bytespider|gptbot|claudebot|ccbot/i;
/**
* A missing user agent counts as a bot. Every real browser sends one, so the
* blank case is a script that did not bother.
*/
export function isLikelyBot(userAgent: string | null | undefined): boolean {
if (typeof userAgent !== 'string') return true;
const value = userAgent.trim();
if (!value) return true;
return BOT_PATTERN.test(value);
}
/**
* Whether a request is a real page load rather than a prefetch, an asset or a
* client-side navigation payload.
*
* Next prefetches the register page as soon as a CTA scrolls into view, so
* without this the funnel would show more signup starts than landing views.
*/
export function isCountableDocumentRequest(headers: Headers): boolean {
if (headers.get('sec-purpose')?.includes('prefetch')) return false;
if (headers.get('purpose') === 'prefetch') return false;
if (headers.get('next-router-prefetch')) return false;
// An RSC navigation is the same visitor moving inside the app, not a new view.
if (headers.get('rsc')) return false;
const dest = headers.get('sec-fetch-dest');
if (dest) return dest === 'document';
// Older browsers and anything behind a proxy that strips fetch metadata.
return headers.get('accept')?.includes('text/html') ?? false;
}
+212
View File
@@ -0,0 +1,212 @@
// Turns whatever the browser told us about a visit into one of nine buckets.
//
// Pure and dependency-free on purpose: this runs in the proxy (edge runtime), so
// the only Prisma reference here is a type-only import, which the compiler erases.
//
// Everything that reaches this file is already reduced to a host and a couple of
// UTM tags. Nothing here ever sees a full URL, so a query string carrying a share
// token or an email address cannot be classified into a stored column by mistake.
import type { AcquisitionChannel } from '@prisma/client';
export interface ChannelInput {
utmSource?: string | null;
utmMedium?: string | null;
referrerHost?: string | null;
}
const MAX_TAG_LENGTH = 64;
const MAX_PATH_LENGTH = 128;
/** Lowercases, trims and caps a UTM tag. Returns null for anything empty. */
export function sanitizeTag(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const cleaned = value.trim().toLowerCase().slice(0, MAX_TAG_LENGTH);
if (!cleaned) return null;
// Campaign names are ours, so anything outside this set is either a typo or
// somebody probing what the column accepts. Drop it rather than store it.
if (!/^[a-z0-9._%+\- ]+$/.test(cleaned)) return null;
return cleaned;
}
/**
* Strips the `www.` prefix and the port, lowercases, and caps the length.
*
* A whole URL is not a host and comes back null. Splitting on the first colon
* would otherwise turn `https://github.com` into the host `https`, and every
* caller that passed one by mistake would file its traffic under a channel that
* does not exist.
*/
export function normalizeHost(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim().toLowerCase();
const withoutPort = trimmed.replace(/:\d+$/, '');
const host = withoutPort.replace(/^www\./, '');
if (!host || !/^[a-z0-9.\-]+$/.test(host)) return null;
return host.slice(0, MAX_TAG_LENGTH);
}
/**
* The referring host, or null when there is no usable one.
*
* A referrer pointing at our own deployment is not a referrer: it is the visitor
* clicking through the site. Treating it as one would file most of the funnel
* under whatever page they happened to start on.
*/
export function extractReferrerHost(
referrer: string | null | undefined,
selfHost?: string | null
): string | null {
if (!referrer) return null;
let host: string | null;
try {
const url = new URL(referrer);
// Browsers only ever send an http(s) referrer. Anything else is a scheme we
// have no host for, such as `android-app://com.example`, and reading its
// opaque body as a domain would invent a referring site.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
host = normalizeHost(url.hostname);
} catch {
return null;
}
if (!host) return null;
const self = normalizeHost(selfHost);
if (self && host === self) return null;
return host;
}
/** Path only, no query string and no fragment, capped. */
export function sanitizeLandingPath(pathname: string | null | undefined): string {
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
const path = pathname.split('?')[0]?.split('#')[0] ?? '/';
return path.slice(0, MAX_PATH_LENGTH) || '/';
}
function suffixMatch(host: string, domain: string): boolean {
return host === domain || host.endsWith(`.${domain}`);
}
const GITHUB_HOSTS = ['github.com', 'github.blog'];
const YOUTUBE_HOSTS = ['youtube.com', 'youtu.be'];
// The bucket the plan calls "google" is really organic search. Google is the
// overwhelming majority of it, and splitting Bing and DuckDuckGo into their own
// slivers would make every row in the scoreboard smaller without changing a
// single decision.
const SEARCH_HOSTS = ['google.com', 'bing.com', 'duckduckgo.com', 'ecosia.org', 'yandex.com'];
const REVIEW_HOSTS = [
'producthunt.com',
'g2.com',
'capterra.com',
'getapp.com',
'alternativeto.net',
'saashub.com',
'slant.co',
'trustpilot.com',
'sourceforge.net',
];
const COMMUNITY_HOSTS = [
'reddit.com',
'news.ycombinator.com',
'lobste.rs',
'discord.com',
'discord.gg',
'x.com',
'twitter.com',
't.co',
'linkedin.com',
'lnkd.in',
'bsky.app',
'mastodon.social',
'dev.to',
'indiehackers.com',
'facebook.com',
'instagram.com',
't.me',
];
// utm_source values we set ourselves, plus the ones other people tend to use
// when they link us. Matched exactly after sanitizing.
const SOURCE_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
['github', 'GITHUB'],
['youtube', 'YOUTUBE'],
['yt', 'YOUTUBE'],
['google', 'GOOGLE'],
['bing', 'GOOGLE'],
['duckduckgo', 'GOOGLE'],
['producthunt', 'REVIEW_LINK'],
['product-hunt', 'REVIEW_LINK'],
['g2', 'REVIEW_LINK'],
['capterra', 'REVIEW_LINK'],
['alternativeto', 'REVIEW_LINK'],
['reddit', 'COMMUNITY'],
['hackernews', 'COMMUNITY'],
['hn', 'COMMUNITY'],
['discord', 'COMMUNITY'],
['twitter', 'COMMUNITY'],
['x', 'COMMUNITY'],
['linkedin', 'COMMUNITY'],
['newsletter', 'OUTBOUND'],
['coldmail', 'OUTBOUND'],
['outreach', 'OUTBOUND'],
]);
// A medium that names the motion beats the source that names the place: an
// outbound campaign sent from a LinkedIn account is outbound, not community.
const MEDIUM_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
['outbound', 'OUTBOUND'],
['email', 'OUTBOUND'],
['cold-email', 'OUTBOUND'],
['coldemail', 'OUTBOUND'],
['dm', 'OUTBOUND'],
['referral', 'REFERRAL'],
['affiliate', 'REFERRAL'],
]);
function classifyHost(host: string): AcquisitionChannel | null {
if (GITHUB_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GITHUB';
if (YOUTUBE_HOSTS.some((domain) => suffixMatch(host, domain))) return 'YOUTUBE';
// google.co.uk, google.de and the rest: the country domains all sit under a
// `google.<tld>` label, so match the label rather than listing 190 domains.
if (/(^|\.)google\.[a-z.]{2,6}$/.test(host)) return 'GOOGLE';
if (SEARCH_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GOOGLE';
if (REVIEW_HOSTS.some((domain) => suffixMatch(host, domain))) return 'REVIEW_LINK';
if (COMMUNITY_HOSTS.some((domain) => suffixMatch(host, domain))) return 'COMMUNITY';
return null;
}
/**
* The bucket a visit belongs to.
*
* Precedence: an explicit medium that names the motion, then an explicit source,
* then the referring host, then direct. A tagged campaign we do not recognise is
* OTHER rather than DIRECT, because somebody deliberately tagged it.
*
* An unrecognised site that links to us counts as REFERRAL. The raw host is
* stored alongside, so a host that turns out to matter can be promoted into one
* of the lists above and re-read from history.
*/
export function classifyChannel(input: ChannelInput): AcquisitionChannel {
const source = sanitizeTag(input.utmSource);
const medium = sanitizeTag(input.utmMedium);
const host = normalizeHost(input.referrerHost);
const byMedium = medium ? MEDIUM_NAMES.get(medium) : undefined;
if (byMedium) return byMedium;
if (source) {
const bySource = SOURCE_NAMES.get(source);
if (bySource) return bySource;
// A source that looks like a domain (utm_source=github.com) is worth reading
// as one before giving up on it.
return classifyHost(source) ?? 'OTHER';
}
if (host) {
return classifyHost(host) ?? 'REFERRAL';
}
return 'DIRECT';
}
+113
View File
@@ -0,0 +1,113 @@
// The two cookies the acquisition system sets, and how to read them back.
//
// Both are first party, both stay on this deployment's own domain, and neither
// is readable from JavaScript. They exist so that a visitor who arrives from a
// YouTube link on Tuesday and signs up on Friday is still counted against
// YouTube; there is no cross-site identifier and nothing is sent anywhere.
//
// Imported by the proxy, so this file must stay free of Prisma and of anything
// else that cannot run on the edge.
import type { AcquisitionChannel } from '@prisma/client';
import { sanitizeLandingPath, sanitizeTag, normalizeHost } from '@/lib/analytics/channel';
export const ANONYMOUS_ID_COOKIE = 'of_aid';
export const FIRST_TOUCH_COOKIE = 'of_ft';
export const ANONYMOUS_ID_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
/** cuid-ish length bound. Values outside it are treated as absent, not repaired. */
const ANONYMOUS_ID_PATTERN = /^[a-z0-9]{16,64}$/;
export interface FirstTouch {
channel: AcquisitionChannel;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
referrerHost: string | null;
landingPath: string;
}
/** Short keys: this rides on every request, so the wire form stays compact. */
interface EncodedFirstTouch {
c: string;
s?: string;
m?: string;
k?: string;
r?: string;
p: string;
}
const CHANNELS: readonly AcquisitionChannel[] = [
'DIRECT',
'GITHUB',
'YOUTUBE',
'GOOGLE',
'REVIEW_LINK',
'REFERRAL',
'OUTBOUND',
'COMMUNITY',
'OTHER',
];
export function isAcquisitionChannel(value: unknown): value is AcquisitionChannel {
return typeof value === 'string' && (CHANNELS as readonly string[]).includes(value);
}
export function isValidAnonymousId(value: string | null | undefined): value is string {
return typeof value === 'string' && ANONYMOUS_ID_PATTERN.test(value);
}
/** 26 lowercase base36 characters from the Web Crypto API, which the edge has. */
export function generateAnonymousId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
let id = '';
for (const byte of bytes) {
id += byte.toString(36).padStart(2, '0');
}
return id;
}
export function encodeFirstTouch(touch: FirstTouch): string {
const payload: EncodedFirstTouch = { c: touch.channel, p: touch.landingPath };
if (touch.utmSource) payload.s = touch.utmSource;
if (touch.utmMedium) payload.m = touch.utmMedium;
if (touch.utmCampaign) payload.k = touch.utmCampaign;
if (touch.referrerHost) payload.r = touch.referrerHost;
return encodeURIComponent(JSON.stringify(payload));
}
/**
* Parses the cookie back, re-sanitizing every field.
*
* The cookie is httpOnly but it still came from the client, so a hand-edited one
* must not be able to put arbitrary text into a database column. Anything that
* fails validation makes the whole value null: a half-trusted first touch is
* worse than none.
*/
export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | null {
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(decodeURIComponent(raw));
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const value = parsed as Record<string, unknown>;
if (!isAcquisitionChannel(value.c)) return null;
if (typeof value.p !== 'string') return null;
return {
channel: value.c,
utmSource: sanitizeTag(typeof value.s === 'string' ? value.s : null),
utmMedium: sanitizeTag(typeof value.m === 'string' ? value.m : null),
utmCampaign: sanitizeTag(typeof value.k === 'string' ? value.k : null),
referrerHost: normalizeHost(typeof value.r === 'string' ? value.r : null),
landingPath: sanitizeLandingPath(value.p),
};
}
+183
View File
@@ -0,0 +1,183 @@
// The only way anything in this repo writes an analytics row.
//
// Two things are centralised here so that no call site has to remember them:
//
// 1. The feature flag. Every function below returns without touching the
// database when OPENFRAME_ENABLE_ANALYTICS is off, which is why the ~15 call
// sites scattered through app/api are unconditional one-liners.
// 2. Failure. Measurement must never be able to fail a product request, so
// every write is caught and logged. An event that is not recorded is a hole
// in a chart; an event that throws is a user who cannot create a project.
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
import { db } from '@/lib/db';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
import type { FirstTouch } from '@/lib/analytics/cookies';
export interface RecordEventInput {
name: AnalyticsEventName;
/**
* What makes this event unique. The column is UNIQUE, so a replayed webhook, a
* double-submitted form or a refreshed page collide here and the second write
* is dropped by the database rather than by a check somebody might forget.
*/
dedupeKey: string;
userId?: string | null;
anonymousId?: string | null;
/**
* Only set for events that happen before there is an account. Once a user
* exists their channel lives in user_acquisitions and the scoreboard reads it
* from there, so a later correction applies to their whole history.
*/
channel?: AcquisitionChannel | null;
occurredAt?: Date;
}
/** `<event>:<subject>`, for something that can only ever happen once per subject. */
export function eventKey(name: AnalyticsEventName, subject: string): string {
return `${name}:${subject}`;
}
/**
* `<event>:<subject>:<UTC day>`, for something repeatable that should still count
* once per visitor per day (a landing view, a CTA click).
*/
export function dailyEventKey(
name: AnalyticsEventName,
subject: string,
at: Date = new Date()
): string {
return `${name}:${subject}:${at.toISOString().slice(0, 10)}`;
}
export async function recordEvent(input: RecordEventInput): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
try {
await db.analyticsEvent.createMany({
data: [
{
name: input.name,
dedupeKey: input.dedupeKey,
userId: input.userId ?? null,
anonymousId: input.anonymousId ?? null,
channel: input.channel ?? null,
...(input.occurredAt ? { occurredAt: input.occurredAt } : {}),
},
],
skipDuplicates: true,
});
} catch (error) {
logError('Failed to record analytics event:', error);
}
}
/**
* Stores the first touch for a visitor with no account yet.
*
* Never updated. A visitor who comes back a week later through a different link
* keeps the channel that brought them the first time, which is the question the
* scoreboard is asking.
*/
export async function recordFirstTouch(anonymousId: string, touch: FirstTouch): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
try {
await db.acquisitionTouch.createMany({
data: [
{
anonymousId,
channel: touch.channel,
utmSource: touch.utmSource,
utmMedium: touch.utmMedium,
utmCampaign: touch.utmCampaign,
referrerHost: touch.referrerHost,
landingPath: touch.landingPath,
},
],
skipDuplicates: true,
});
} catch (error) {
logError('Failed to record acquisition touch:', error);
}
}
/**
* Copies the first touch onto a freshly created account and claims the events
* that visitor produced before they had one.
*
* The backfill is what joins the two halves of the funnel: without it a landing
* view and the signup it led to are two unrelated rows, and no query can tell
* you that GitHub traffic converts and Google traffic does not.
*/
export async function attachAcquisitionToUser(params: {
userId: string;
anonymousId: string | null;
touch: FirstTouch | null;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
const { userId, anonymousId, touch } = params;
try {
await db.userAcquisition.createMany({
data: [
{
userId,
anonymousId,
channel: touch?.channel ?? 'DIRECT',
utmSource: touch?.utmSource ?? null,
utmMedium: touch?.utmMedium ?? null,
utmCampaign: touch?.utmCampaign ?? null,
referrerHost: touch?.referrerHost ?? null,
landingPath: touch?.landingPath ?? null,
},
],
skipDuplicates: true,
});
if (anonymousId) {
await db.analyticsEvent.updateMany({
where: { anonymousId, userId: null },
data: { userId },
});
}
} catch (error) {
logError('Failed to attach acquisition to user:', error);
}
}
/**
* The answer to the onboarding question, which is a check on the cookie rather
* than a replacement for it: it survives a cleared cookie and a phone-to-laptop
* switch, and it is the only signal that can catch a channel the UTM tags miss
* entirely ("a friend told me").
*/
export async function setSelfReportedSource(params: {
userId: string;
selfReported: AcquisitionChannel;
note?: string | null;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
const note = params.note?.trim().slice(0, 200) || null;
try {
await db.userAcquisition.upsert({
where: { userId: params.userId },
create: {
userId: params.userId,
channel: 'DIRECT',
selfReported: params.selfReported,
selfReportedNote: note,
},
update: {
selfReported: params.selfReported,
selfReportedNote: note,
},
});
} catch (error) {
logError('Failed to store self-reported acquisition source:', error);
}
}
+323
View File
@@ -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',
};
}
+48
View File
@@ -0,0 +1,48 @@
// Signup is the seam where an anonymous visitor becomes an account, so it is the
// one place the two halves of the funnel are joined. Both ways of creating an
// account (the credentials form and an OAuth provider) go through here, because
// a channel that only shows up for one of them is worse than no channel at all.
import { eventKey, recordEvent, attachAcquisitionToUser } from '@/lib/analytics/record';
import { readVisitorContext, type VisitorContext } from '@/lib/analytics/visitor';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null };
/**
* The visitor context of the request being handled.
*
* For the OAuth path there is no NextRequest to read: the account is created by
* the adapter, from inside a NextAuth event. `cookies()` still resolves there,
* and when it does not the signup is simply recorded without a channel rather
* than not recorded at all.
*/
export async function readVisitorContextFromHeaders(): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
try {
const { cookies } = await import('next/headers');
return readVisitorContext(await cookies());
} catch {
return NO_VISITOR;
}
}
export async function recordSignupCompleted(params: {
userId: string;
visitor: VisitorContext;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
await attachAcquisitionToUser({
userId: params.userId,
anonymousId: params.visitor.anonymousId,
touch: params.visitor.firstTouch,
});
await recordEvent({
name: 'SIGNUP_COMPLETED',
dedupeKey: eventKey('SIGNUP_COMPLETED', params.userId),
userId: params.userId,
anonymousId: params.visitor.anonymousId,
});
}
+72
View File
@@ -0,0 +1,72 @@
// Reading the acquisition cookies, and recording the events that happen before
// an account exists.
//
// The proxy sets the cookies but cannot write rows (it runs on the edge). These
// helpers close that gap from Node, and are the reason no visitor event depends
// on client-side JavaScript running: a landing view is recorded by the server
// rendering the landing page, so an ad blocker has nothing to block. That is not
// a purity argument. Blocking rates differ by channel, and an undercounted
// denominator would make GitHub and Hacker News traffic look like it converts
// better than it does.
import type { AnalyticsEventName } from '@prisma/client';
import {
ANONYMOUS_ID_COOKIE,
FIRST_TOUCH_COOKIE,
decodeFirstTouch,
isValidAnonymousId,
type FirstTouch,
} from '@/lib/analytics/cookies';
import { dailyEventKey, recordEvent, recordFirstTouch } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
/** Both `cookies()` from next/headers and `request.cookies` satisfy this. */
export interface AnalyticsCookieReader {
get(name: string): { value: string } | undefined;
}
export interface VisitorContext {
anonymousId: string | null;
firstTouch: FirstTouch | null;
}
export function readVisitorContext(store: AnalyticsCookieReader): VisitorContext {
const rawId = store.get(ANONYMOUS_ID_COOKIE)?.value;
return {
anonymousId: isValidAnonymousId(rawId) ? rawId : null,
firstTouch: decodeFirstTouch(store.get(FIRST_TOUCH_COOKIE)?.value),
};
}
const DIRECT_TOUCH: FirstTouch = {
channel: 'DIRECT',
utmSource: null,
utmMedium: null,
utmCampaign: null,
referrerHost: null,
landingPath: '/',
};
/**
* Records an event for a visitor with no account, once per visitor per UTC day.
*
* The first touch row is written here rather than in the proxy because this is
* the first moment the visitor is known to be a browser that kept the cookie.
*/
export async function recordVisitorEvent(
name: AnalyticsEventName,
visitor: VisitorContext
): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
if (!visitor.anonymousId) return;
const touch = visitor.firstTouch ?? DIRECT_TOUCH;
await recordFirstTouch(visitor.anonymousId, touch);
await recordEvent({
name,
dedupeKey: dailyEventKey(name, visitor.anonymousId),
anonymousId: visitor.anonymousId,
channel: touch.channel,
});
}