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:
+115
-1
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user