Files
OpenFrame/lib/analytics/record.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

184 lines
5.8 KiB
TypeScript

// 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);
}
}