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
@@ -0,0 +1,87 @@
-- CreateEnum
CREATE TYPE "AcquisitionChannel" AS ENUM ('DIRECT', 'GITHUB', 'YOUTUBE', 'GOOGLE', 'REVIEW_LINK', 'REFERRAL', 'OUTBOUND', 'COMMUNITY', 'OTHER');
-- CreateEnum
CREATE TYPE "AnalyticsEventName" AS ENUM ('LANDING_VIEW', 'CTA_CLICKED', 'SIGNUP_STARTED', 'SIGNUP_COMPLETED', 'EMAIL_VERIFIED', 'TRIAL_STARTED', 'WORKSPACE_CREATED', 'PROJECT_CREATED', 'VIDEO_ADDED', 'SHARE_LINK_CREATED', 'FIRST_GUEST_COMMENT', 'APPROVAL_COMPLETED', 'CHECKOUT_STARTED', 'SUBSCRIPTION_STARTED', 'SUBSCRIPTION_CANCELED', 'SUBSCRIPTION_REACTIVATED');
-- CreateTable
CREATE TABLE "acquisition_touches" (
"id" TEXT NOT NULL,
"anonymous_id" TEXT NOT NULL,
"channel" "AcquisitionChannel" NOT NULL,
"utm_source" TEXT,
"utm_medium" TEXT,
"utm_campaign" TEXT,
"referrer_host" TEXT,
"landing_path" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "acquisition_touches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_acquisitions" (
"user_id" TEXT NOT NULL,
"anonymous_id" TEXT,
"channel" "AcquisitionChannel" NOT NULL DEFAULT 'DIRECT',
"utm_source" TEXT,
"utm_medium" TEXT,
"utm_campaign" TEXT,
"referrer_host" TEXT,
"landing_path" TEXT,
"self_reported" "AcquisitionChannel",
"self_reported_note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_acquisitions_pkey" PRIMARY KEY ("user_id")
);
-- CreateTable
CREATE TABLE "analytics_events" (
"id" TEXT NOT NULL,
"name" "AnalyticsEventName" NOT NULL,
"user_id" TEXT,
"anonymous_id" TEXT,
"channel" "AcquisitionChannel",
"dedupe_key" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "acquisition_touches_anonymous_id_key" ON "acquisition_touches"("anonymous_id");
-- CreateIndex
CREATE INDEX "acquisition_touches_channel_created_at_idx" ON "acquisition_touches"("channel", "created_at");
-- CreateIndex
CREATE INDEX "acquisition_touches_created_at_idx" ON "acquisition_touches"("created_at");
-- CreateIndex
CREATE INDEX "user_acquisitions_channel_created_at_idx" ON "user_acquisitions"("channel", "created_at");
-- CreateIndex
CREATE INDEX "user_acquisitions_anonymous_id_idx" ON "user_acquisitions"("anonymous_id");
-- CreateIndex
CREATE UNIQUE INDEX "analytics_events_dedupe_key_key" ON "analytics_events"("dedupe_key");
-- CreateIndex
CREATE INDEX "analytics_events_name_occurred_at_idx" ON "analytics_events"("name", "occurred_at");
-- CreateIndex
CREATE INDEX "analytics_events_user_id_occurred_at_idx" ON "analytics_events"("user_id", "occurred_at");
-- CreateIndex
CREATE INDEX "analytics_events_channel_name_occurred_at_idx" ON "analytics_events"("channel", "name", "occurred_at");
-- CreateIndex
CREATE INDEX "analytics_events_anonymous_id_idx" ON "analytics_events"("anonymous_id");
-- AddForeignKey
ALTER TABLE "user_acquisitions" ADD CONSTRAINT "user_acquisitions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "analytics_events" ADD CONSTRAINT "analytics_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+115 -1
View File
@@ -50,7 +50,9 @@ model User {
canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy")
approvalDecisions ApprovalDecision[]
sentInvitations Invitation[] @relation("InvitationsSentBy")
acquisition UserAcquisition?
analyticsEvents AnalyticsEvent[]
@@map("users")
}
@@ -738,6 +740,118 @@ model VideoUploadSession {
@@map("video_upload_sessions")
}
// ============================================
// ACQUISITION + PRODUCT ANALYTICS
// ============================================
//
// Everything here is first party. Rows are written to this deployment's own
// database and read back by this deployment's own admin panel; no part of the
// codebase ships them anywhere else. The whole subsystem is off unless
// OPENFRAME_ENABLE_ANALYTICS is set, so a self-hosted instance carries the
// tables empty and pays nothing for them.
//
// No free text from user content is stored. Referrers are reduced to a host and
// landing pages to a path, both without query strings, so a shared link with a
// name or token in it cannot leak in here.
enum AcquisitionChannel {
DIRECT
GITHUB
YOUTUBE
GOOGLE
REVIEW_LINK
REFERRAL
OUTBOUND
COMMUNITY
OTHER
}
enum AnalyticsEventName {
LANDING_VIEW
CTA_CLICKED
SIGNUP_STARTED
SIGNUP_COMPLETED
EMAIL_VERIFIED
TRIAL_STARTED
WORKSPACE_CREATED
PROJECT_CREATED
VIDEO_ADDED
SHARE_LINK_CREATED
FIRST_GUEST_COMMENT
APPROVAL_COMPLETED
CHECKOUT_STARTED
SUBSCRIPTION_STARTED
SUBSCRIPTION_CANCELED
SUBSCRIPTION_REACTIVATED
}
// First touch for a visitor who does not have an account yet. Written once per
// anonymous id and never updated: the whole point is what brought them here the
// first time, so a later visit carrying different UTM tags must not overwrite it.
model AcquisitionTouch {
id String @id @default(cuid())
anonymousId String @unique @map("anonymous_id")
channel AcquisitionChannel
utmSource String? @map("utm_source")
utmMedium String? @map("utm_medium")
utmCampaign String? @map("utm_campaign")
referrerHost String? @map("referrer_host")
landingPath String @map("landing_path")
createdAt DateTime @default(now()) @map("created_at")
@@index([channel, createdAt])
@@index([createdAt])
@@map("acquisition_touches")
}
// The first touch copied onto the account at signup, plus the answer to the
// onboarding question. Kept beside User rather than on it so the acquisition
// columns stay out of every session and billing query.
model UserAcquisition {
userId String @id @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
anonymousId String? @map("anonymous_id")
channel AcquisitionChannel @default(DIRECT)
utmSource String? @map("utm_source")
utmMedium String? @map("utm_medium")
utmCampaign String? @map("utm_campaign")
referrerHost String? @map("referrer_host")
landingPath String? @map("landing_path")
selfReported AcquisitionChannel? @map("self_reported")
selfReportedNote String? @map("self_reported_note")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([channel, createdAt])
@@index([anonymousId])
@@map("user_acquisitions")
}
// One row per funnel step. `dedupeKey` is what makes "recorded exactly once"
// a property of the schema instead of a property of every call site: a replayed
// webhook, a double-submitted form or a refreshed page all collide on the unique
// index and the second write is dropped.
//
// The user relation is SetNull rather than Cascade on purpose. A deleted account
// still happened, and dropping its rows would silently rewrite past weeks of the
// funnel.
model AnalyticsEvent {
id String @id @default(cuid())
name AnalyticsEventName
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
anonymousId String? @map("anonymous_id")
channel AcquisitionChannel?
dedupeKey String @unique @map("dedupe_key")
occurredAt DateTime @default(now()) @map("occurred_at")
@@index([name, occurredAt])
@@index([userId, occurredAt])
@@index([channel, name, occurredAt])
@@index([anonymousId])
@@map("analytics_events")
}
// Rate limiting table (created as UNLOGGED via raw SQL migration)
// Defined here so `prisma db push` doesn't drop it
model RateLimit {