diff --git a/.env.example b/.env.example index fa63f41..5d0d2f2 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES="33554432" # Run once after creating the bucket: bun run r2:configure-cors # Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy. OPENFRAME_REQUIRE_INVITE_CODE="true" +# Acquisition attribution and funnel events, read back on /admin/growth. Off by +# default: the rows only pay for themselves if you are running a signup funnel. +# Everything is written to this instance's own database and sent nowhere. +OPENFRAME_ENABLE_ANALYTICS="false" SELF_HOSTED_AUTO_CREATE_BUCKET="false" # ============================================================================ diff --git a/README.md b/README.md index adc1b64..79f6dfd 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ Behavior when disabled: - `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available. - `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`. - `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact. +- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`. Off by default, and nothing leaves the instance either way. For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts: diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 43a012a..84930c1 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,3 +1,5 @@ +import { after } from 'next/server'; +import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor'; import { isInviteCodeRequired } from '@/lib/feature-flags'; import { getInvitationPreviewByToken } from '@/lib/invitations'; import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit'; @@ -8,6 +10,13 @@ interface RegisterPageProps { } export default async function RegisterPage({ searchParams }: RegisterPageProps) { + // Reaching this page is the funnel step. Recording it here rather than from the + // browser also keeps it honest: a prefetch of this route is filtered out by + // readPageVisitor, so signup starts can never outnumber the landing views + // above them. + const visitor = await readPageVisitor(); + after(() => recordVisitorEvent('SIGNUP_STARTED', visitor)); + const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); diff --git a/app/(marketing)/[slug]/page.tsx b/app/(marketing)/[slug]/page.tsx index 4e0c393..27691af 100644 --- a/app/(marketing)/[slug]/page.tsx +++ b/app/(marketing)/[slug]/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; +import { after } from 'next/server'; import { ComparisonPage } from '@/components/marketing/comparison-page'; +import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor'; import { auth } from '@/lib/auth'; import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages'; import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata'; @@ -38,6 +40,15 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro } const session = await auth(); + const isLoggedIn = Boolean(session?.user); + + // A comparison page is a landing page: for most of these visitors it is the + // first thing they see, so it belongs in the same visitor count as `/`. + if (!isLoggedIn) { + const visitor = await readPageVisitor(); + after(() => recordVisitorEvent('LANDING_VIEW', visitor)); + } + const structuredData = buildComparisonJsonLd({ title: page.title, description: page.metaDescription, @@ -57,7 +68,7 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro }} /> ))} - + ); } diff --git a/app/admin/growth/page.tsx b/app/admin/growth/page.tsx new file mode 100644 index 0000000..003cb1e --- /dev/null +++ b/app/admin/growth/page.tsx @@ -0,0 +1,365 @@ +import { Metadata } from 'next'; +import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + AT_RISK_SILENT_DAYS, + conversionRates, + getScoreboard, + type FunnelRates, +} from '@/lib/analytics/scoreboard'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; +import { AlertTriangle, CreditCard, TrendingUp, Users } from 'lucide-react'; + +export const metadata: Metadata = { + title: 'Growth | OpenFrame', + description: 'Acquisition funnel and retention scoreboard', +}; + +function formatMoney(cents: number | null, currency: string) { + if (cents === null) return '—'; + const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD'; + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: safeCurrency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(cents / 100); +} + +function formatWeek(date: Date) { + return date.toISOString().slice(0, 10); +} + +function formatDate(date: Date | null) { + return date ? date.toISOString().slice(0, 10) : 'never'; +} + +/** A percentage with the count it was computed from, because n matters here. */ +function Rate({ rate, of }: { rate: number | null; of: number }) { + if (rate === null) return ; + return ( + + {Math.round(rate * 100)}% /{of} + + ); +} + +const WEEK_COLUMNS: Array<{ key: string; label: string }> = [ + { key: 'visitors', label: 'Visitors' }, + { key: 'signups', label: 'Signup' }, + { key: 'firstVideo', label: 'Video' }, + { key: 'shareLinks', label: 'Share link' }, + { key: 'externalFeedback', label: 'Ext. feedback' }, + { key: 'trials', label: 'Trial' }, + { key: 'newPaid', label: 'New paid' }, + { key: 'canceled', label: 'Canceled' }, + { key: 'activePaid', label: 'Active paid' }, +]; + +export default async function AdminGrowthPage() { + const session = await auth(); + if (!session?.user?.isAdmin) { + redirect('/'); + } + + if (!isProductAnalyticsEnabled()) { + return ( +
+

Growth

+ + + Acquisition tracking is off on this deployment. Set{' '} + OPENFRAME_ENABLE_ANALYTICS=true to start recording + the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but + this instance's own database. + + +
+ ); + } + + const scoreboard = await getScoreboard(); + const latest = scoreboard.weeks[scoreboard.weeks.length - 1]; + const window = scoreboard.weeks.reduce( + (sum, week) => ({ + visitors: sum.visitors + week.visitors, + signups: sum.signups + week.signups, + firstVideo: sum.firstVideo + week.firstVideo, + shareLinks: sum.shareLinks + week.shareLinks, + externalFeedback: sum.externalFeedback + week.externalFeedback, + trials: sum.trials + week.trials, + newPaid: sum.newPaid + week.newPaid, + }), + { + visitors: 0, + signups: 0, + firstVideo: 0, + shareLinks: 0, + externalFeedback: 0, + trials: 0, + newPaid: 0, + } + ); + const overall: FunnelRates = conversionRates(window); + + return ( +
+
+

Growth

+
+ +
+ + + Active paid + + + +
{scoreboard.currentActivePaid ?? '—'}
+

from Stripe, right now

+
+
+ + + MRR + + + +
+ {formatMoney(scoreboard.currentMrrCents, scoreboard.currency)} +
+

from Stripe, right now

+
+
+ + + Visitors this week + + + +
{latest?.visitors ?? 0}
+

+ {latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'} +

+
+
+ + + At risk + + + +
{scoreboard.atRisk.length}
+

+ paid, silent for {AT_RISK_SILENT_DAYS}+ days +

+
+
+
+ + + + Weekly funnel +

+ Weeks start Monday, UTC. Active paid is the running net of subscriptions started minus + canceled, so it can drift from the Stripe figure above; the difference is the drift. +

+
+ + + + + + {WEEK_COLUMNS.map((column) => ( + + ))} + + + + + {scoreboard.weeks.map((week) => ( + + + {WEEK_COLUMNS.map((column) => ( + + ))} + + + ))} + +
Week + {column.label} + MRR
{formatWeek(week.weekStart)} + {week[column.key as keyof typeof week] as number} + + {formatMoney(week.mrrCents, scoreboard.currency)} +
+
+
+ + + + Where it narrows +

+ Every step over the whole {scoreboard.weeks.length}-week window, with the denominator + beside it. The lowest rate is the step to work on. +

+
+ +
+
Visitor to signup
+
+ +
+
+
+
Signup to first video
+
+ +
+
+
+
Video to share link
+
+ +
+
+
+
Share to outside feedback
+
+ +
+
+
+
Trial to paid
+
+ +
+
+
+
+ + + + By source +

+ Rolling {scoreboard.channelWindowDays} days rather than one week: a weekly per-source + cell holds single digits at this volume, and a percentage computed from three visits + reads exactly as confidently as one computed from three hundred. +

+
+ + + + + + + + + + + + + + {scoreboard.channels.length === 0 && ( + + + + )} + {scoreboard.channels.map((row) => ( + + + + + + + + + ))} + +
SourceVisitorsSignupTrialPaidVisitor to signup
+ Nothing recorded in this window yet. +
{row.channel.toLowerCase()}{row.visitors}{row.signups}{row.trials}{row.paid} + 0 ? row.signups / row.visitors : null} + of={row.visitors} + /> +
+
+
+ + + + Paid accounts +

+ Value events are videos, share links, outside feedback, approvals and projects. Rows + marked at risk have produced none for {AT_RISK_SILENT_DAYS} days. + {scoreboard.paidAccountsTruncated && ( + <> + {' '} + Quietest {scoreboard.paidAccountLimit} only; there are more paid accounts than this + table shows. + + )} +

+
+ + + + + + + + + + + + + + {scoreboard.paidAccounts.length === 0 && ( + + + + )} + {scoreboard.paidAccounts.map((account) => { + const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId); + return ( + + + + + + + + + ); + })} + +
AccountStatusSource7d30dLast activity
+ No active or trialing accounts. +
+ {account.name || account.email || account.userId} + {atRisk && ( + + at risk + + )} + + {account.status.toLowerCase()} + + {account.channel?.toLowerCase() ?? '—'} + {account.selfReported && account.selfReported !== account.channel && ( + + {' '} + (said {account.selfReported.toLowerCase()}) + + )} + {account.valueEvents7}{account.valueEvents30} + {formatDate(account.lastValueEventAt)} +
+
+
+
+ ); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 8ac9288..a57dcbe 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; import { auth } from '@/lib/auth'; import { Header } from '@/components/layout'; import Link from 'next/link'; -import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react'; +import { LayoutDashboard, MessageSquareQuote, TrendingUp, Users } from 'lucide-react'; export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await auth(); @@ -39,6 +39,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN Feedback + + + Growth + {/* Desktop Nav */} @@ -66,6 +73,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN Feedback + + + Growth + diff --git a/app/api/admin/growth/route.ts b/app/api/admin/growth/route.ts new file mode 100644 index 0000000..2e75810 --- /dev/null +++ b/app/api/admin/growth/route.ts @@ -0,0 +1,38 @@ +import { NextRequest } from 'next/server'; +import { auth } from '@/lib/auth'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; +import { logError } from '@/lib/logger'; + +// The same numbers /admin/growth renders, as JSON, so the Monday digest can pull +// the scoreboard instead of somebody retyping it into a table. +export async function GET(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + if (!session.user.isAdmin) { + return apiErrors.forbidden('Admin access required'); + } + + if (!isProductAnalyticsEnabled()) { + return apiErrors.badRequest('Analytics are disabled by this host'); + } + + const weeksParam = Number(request.nextUrl.searchParams.get('weeks')); + const scoreboard = await getScoreboard({ + weeks: Number.isSafeInteger(weeksParam) && weeksParam > 0 ? weeksParam : undefined, + }); + + const response = successResponse({ + ...scoreboard, + weeks: scoreboard.weeks.map((week) => ({ ...week, rates: conversionRates(week) })), + }); + return withCacheControl(response, 'private, no-store'); + } catch (error) { + logError('Error building the growth scoreboard:', error); + return apiErrors.internalError('Failed to build the scoreboard'); + } +} diff --git a/app/api/approvals/[requestId]/decision/route.ts b/app/api/approvals/[requestId]/decision/route.ts index f5ecf6a..71b141f 100644 --- a/app/api/approvals/[requestId]/decision/route.ts +++ b/app/api/approvals/[requestId]/decision/route.ts @@ -6,6 +6,7 @@ import { notifyUsers } from '@/lib/notifications'; import { rateLimit } from '@/lib/rate-limit'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { logError } from '@/lib/logger'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; type RouteParams = { params: Promise<{ requestId: string }> }; @@ -202,6 +203,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }); if (updated.status === 'APPROVED') { + await recordEvent({ + name: 'APPROVAL_COMPLETED', + dedupeKey: eventKey('APPROVAL_COMPLETED', requestId), + userId: approvalRequest.version.video.project.ownerId, + }); + notifyUsers([updated.requestedById], { type: 'approval_completed', projectName: updated.version.video.project.name, diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 7c28ba9..801a8e3 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -17,6 +17,8 @@ import { sendVerificationEmail, } from '@/lib/email-verification'; import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation'; +import { recordSignupCompleted } from '@/lib/analytics/signup'; +import { readRequestVisitor } from '@/lib/analytics/visitor'; export async function POST(request: NextRequest) { try { @@ -132,6 +134,14 @@ export async function POST(request: NextRequest) { } } + // Ties the account to the first touch stored in this browser's cookie and + // claims the visitor events that led here. Recorded after the invitation has + // been accepted, so an account that gets rolled back never leaves a signup. + await recordSignupCompleted({ + userId: user.id, + visitor: await readRequestVisitor(request), + }); + // Send verification email if SMTP is configured if (emailVerificationRequired) { const verificationToken = await createVerificationToken(normalizedEmail); diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index b28cb83..81c44dd 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -11,6 +11,7 @@ import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe'; import { isTrustedSameOriginRequest } from '@/lib/request-origin'; import { logError } from '@/lib/logger'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; function getAppOrigin(request: NextRequest) { if (isTrustedSameOriginRequest(request)) { @@ -84,6 +85,15 @@ export async function POST(request: NextRequest) { throw new Error('Stripe did not return a checkout URL'); } + // Keyed on the Stripe session, so an abandoned checkout followed by a second + // attempt counts twice. That is the intent: the gap between checkouts started + // and subscriptions started is the number worth watching. + await recordEvent({ + name: 'CHECKOUT_STARTED', + dedupeKey: eventKey('CHECKOUT_STARTED', checkoutSession.id), + userId: session.user.id, + }); + const response = successResponse({ url: checkoutSession.url }); return withCacheControl(response, 'private, no-store'); } catch (error) { diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..262686d --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server'; +import { rateLimit } from '@/lib/rate-limit'; +import { isTrustedSameOriginRequest } from '@/lib/request-origin'; +import { readRequestVisitor, recordVisitorEvent } from '@/lib/analytics/visitor'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; + +// The one funnel event that cannot be observed from the server: a click on a +// call to action, which never reaches us as a request of its own. +// +// Everything else in the funnel is recorded where it actually happens, so this +// endpoint accepts exactly one event name. An anonymous caller must not be able +// to post `SUBSCRIPTION_STARTED` into the scoreboard, and the cheapest way to +// guarantee that is to make the allowed set a single literal. +const ALLOWED_EVENTS = new Set(['cta_clicked']); + +export async function POST(request: NextRequest) { + // Answers 204 whatever happens. This endpoint reports nothing back to the page + // that called it, so there is no reason to tell a caller which of their + // attempts landed. + const noContent = new Response(null, { + status: 204, + headers: { 'Cache-Control': 'private, no-store' }, + }); + + // Both cheap and both free of side effects, so they come before the limiter. + // Checking the flag here rather than only inside the recorder keeps a host who + // never turned analytics on from paying a rate-limit write for every anonymous + // POST to an endpoint they are not using. + if (!isProductAnalyticsEnabled()) return noContent; + if (!isTrustedSameOriginRequest(request)) return noContent; + + // 204 rather than the limiter's 429: a beacon has nobody to tell, and a + // flooder should not be handed a signal for when the window resets. + const limited = await rateLimit(request, 'analytics-beacon'); + if (limited) return noContent; + + const body = await request.json().catch(() => null); + const name = typeof body?.name === 'string' ? body.name : ''; + if (!ALLOWED_EVENTS.has(name)) return noContent; + + await recordVisitorEvent('CTA_CLICKED', await readRequestVisitor(request)); + + return noContent; +} diff --git a/app/api/onboarding/source/route.ts b/app/api/onboarding/source/route.ts new file mode 100644 index 0000000..fd1c811 --- /dev/null +++ b/app/api/onboarding/source/route.ts @@ -0,0 +1,54 @@ +import { NextRequest } from 'next/server'; +import { auth } from '@/lib/auth'; +import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; +import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit'; +import { setSelfReportedSource } from '@/lib/analytics/record'; +import { isAcquisitionChannel } from '@/lib/analytics/cookies'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; + +// "How did you hear about us?", answered on the first onboarding screen. +// +// It is stored beside the cookie-derived channel rather than instead of it. The +// cookie is precise but loses cross-device visits and cleared browsers; the +// answer survives both, and it is the only thing that can name a channel no UTM +// tag ever carries, like being told about it by a friend. +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session?.user?.id) { + return apiErrors.unauthorized(); + } + + // Keyed by account, like /api/onboarding/complete beside it. An IP key would + // be the wrong bucket twice over: without TRUSTED_PROXY_MODE every caller + // resolves to 127.0.0.1, so five answers an hour would be five for the whole + // deployment, and with it a shared office address would lock out everyone + // after one colleague answered. + const config = RATE_LIMIT_CONFIGS['onboarding-source']; + const limit = await checkRateLimit(session.user.id, 'onboarding-source', config); + if (!limit.allowed) { + return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), { + status: 429, + headers: { + 'Content-Type': 'application/json', + ...rateLimitHeaders(limit, config.maxRequests), + }, + }); + } + + if (!isProductAnalyticsEnabled()) { + return apiErrors.badRequest('Analytics are disabled by this host'); + } + + const body = await request.json().catch(() => null); + const source = body?.source; + if (!isAcquisitionChannel(source)) { + return apiErrors.badRequest('Unknown source'); + } + + const note = typeof body?.note === 'string' ? body.note : null; + + await setSelfReportedSource({ userId: session.user.id, selfReported: source, note }); + + const response = successResponse({ recorded: true }); + return withCacheControl(response, 'private, no-store'); +} diff --git a/app/api/projects/[projectId]/videos/[videoId]/share/route.ts b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts index 52d5fca..66d3971 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/share/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts @@ -8,6 +8,7 @@ import { db } from '@/lib/db'; import { rateLimit } from '@/lib/rate-limit'; import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links'; import { logError } from '@/lib/logger'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; @@ -141,7 +142,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } const { projectId, videoId } = await params; - const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id); + const { error, video } = await requireShareManagementAccess( + projectId, + videoId, + session.user.id + ); if (error) return error; const body = await request.json().catch(() => ({})); @@ -244,6 +249,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return apiErrors.internalError('Failed to create video share link'); } + // Keyed on the link id, so re-issuing the token for a link that already + // exists updates the row and records nothing: the share was created once. + await recordEvent({ + name: 'SHARE_LINK_CREATED', + dedupeKey: eventKey('SHARE_LINK_CREATED', link.id), + userId: video?.project.ownerId ?? null, + }); + const response = successResponse(serializeShareLink(request, videoId, link)); return withCacheControl(response, 'private, no-store'); diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts index a56fa73..cad3a12 100644 --- a/app/api/projects/[projectId]/videos/route.ts +++ b/app/api/projects/[projectId]/videos/route.ts @@ -8,6 +8,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token'; import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize'; import { logError } from '@/lib/logger'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; type RouteParams = { params: Promise<{ projectId: string }> }; @@ -268,6 +269,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) { }).catch((err) => logError('Notification failed:', err)); } + await recordEvent({ + name: 'VIDEO_ADDED', + dedupeKey: eventKey('VIDEO_ADDED', video.id), + userId: project.ownerId, + }); + const response = successResponse(video, 201); return withCacheControl(response, 'private, no-store'); } catch (error) { diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 3c28ae0..f35ad8d 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -7,6 +7,7 @@ import { buildBillingAccessWhereInput } from '@/lib/billing'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags'; import { logError } from '@/lib/logger'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; // GET /api/projects - List all projects for the authenticated user export async function GET(request: NextRequest) { @@ -187,6 +188,15 @@ export async function POST(request: NextRequest) { return createdProject; }); + // Attributed to the workspace owner rather than the caller: the funnel asks + // which account is progressing, and a team member creating a project moves + // the owner's account, not their own. + await recordEvent({ + name: 'PROJECT_CREATED', + dedupeKey: eventKey('PROJECT_CREATED', project.id), + userId: workspace.ownerId, + }); + const response = successResponse(project, 201); return withCacheControl(response, 'private, no-store'); } catch (error) { diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index cf889f7..b236f1b 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -13,6 +13,7 @@ import { getGuestIdentityFromRequest, setGuestIdentityCookie, } from '@/lib/guest-identity'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; import { extractImageFileNameFromProxyUrl, extractAudioFileNameFromProxyUrl, @@ -547,6 +548,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } } + // Feedback arriving from outside the team is the moment this product + // becomes worth paying for, so it is the activation step of the funnel. + // Keyed on the account, not the comment: what matters is the first time an + // account ever received one. + if (isGuest) { + await recordEvent({ + name: 'FIRST_GUEST_COMMENT', + dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId), + userId: project.workspace.ownerId, + }); + } + const viewerUserId = session?.user?.id ?? null; const viewerGuestIdentityId = viewerUserId ? null diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index 83de26f..79ba35d 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit'; import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { logError } from '@/lib/logger'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; // GET /api/workspaces - List all workspaces for the authenticated user export async function GET(request: NextRequest) { @@ -136,6 +137,12 @@ export async function POST(request: NextRequest) { }, }); + await recordEvent({ + name: 'WORKSPACE_CREATED', + dedupeKey: eventKey('WORKSPACE_CREATED', workspace.id), + userId: workspace.ownerId, + }); + const response = successResponse(workspace, 201); return withCacheControl(response, 'private, no-store'); } catch (error) { diff --git a/app/onboarding/onboarding-wizard.tsx b/app/onboarding/onboarding-wizard.tsx index c152b45..1c853e2 100644 --- a/app/onboarding/onboarding-wizard.tsx +++ b/app/onboarding/onboarding-wizard.tsx @@ -107,7 +107,45 @@ function ToggleButton({ // ─── Step 1: Welcome ─────────────────────────────────────────────────────────── -function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) { +// Asked here rather than on the registration form. The whole point of measuring +// this funnel is the signup conversion rate, and a question added to the form +// would move the number being measured. +const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: 'GITHUB', label: 'GitHub' }, + { value: 'YOUTUBE', label: 'YouTube' }, + { value: 'GOOGLE', label: 'A search engine' }, + { value: 'REVIEW_LINK', label: 'A review or comparison site' }, + { value: 'REFERRAL', label: 'Someone recommended it' }, + { value: 'COMMUNITY', label: 'Reddit, X, Discord or a forum' }, + { value: 'OUTBOUND', label: 'An email from us' }, + { value: 'OTHER', label: 'Somewhere else' }, +]; + +function StepWelcome({ + userName, + askSource, + onNext, +}: { + userName: string; + askSource: boolean; + onNext: () => void; +}) { + const [source, setSource] = useState(''); + const [note, setNote] = useState(''); + + const handleNext = () => { + // Never blocks the wizard. An unanswered or failed question costs one row in + // a cross-check column; a broken Get Started button costs the account. + if (askSource && source) { + void fetch('/api/onboarding/source', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source, note: source === 'OTHER' ? note : undefined }), + }).catch(() => undefined); + } + onNext(); + }; + return (
@@ -122,7 +160,36 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi manage versions, and streamline approvals — all in one place.

- @@ -691,10 +758,12 @@ export function OnboardingWizard({ userName, canCreateWorkspace, availableWorkspaces, + askAcquisitionSource, }: { userName: string; canCreateWorkspace: boolean; availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>; + askAcquisitionSource: boolean; }) { const router = useRouter(); const [currentStep, setCurrentStep] = useState(1); @@ -761,7 +830,9 @@ export function OnboardingWizard({ {/* Step content */} - {currentStep === 1 && } + {currentStep === 1 && ( + + )} {currentStep === 2 && ( ({ id: workspace.id, name: workspace.name, diff --git a/app/page.tsx b/app/page.tsx index c8e9008..7ddc5f0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,18 @@ +import { after } from 'next/server'; import { LandingPage } from '@/components/LandingPage'; import { auth } from '@/lib/auth'; +import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor'; export default async function HomePage() { const session = await auth(); + const isLoggedIn = Boolean(session?.user); - return ; + // Signed-in users land here too, and counting them would put existing + // customers at the top of the acquisition funnel. + if (!isLoggedIn) { + const visitor = await readPageVisitor(); + after(() => recordVisitorEvent('LANDING_VIEW', visitor)); + } + + return ; } diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx index f53c56f..4a0cb6d 100644 --- a/components/LandingPage.tsx +++ b/components/LandingPage.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; +import { CtaLink } from '@/components/marketing/cta-link'; import { MarketingCompareLinks } from '@/components/marketing/marketing-compare-links'; import { useEffect, useRef } from 'react'; import { gsap } from 'gsap'; @@ -258,13 +259,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) { data-hero-copy className="mx-auto flex max-w-md flex-col items-center justify-center gap-3" > - Start free trial - +

7-day free trial · Flat $10/mo — no per-seat fees · No client accounts @@ -717,12 +718,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) { Need more storage? Add 100 GB for $5/mo.

- Start free trial - +
{/* Card 2: Fair Source (Self-hosted) */} @@ -901,12 +902,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) { Your first review link takes minutes.

- Start free trial - + diff --git a/components/marketing/comparison-page.tsx b/components/marketing/comparison-page.tsx index 7c7a431..c99660a 100644 --- a/components/marketing/comparison-page.tsx +++ b/components/marketing/comparison-page.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import { ArrowRight, Github, MoveRight } from 'lucide-react'; +import { CtaLink } from '@/components/marketing/cta-link'; import { FeatureComparisonTable } from '@/components/marketing/feature-comparison-table'; import { MarketingFooter } from '@/components/marketing/marketing-footer'; import { MarketingHeader } from '@/components/marketing/marketing-header'; @@ -41,13 +42,13 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) { $10/month hosted plan covers your whole team and every client reviewer link.

- Start free trial - +
- Start free trial - + + {children} + + ); +} diff --git a/lib/analytics/billing-events.ts b/lib/analytics/billing-events.ts new file mode 100644 index 0000000..e7fc5f9 --- /dev/null +++ b/lib/analytics/billing-events.ts @@ -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 { + 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, + }); + } +} diff --git a/lib/analytics/bots.ts b/lib/analytics/bots.ts new file mode 100644 index 0000000..0bf06e3 --- /dev/null +++ b/lib/analytics/bots.ts @@ -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; +} diff --git a/lib/analytics/channel.ts b/lib/analytics/channel.ts new file mode 100644 index 0000000..0062865 --- /dev/null +++ b/lib/analytics/channel.ts @@ -0,0 +1,225 @@ +// 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; +} + +// What a URL path is allowed to be made of, per RFC 3986: unreserved characters, +// percent escapes, sub-delims and the separators. Everything a real route can +// carry, and nothing that survives being pasted into a page or a log line. +const LANDING_PATH_PATTERN = /^\/[A-Za-z0-9\-._~%!$&'()*+,;=:@/]*$/; + +/** + * Path only, no query string and no fragment, capped and character-checked. + * + * The proxy feeds this `request.nextUrl.pathname`, which is already a path. The + * cookie reader feeds it whatever the cookie said, which is why the allowlist is + * here rather than left to the caller: an unchecked value would put newlines and + * markup into a column that some later admin table renders. + */ +export function sanitizeLandingPath(pathname: string | null | undefined): string { + if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/'; + const path = (pathname.split('?')[0]?.split('#')[0] ?? '/').slice(0, MAX_PATH_LENGTH); + if (!path || !LANDING_PATH_PATTERN.test(path)) return '/'; + return path; +} + +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 = 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 = 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.` 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'; +} diff --git a/lib/analytics/cookies.ts b/lib/analytics/cookies.ts new file mode 100644 index 0000000..999035f --- /dev/null +++ b/lib/analytics/cookies.ts @@ -0,0 +1,180 @@ +// 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. +// +// Both are also signed. Nothing here trusts a cookie it did not issue: read +// through `readAnonymousIdCookie` and `readFirstTouchCookie`, never through the +// `decode` helpers, which are the unsigned inner layer. +// +// 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'; +import { signCookieValue, unsignCookieValue } from '@/lib/analytics/signing'; + +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); +} + +/** 128 bits from the Web Crypto API, which the edge has, as 32 base36 characters. */ +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; +} + +// base64url rather than encodeURIComponent, and not for compactness. Cookie +// values are percent-encoded on the way out and decoded on the way back, by +// several layers that do not all agree on how many times; a payload that already +// contains percent escapes comes back subtly different and takes the signature +// down with it. base64url has nothing either layer wants to touch. +function toBase64Url(text: string): string { + let binary = ''; + for (const byte of new TextEncoder().encode(text)) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function fromBase64Url(value: string): string | null { + try { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '=')); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return null; + } +} + +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 toBase64Url(JSON.stringify(payload)); +} + +/** + * Parses the cookie body back, re-sanitizing every field. + * + * The second line of defence, not the first: callers go through + * `readFirstTouchCookie`, which checks the signature before this ever runs. The + * re-sanitizing stays because a value that survives both checks can still be one + * this deployment signed a year ago, under an older set of rules. Anything that + * fails validation makes the whole value null, since a half-trusted first touch + * is worse than none. + */ +export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | null { + if (!raw) return null; + + const json = fromBase64Url(raw); + if (!json) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object') return null; + const value = parsed as Record; + + 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), + }; +} + +// --------------------------------------------------------------------------- +// The signed forms, which are the only ones anything outside this file uses. +// --------------------------------------------------------------------------- + +/** The cookie value to set, or null when there is no secret to sign it with. */ +export function signAnonymousId(anonymousId: string): Promise { + return signCookieValue(anonymousId); +} + +export function signFirstTouch(touch: FirstTouch): Promise { + return signCookieValue(encodeFirstTouch(touch)); +} + +/** + * The anonymous id this deployment issued, or null. + * + * Null covers every failure the same way: no cookie, a cookie signed with + * another key, one edited by hand, one whose id no longer matches the shape we + * mint. A visitor we cannot vouch for is not counted rather than counted wrong. + */ +export async function readAnonymousIdCookie( + raw: string | null | undefined +): Promise { + const anonymousId = await unsignCookieValue(raw); + return isValidAnonymousId(anonymousId) ? anonymousId : null; +} + +export async function readFirstTouchCookie( + raw: string | null | undefined +): Promise { + return decodeFirstTouch(await unsignCookieValue(raw)); +} diff --git a/lib/analytics/record.ts b/lib/analytics/record.ts new file mode 100644 index 0000000..cc73239 --- /dev/null +++ b/lib/analytics/record.ts @@ -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; +} + +/** `:`, for something that can only ever happen once per subject. */ +export function eventKey(name: AnalyticsEventName, subject: string): string { + return `${name}:${subject}`; +} + +/** + * `::`, 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/lib/analytics/scoreboard.ts b/lib/analytics/scoreboard.ts new file mode 100644 index 0000000..de29580 --- /dev/null +++ b/lib/analytics/scoreboard.ts @@ -0,0 +1,341 @@ +// 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; + +/** + * How many paid accounts the per-account table carries. + * + * The list is ordered quietest first, so the cap drops the accounts that are + * using the product most, which are the ones nobody needs to read a row about. + * It is reported rather than applied silently: a truncated table that looks + * complete is worse than a smaller one that says so. + */ +const PAID_ACCOUNT_LIMIT = 500; + +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[]; + /** True when there are more paid accounts than the table shows. */ + paidAccountsTruncated: boolean; + paidAccountLimit: number; + 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 = { + 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 { + 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` + 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` + 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>` + 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` + 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 + LIMIT ${PAID_ACCOUNT_LIMIT + 1} + `, + getCachedStripeStats(), + ]); + + const byWeek = new Map(); + 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(); + 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); + } + + // One row over the limit was fetched purely to tell "exactly full" from "cut off". + const paidAccountsTruncated = paidAccounts.length > PAID_ACCOUNT_LIMIT; + const accounts: PaidAccountRow[] = paidAccounts.slice(0, PAID_ACCOUNT_LIMIT).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, + paidAccountsTruncated, + paidAccountLimit: PAID_ACCOUNT_LIMIT, + atRisk: accounts.filter( + (account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore + ), + currentActivePaid: stripeStats?.activeSubscribers ?? null, + currentMrrCents: stripeStats?.mrrCents ?? null, + currency: stripeStats?.currency ?? 'usd', + }; +} diff --git a/lib/analytics/signing.ts b/lib/analytics/signing.ts new file mode 100644 index 0000000..02dcb8b --- /dev/null +++ b/lib/analytics/signing.ts @@ -0,0 +1,115 @@ +// Signing for the two acquisition cookies. +// +// httpOnly keeps JavaScript out of these cookies. It does nothing about curl, +// and both cookies are read straight into database columns, so without a +// signature the anonymous id is simply a string the caller picked. Picking one +// is enough to write a first-touch row for a visitor who never existed, or to +// claim another visitor's events at signup, since the backfill matches on the +// id alone. +// +// Web Crypto rather than node:crypto: this is imported by the proxy, which runs +// on the edge, and by the pages that read the cookies back, which run in Node. +// Both have crypto.subtle; only Node has createHmac. + +import { logWarn } from '@/lib/logger'; + +const SEPARATOR = '.'; + +/** + * 132 bits of an HMAC-SHA256, base64url. Truncating a MAC is standard practice + * and keeps a cookie that rides on every request small. + */ +const SIGNATURE_LENGTH = 22; + +let cachedSecret: string | null = null; +let cachedKey: Promise | null = null; +let warnedAboutMissingSecret = false; + +function readSecret(): string | null { + const secret = process.env.AUTH_SECRET?.trim() || process.env.NEXTAUTH_SECRET?.trim(); + if (secret) return secret; + + // Not thrown. The proxy runs on every request and the pages render for every + // visitor; failing those to protect a funnel chart would be the wrong trade. + // Analytics simply records nothing, which is visible on /admin/growth the same + // day, and it is announced once per process rather than per request. + if (!warnedAboutMissingSecret) { + warnedAboutMissingSecret = true; + logWarn( + 'AUTH_SECRET (or NEXTAUTH_SECRET) is not set, so acquisition cookies cannot be ' + + 'signed. Nothing will be recorded while it is missing.' + ); + } + return null; +} + +function getKey(secret: string): Promise { + if (!cachedKey || cachedSecret !== secret) { + cachedSecret = secret; + cachedKey = crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + } + return cachedKey; +} + +function toBase64Url(buffer: ArrayBuffer): string { + let binary = ''; + for (const byte of new Uint8Array(buffer)) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function macOf(value: string, secret: string): Promise { + const signature = await crypto.subtle.sign( + 'HMAC', + await getKey(secret), + new TextEncoder().encode(value) + ); + return toBase64Url(signature).slice(0, SIGNATURE_LENGTH); +} + +/** Constant time, so a forged cookie learns nothing from how long it took to reject. */ +function equals(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index += 1) { + difference |= a.charCodeAt(index) ^ b.charCodeAt(index); + } + return difference === 0; +} + +/** + * `.`. + * + * The MAC goes first and is fixed-length, so the split is a slice at a known + * offset rather than a search for a separator that a future payload might + * happen to contain. + * + * Returns null when there is no secret to sign with, which the callers treat as + * "set no cookie". + */ +export async function signCookieValue(value: string): Promise { + const secret = readSecret(); + if (!secret) return null; + return `${await macOf(value, secret)}${SEPARATOR}${value}`; +} + +/** The signed value back, or null if it was absent, truncated, or edited. */ +export async function unsignCookieValue(signed: string | null | undefined): Promise { + if (typeof signed !== 'string' || signed.length <= SIGNATURE_LENGTH + 1) return null; + + const secret = readSecret(); + if (!secret) return null; + + if (signed[SIGNATURE_LENGTH] !== SEPARATOR) return null; + const mac = signed.slice(0, SIGNATURE_LENGTH); + const value = signed.slice(SIGNATURE_LENGTH + 1); + + return equals(mac, await macOf(value, secret)) ? value : null; +} diff --git a/lib/analytics/signup.ts b/lib/analytics/signup.ts new file mode 100644 index 0000000..cbcc96c --- /dev/null +++ b/lib/analytics/signup.ts @@ -0,0 +1,46 @@ +// 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 { NO_VISITOR, readVisitorContext, type VisitorContext } from '@/lib/analytics/visitor'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; + +/** + * 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 { + if (!isProductAnalyticsEnabled()) return NO_VISITOR; + try { + const { cookies, headers } = await import('next/headers'); + return await readVisitorContext(await cookies(), await headers()); + } catch { + return NO_VISITOR; + } +} + +export async function recordSignupCompleted(params: { + userId: string; + visitor: VisitorContext; +}): Promise { + 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, + }); +} diff --git a/lib/analytics/visitor.ts b/lib/analytics/visitor.ts new file mode 100644 index 0000000..74f2fc2 --- /dev/null +++ b/lib/analytics/visitor.ts @@ -0,0 +1,159 @@ +// 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. +// +// Recording server-side also means an anonymous request can write rows, so the +// three filters that decide whether a request counts live here rather than only +// in the proxy: the signature on the cookie, the bot and prefetch checks, and a +// per-client ceiling. In the proxy they only govern which cookies get issued, +// which is not the same thing as which rows get written. + +import type { AnalyticsEventName } from '@prisma/client'; +import { + ANONYMOUS_ID_COOKIE, + FIRST_TOUCH_COOKIE, + readAnonymousIdCookie, + readFirstTouchCookie, + type FirstTouch, +} from '@/lib/analytics/cookies'; +import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots'; +import { dailyEventKey, recordEvent, recordFirstTouch } from '@/lib/analytics/record'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; +import { + RATE_LIMIT_CONFIGS, + checkRateLimit, + getClientIpFromHeaders, + isClientIpTrustworthy, +} from '@/lib/rate-limit'; + +/** 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; + /** Carried so the ceiling below can be applied after the response, not during it. */ + clientIp: string | null; +} + +export const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null, clientIp: null }; + +/** + * The visitor behind a set of cookies, or an empty context. + * + * Both cookies are verified, so an id that reaches a database column is one this + * deployment issued. A forged one is not repaired or partially trusted, it is + * simply not a visitor. + */ +export async function readVisitorContext( + store: AnalyticsCookieReader, + headers?: Headers +): Promise { + if (!isProductAnalyticsEnabled()) return NO_VISITOR; + + const anonymousId = await readAnonymousIdCookie(store.get(ANONYMOUS_ID_COOKIE)?.value); + if (!anonymousId) return NO_VISITOR; + + return { + anonymousId, + firstTouch: await readFirstTouchCookie(store.get(FIRST_TOUCH_COOKIE)?.value), + clientIp: headers ? getClientIpFromHeaders(headers) : null, + }; +} + +/** + * The visitor behind a page render. + * + * Applies the same two header checks the proxy does, because they mean different + * things in the two places. In the proxy they decide who gets a cookie; here they + * decide what counts. A returning visitor already holds a cookie, so without this + * Next prefetching /register as a CTA scrolls into view would record a signup + * start for a page nobody opened. + */ +export async function readPageVisitor(): Promise { + if (!isProductAnalyticsEnabled()) return NO_VISITOR; + + const { cookies, headers } = await import('next/headers'); + const requestHeaders = await headers(); + if (!isCountableDocumentRequest(requestHeaders)) return NO_VISITOR; + if (isLikelyBot(requestHeaders.get('user-agent'))) return NO_VISITOR; + + return readVisitorContext(await cookies(), requestHeaders); +} + +/** + * The visitor behind an API request. + * + * No document check: a beacon is `sec-fetch-dest: empty` by definition, and the + * routes that call this are reached by a form submission rather than by a + * navigation. + */ +export async function readRequestVisitor(request: { + cookies: AnalyticsCookieReader; + headers: Headers; +}): Promise { + if (!isProductAnalyticsEnabled()) return NO_VISITOR; + if (isLikelyBot(request.headers.get('user-agent'))) return NO_VISITOR; + return readVisitorContext(request.cookies, request.headers); +} + +const DIRECT_TOUCH: FirstTouch = { + channel: 'DIRECT', + utmSource: null, + utmMedium: null, + utmCampaign: null, + referrerHost: null, + landingPath: '/', +}; + +/** + * A ceiling on how many visitors one client can invent per hour. + * + * A fresh signed cookie is one request away: drop the cookie, ask for the + * landing page again, and the proxy mints another id. The signature stops a + * caller from choosing an id, and this stops them from collecting an unbounded + * number of real ones. Skipped when the client IP is not real, where the bucket + * would be shared by everybody and would throttle the site rather than the + * flood. + */ +async function withinVisitorCeiling(clientIp: string | null): Promise { + if (!clientIp || !isClientIpTrustworthy()) return true; + + const config = RATE_LIMIT_CONFIGS['analytics-visitor']; + const result = await checkRateLimit(clientIp, 'analytics-visitor', config); + return result.allowed; +} + +/** + * 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 { + if (!isProductAnalyticsEnabled()) return; + if (!visitor.anonymousId) return; + if (!(await withinVisitorCeiling(visitor.clientIp))) 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, + }); +} diff --git a/lib/auth.ts b/lib/auth.ts index 5f3880d..5d8dad5 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -150,6 +150,21 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ return token; }, }, + events: { + // The OAuth half of signup. Accounts created by Google or GitHub are written + // by the Prisma adapter and never reach app/api/auth/register, so recording + // the event only there would have made every social signup invisible in the + // funnel while looking like it worked. + async createUser({ user }) { + if (!user.id) return; + const { recordSignupCompleted, readVisitorContextFromHeaders } = + await import('@/lib/analytics/signup'); + await recordSignupCompleted({ + userId: user.id, + visitor: await readVisitorContextFromHeaders(), + }); + }, + }, }); // --------------------------------------------------------------------------- diff --git a/lib/billing.ts b/lib/billing.ts index 082001e..3b67130 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -4,6 +4,7 @@ import { BillingSubscriptionStatus } from '@prisma/client'; import { db } from '@/lib/db'; import { getStripe, getStripePriceId } from '@/lib/stripe'; import { isStripeFeatureEnabled } from '@/lib/feature-flags'; +import { recordSubscriptionTransition } from '@/lib/analytics/billing-events'; const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ BillingSubscriptionStatus.ACTIVE, @@ -394,6 +395,10 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip select: { id: true, billingTrialConsumedAt: true, + // Read for the funnel: the transition is what gets recorded, so the state + // being overwritten has to be captured before the update below. + subscriptionStatus: true, + stripeCancelAtPeriodEnd: true, }, }); @@ -430,7 +435,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip (hasActiveSubscription(mappedStatus) || Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now())); - return db.user.update({ + const updated = await db.user.update({ where: { id: user.id }, data: { stripeSubscriptionId: subscription.id, @@ -449,6 +454,24 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip : getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null), }, }); + + await recordSubscriptionTransition({ + userId: user.id, + subscriptionId: subscription.id, + before: { + status: user.subscriptionStatus, + cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd, + hadTrial: user.billingTrialConsumedAt !== null, + }, + after: { + status: mappedStatus, + cancelAtPeriodEnd, + trialEndsAt: effectiveTrialEnd, + currentPeriodEnd: effectiveCurrentPeriodEnd, + }, + }); + + return updated; } // A single Stripe customer can own several subscriptions at once (e.g. after @@ -524,14 +547,21 @@ export async function markSubscriptionCanceledByCustomerId( ) { const user = await db.user.findUnique({ where: { stripeCustomerId: customerId }, - select: { id: true }, + select: { + id: true, + subscriptionStatus: true, + stripeSubscriptionId: true, + stripeCancelAtPeriodEnd: true, + stripeCurrentPeriodEnd: true, + billingTrialConsumedAt: true, + }, }); if (!user) { return null; } - return db.user.update({ + const updated = await db.user.update({ where: { id: user.id }, data: { subscriptionStatus: BillingSubscriptionStatus.CANCELED, @@ -544,4 +574,26 @@ export async function markSubscriptionCanceledByCustomerId( billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(), }, }); + + // Reached when the customer has no subscriptions left at all. The cycle marker + // uses the period end being cleared here, which is the same one the earlier + // "cancel at period end" write carried, so a customer who cancelled through the + // portal and then reached the end of their term produces one cancellation, not two. + await recordSubscriptionTransition({ + userId: user.id, + subscriptionId: user.stripeSubscriptionId ?? user.id, + before: { + status: user.subscriptionStatus, + cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd, + hadTrial: user.billingTrialConsumedAt !== null, + }, + after: { + status: BillingSubscriptionStatus.CANCELED, + cancelAtPeriodEnd: false, + trialEndsAt: null, + currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null, + }, + }); + + return updated; } diff --git a/lib/client/track.ts b/lib/client/track.ts new file mode 100644 index 0000000..88ae685 --- /dev/null +++ b/lib/client/track.ts @@ -0,0 +1,31 @@ +/** + * Reports a click on a call to action. + * + * `sendBeacon` rather than a plain fetch because a CTA click navigates away + * immediately, and a request still in flight when the page unloads is cancelled. + * + * Everything here is best effort. This is the only funnel event that depends on + * the browser cooperating, which is also why nothing downstream divides by it: + * the visitor and signup counts either side of it are recorded server-side. + */ +export function trackCtaClick(): void { + if (typeof navigator === 'undefined') return; + + const payload = JSON.stringify({ name: 'cta_clicked' }); + + try { + if (typeof navigator.sendBeacon === 'function') { + navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' })); + return; + } + + void fetch('/api/events', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: payload, + keepalive: true, + }).catch(() => undefined); + } catch { + // A click must never fail because measurement did. + } +} diff --git a/lib/email-verification.ts b/lib/email-verification.ts index 875d6ff..a8c6b8c 100644 --- a/lib/email-verification.ts +++ b/lib/email-verification.ts @@ -9,6 +9,8 @@ import { EMAIL_COLORS, } from '@/lib/email-brand'; import { logError } from '@/lib/logger'; +import { eventKey, recordEvent } from '@/lib/analytics/record'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; // Reduce window to 2 hours — shorter exposure in access logs and backups. const TOKEN_EXPIRY_HOURS = 2; @@ -77,6 +79,23 @@ export async function consumeVerificationToken(token: string): Promise 0 above already means this is the one call + // that flipped the account, so a replayed link cannot reach here. + if (isProductAnalyticsEnabled()) { + const verified = await db.user.findUnique({ + where: { email: record.identifier }, + select: { id: true }, + }); + if (verified) { + await recordEvent({ + name: 'EMAIL_VERIFIED', + dedupeKey: eventKey('EMAIL_VERIFIED', verified.id), + userId: verified.id, + }); + } + } + return record.identifier; } diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts index 00c1eb3..9bea836 100644 --- a/lib/feature-flags.ts +++ b/lib/feature-flags.ts @@ -98,6 +98,16 @@ export function isInviteCodeRequired() { return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true); } +// Acquisition attribution and funnel events. Defaults to OFF, unlike the other +// product flags here, because the cost of the two mistakes is not symmetric: a +// hosted instance that forgets to switch it on shows an empty growth page and is +// noticed the same day, while a self-hosted instance that gets it silently +// switched on accumulates rows nobody asked for. Nothing is ever sent off the +// instance either way, so this is about cost, not disclosure. +export function isProductAnalyticsEnabled() { + return readBooleanEnv('OPENFRAME_ENABLE_ANALYTICS', false); +} + function parseBigIntEnv(name: string, defaultValue: bigint, minValue?: bigint): bigint { const raw = process.env[name]?.trim(); if (!raw) return defaultValue; diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 37017e6..b8e7bda 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -90,8 +90,11 @@ export const RATE_LIMIT_CONFIGS: Record = { 'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link) 'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour - // Onboarding — one-time action, very strict + // Onboarding — one-time action, very strict. Both are keyed by user id, not IP: + // an office behind one address must not be able to lock its colleagues out of + // finishing onboarding. 'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour + 'onboarding-source': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour // Member management 'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour @@ -105,6 +108,16 @@ export const RATE_LIMIT_CONFIGS: Record = { // Mutations (update/delete) — moderate mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute + // Analytics beacon — anonymous and public, so bound it per IP + 'analytics-beacon': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour + + // Anonymous visitor events recorded server-side from the landing pages. Bounds + // a flood that would otherwise write two rows per request forever, and is + // deliberately generous: these are the denominator of every rate on the + // scoreboard, so a limit that bites real traffic costs more than the flood it + // stops. Only applied when the client IP is real — see isClientIpTrustworthy. + 'analytics-visitor': { windowMs: 60 * 60 * 1000, maxRequests: 240 }, // 240 per hour + // General reads — generous api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute }; @@ -261,6 +274,20 @@ export function getClientIpFromHeaders(headers: Headers): string { return '127.0.0.1'; } +/** + * Whether {@link getClientIp} resolves to the caller rather than to 127.0.0.1. + * + * Without TRUSTED_PROXY_MODE every request shares one bucket. That is a usable + * global brake on an endpoint nobody hits in a loop, and useless on a landing + * page: the bucket would empty on real traffic long before it emptied on an + * attacker, and the counting this whole subsystem exists for would stop. Callers + * that only make sense per-client check this first. + */ +export function isClientIpTrustworthy(): boolean { + const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase(); + return mode === 'cloudflare' || mode === 'nginx'; +} + /** * Create rate limit headers for response */ diff --git a/prisma/migrations/20260801120000_add_acquisition_analytics/migration.sql b/prisma/migrations/20260801120000_add_acquisition_analytics/migration.sql new file mode 100644 index 0000000..ebffb2d --- /dev/null +++ b/prisma/migrations/20260801120000_add_acquisition_analytics/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8fc215b..b1ccefb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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,119 @@ 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. +// +// 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. The one free-text column is user_acquisitions.self_reported_note, which +// holds up to 200 characters the account typed into the onboarding question. + +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 { diff --git a/proxy.ts b/proxy.ts index 52d5ac4..a99d5a0 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,9 +1,86 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { buildContentSecurityPolicy } from '@/lib/content-security-policy'; +import { + classifyChannel, + extractReferrerHost, + sanitizeLandingPath, + sanitizeTag, +} from '@/lib/analytics/channel'; +import { + ANONYMOUS_ID_COOKIE, + ANONYMOUS_ID_MAX_AGE_SECONDS, + FIRST_TOUCH_COOKIE, + generateAnonymousId, + readAnonymousIdCookie, + signAnonymousId, + signFirstTouch, +} from '@/lib/analytics/cookies'; +import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots'; +import { isProductAnalyticsEnabled } from '@/lib/feature-flags'; +import { getPublicOrigin } from '@/lib/request-origin'; -export function proxy() { - const response = NextResponse.next(); +// Runs on the edge, so nothing here touches the database. It only decides who a +// visitor is and what brought them, then hands both downstream as signed +// cookies. The rows are written by the pages, which run in Node. +async function applyAcquisitionCookies( + request: NextRequest, + response: NextResponse +): Promise { + if (!isProductAnalyticsEnabled()) return; + if (!isCountableDocumentRequest(request.headers)) return; + if (isLikelyBot(request.headers.get('user-agent'))) return; + + const cookieOptions = { + httpOnly: true, + sameSite: 'lax' as const, + // Not `request.nextUrl.protocol`. Behind a TLS-terminating reverse proxy, + // which is the deployment shape the README documents, that is the + // container-internal `http://localhost:3000` and the flag would silently + // come off in exactly the setup that needs it. + secure: getPublicOrigin(request).startsWith('https:'), + path: '/', + maxAge: ANONYMOUS_ID_MAX_AGE_SECONDS, + }; + + const existingId = await readAnonymousIdCookie(request.cookies.get(ANONYMOUS_ID_COOKIE)?.value); + if (!existingId) { + const signedId = await signAnonymousId(generateAnonymousId()); + if (!signedId) return; + // Set on the request as well as the response: without this the page rendering + // *this* request cannot see the id, and the first landing view of every new + // visitor, the one carrying the campaign tags, goes unrecorded. + request.cookies.set(ANONYMOUS_ID_COOKIE, signedId); + response.cookies.set(ANONYMOUS_ID_COOKIE, signedId, cookieOptions); + } + + if (request.cookies.get(FIRST_TOUCH_COOKIE)) return; + + const params = request.nextUrl.searchParams; + const referrerHost = extractReferrerHost( + request.headers.get('referer'), + request.nextUrl.hostname + ); + const utmSource = sanitizeTag(params.get('utm_source')); + const utmMedium = sanitizeTag(params.get('utm_medium')); + + const firstTouch = await signFirstTouch({ + channel: classifyChannel({ utmSource, utmMedium, referrerHost }), + utmSource, + utmMedium, + utmCampaign: sanitizeTag(params.get('utm_campaign')), + referrerHost, + landingPath: sanitizeLandingPath(request.nextUrl.pathname), + }); + if (!firstTouch) return; + + request.cookies.set(FIRST_TOUCH_COOKIE, firstTouch); + response.cookies.set(FIRST_TOUCH_COOKIE, firstTouch, cookieOptions); +} + +export async function proxy(request: NextRequest) { + const response = NextResponse.next({ request }); response.headers.set('Content-Security-Policy', buildContentSecurityPolicy()); + await applyAcquisitionCookies(request, response); return response; } diff --git a/tests/api/analytics-events.test.ts b/tests/api/analytics-events.test.ts new file mode 100644 index 0000000..c14f10c --- /dev/null +++ b/tests/api/analytics-events.test.ts @@ -0,0 +1,417 @@ +// The property the whole acquisition system rests on: every funnel event is +// recorded exactly once, against the right account, and nothing at all is +// recorded when the feature flag is off. +// +// The dedupe key is a UNIQUE column, so these tests are checking that each call +// site derives the right key. A key that varies when it should not shows up here +// as a duplicated row, which is the failure that would quietly inflate the +// scoreboard. + +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { db } from '@/lib/db'; +import { POST as beacon } from '@/app/api/events/route'; +import { POST as register } from '@/app/api/auth/register/route'; +import { recordSubscriptionTransition } from '@/lib/analytics/billing-events'; +import { recordSignupCompleted } from '@/lib/analytics/signup'; +import { signAnonymousId, signFirstTouch, type FirstTouch } from '@/lib/analytics/cookies'; +import { apiRequest, callRoute } from '../helpers/request'; +import { signedOut } from '../helpers/session'; +import { createUser } from '../factories'; + +const ANON_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; +const INVITE_CODE = 'test-invite'; +const ORIGIN = 'http://localhost:3000'; +const SECRET = 'analytics-test-secret'; +const BROWSER_UA = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36'; + +const GITHUB_TOUCH: FirstTouch = { + channel: 'GITHUB', + utmSource: 'github', + utmMedium: 'readme', + utmCampaign: null, + referrerHost: 'github.com', + landingPath: '/', +}; + +/** What the proxy would have set. Signed, because nothing downstream trusts anything else. */ +async function visitorCookies(anonymousId = ANON_ID, touch: FirstTouch = GITHUB_TOUCH) { + return { + of_aid: (await signAnonymousId(anonymousId)) ?? '', + of_ft: (await signFirstTouch(touch)) ?? '', + }; +} + +async function beaconRequest(options?: { + name?: string; + origin?: string | null; + userAgent?: string | null; + cookies?: Record; +}) { + const headers: Record = {}; + const origin = options?.origin === undefined ? ORIGIN : options.origin; + if (origin) headers.origin = origin; + const userAgent = options?.userAgent === undefined ? BROWSER_UA : options.userAgent; + if (userAgent) headers['user-agent'] = userAgent; + + return apiRequest('/api/events', { + body: { name: options?.name ?? 'cta_clicked' }, + headers, + cookies: options?.cookies ?? (await visitorCookies()), + }); +} + +async function eventNames(): Promise { + const rows = await db.analyticsEvent.findMany({ orderBy: { dedupeKey: 'asc' } }); + return rows.map((row) => row.name); +} + +beforeEach(() => { + signedOut(); + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + vi.stubEnv('NEXTAUTH_SECRET', SECRET); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('POST /api/events', () => { + it('records a CTA click and the first touch behind it', async () => { + const response = await callRoute(beacon, await beaconRequest()); + + expect(response.status).toBe(204); + + const events = await db.analyticsEvent.findMany(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + name: 'CTA_CLICKED', + anonymousId: ANON_ID, + channel: 'GITHUB', + userId: null, + }); + + const touches = await db.acquisitionTouch.findMany(); + expect(touches).toHaveLength(1); + expect(touches[0]).toMatchObject({ + anonymousId: ANON_ID, + channel: 'GITHUB', + utmSource: 'github', + referrerHost: 'github.com', + }); + }); + + it('records one event however many times the same visitor clicks', async () => { + await callRoute(beacon, await beaconRequest()); + await callRoute(beacon, await beaconRequest()); + await callRoute(beacon, await beaconRequest()); + + expect(await db.analyticsEvent.count()).toBe(1); + }); + + it('counts two different visitors separately', async () => { + await callRoute(beacon, await beaconRequest()); + await callRoute( + beacon, + await beaconRequest({ cookies: await visitorCookies('f0e1d2c3b4a596877869504132231415') }) + ); + + expect(await db.analyticsEvent.count()).toBe(2); + }); + + it('never keeps the first touch of a visitor who came back through another link', async () => { + await callRoute(beacon, await beaconRequest()); + await callRoute( + beacon, + await beaconRequest({ + cookies: await visitorCookies(ANON_ID, { + ...GITHUB_TOUCH, + channel: 'GOOGLE', + utmSource: null, + }), + }) + ); + + const touches = await db.acquisitionTouch.findMany(); + expect(touches).toHaveLength(1); + expect(touches[0]?.channel).toBe('GITHUB'); + }); + + it('refuses to record an event name the beacon does not own', async () => { + // Without this the endpoint would let any anonymous caller write a payment + // into the funnel. + for (const name of ['subscription_started', 'signup_completed', 'SUBSCRIPTION_STARTED', '']) { + const response = await callRoute(beacon, await beaconRequest({ name })); + expect(response.status, name).toBe(204); + } + + expect(await db.analyticsEvent.count()).toBe(0); + }); + + it('ignores a cross-origin caller', async () => { + const response = await callRoute( + beacon, + await beaconRequest({ origin: 'https://evil.example' }) + ); + + expect(response.status).toBe(204); + expect(await db.analyticsEvent.count()).toBe(0); + }); + + it('ignores a caller with no anonymous id cookie', async () => { + await callRoute(beacon, await beaconRequest({ cookies: {} })); + + expect(await db.analyticsEvent.count()).toBe(0); + expect(await db.acquisitionTouch.count()).toBe(0); + }); + + it('ignores a hand-written cookie, whatever channel it claims', async () => { + // httpOnly stops JavaScript, not curl. Without the signature this is a + // visitor of the caller's choosing, filed under a channel of their choosing, + // and every row on the scoreboard is theirs to write. + await callRoute( + beacon, + await beaconRequest({ + cookies: { + of_aid: 'deadbeefdeadbeefdeadbeefdeadbeef', + of_ft: encodeURIComponent(JSON.stringify({ c: 'GITHUB', p: '/' })), + }, + }) + ); + + expect(await db.analyticsEvent.count()).toBe(0); + expect(await db.acquisitionTouch.count()).toBe(0); + }); + + it('ignores a cookie signed by another deployment', async () => { + const cookies = await visitorCookies(); + vi.stubEnv('NEXTAUTH_SECRET', 'some-other-secret'); + + await callRoute(beacon, await beaconRequest({ cookies })); + + expect(await db.analyticsEvent.count()).toBe(0); + }); + + it('ignores a script that sends no user agent', async () => { + await callRoute(beacon, await beaconRequest({ userAgent: null })); + + expect(await db.analyticsEvent.count()).toBe(0); + }); + + it('writes nothing at all when the flag is off', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false'); + + const response = await callRoute(beacon, await beaconRequest()); + + expect(response.status).toBe(204); + expect(await db.analyticsEvent.count()).toBe(0); + expect(await db.acquisitionTouch.count()).toBe(0); + }); +}); + +describe('signup attribution', () => { + async function registerWithCookies(email: string) { + return callRoute( + register, + apiRequest('/api/auth/register', { + body: { + name: 'New User', + email, + password: 'correct horse battery', + inviteCode: INVITE_CODE, + }, + headers: { 'user-agent': BROWSER_UA }, + cookies: await visitorCookies(), + }) + ); + } + + it('copies the first touch onto the account and records the signup once', async () => { + const response = await registerWithCookies('attributed@example.com'); + expect(response.status).toBe(201); + + const user = await db.user.findUniqueOrThrow({ where: { email: 'attributed@example.com' } }); + const acquisition = await db.userAcquisition.findUniqueOrThrow({ + where: { userId: user.id }, + }); + + expect(acquisition).toMatchObject({ + channel: 'GITHUB', + utmSource: 'github', + utmMedium: 'readme', + referrerHost: 'github.com', + anonymousId: ANON_ID, + }); + + const signups = await db.analyticsEvent.findMany({ where: { name: 'SIGNUP_COMPLETED' } }); + expect(signups).toHaveLength(1); + expect(signups[0]?.userId).toBe(user.id); + }); + + it('claims the events the visitor produced before they had an account', async () => { + await callRoute(beacon, await beaconRequest()); + await registerWithCookies('backfilled@example.com'); + + const user = await db.user.findUniqueOrThrow({ where: { email: 'backfilled@example.com' } }); + const click = await db.analyticsEvent.findFirstOrThrow({ where: { name: 'CTA_CLICKED' } }); + + // Without the backfill the click and the signup are two unrelated rows and + // no query can tell you which channel converted. + expect(click.userId).toBe(user.id); + }); + + it('records one signup even if the helper runs twice', async () => { + const user = await createUser(); + + await recordSignupCompleted({ + userId: user.id, + visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null }, + }); + await recordSignupCompleted({ + userId: user.id, + visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null }, + }); + + expect(await db.analyticsEvent.count({ where: { name: 'SIGNUP_COMPLETED' } })).toBe(1); + expect(await db.userAcquisition.count({ where: { userId: user.id } })).toBe(1); + }); + + it('records no acquisition row when the flag is off', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false'); + + const response = await registerWithCookies('unmeasured@example.com'); + + expect(response.status).toBe(201); + expect(await db.userAcquisition.count()).toBe(0); + expect(await db.analyticsEvent.count()).toBe(0); + }); +}); + +describe('subscription transitions', () => { + const SUB = 'sub_test_1'; + const periodEnd = new Date('2026-09-01T00:00:00.000Z'); + + async function transition(params: { + userId: string; + beforeStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED'; + afterStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED'; + beforeCancelAtPeriodEnd?: boolean; + afterCancelAtPeriodEnd?: boolean; + hadTrial?: boolean; + trialEndsAt?: Date | null; + }) { + await recordSubscriptionTransition({ + userId: params.userId, + subscriptionId: SUB, + before: { + status: params.beforeStatus, + cancelAtPeriodEnd: params.beforeCancelAtPeriodEnd ?? false, + hadTrial: params.hadTrial ?? false, + }, + after: { + status: params.afterStatus, + cancelAtPeriodEnd: params.afterCancelAtPeriodEnd ?? false, + trialEndsAt: params.trialEndsAt ?? null, + currentPeriodEnd: periodEnd, + }, + }); + } + + it('records the trial once and the conversion to paid once', async () => { + const user = await createUser(); + + await transition({ + userId: user.id, + beforeStatus: 'FREE', + afterStatus: 'TRIALING', + trialEndsAt: new Date('2026-08-15T00:00:00.000Z'), + }); + // The same webhook arriving again, which Stripe does routinely. + await transition({ + userId: user.id, + beforeStatus: 'FREE', + afterStatus: 'TRIALING', + trialEndsAt: new Date('2026-08-15T00:00:00.000Z'), + }); + await transition({ + userId: user.id, + beforeStatus: 'TRIALING', + afterStatus: 'ACTIVE', + hadTrial: true, + }); + + expect(await eventNames()).toEqual(['SUBSCRIPTION_STARTED', 'TRIAL_STARTED']); + }); + + it('does not record a second trial for an account that already had one', async () => { + const user = await createUser(); + + await transition({ + userId: user.id, + beforeStatus: 'CANCELED', + afterStatus: 'TRIALING', + hadTrial: true, + trialEndsAt: new Date('2026-08-15T00:00:00.000Z'), + }); + + expect(await eventNames()).toEqual([]); + }); + + it('counts one cancellation for the flag and the status that follow each other', async () => { + const user = await createUser(); + + // The customer cancels in the portal: cancel_at_period_end flips on. + await transition({ + userId: user.id, + beforeStatus: 'ACTIVE', + afterStatus: 'ACTIVE', + afterCancelAtPeriodEnd: true, + }); + // The term ends weeks later and Stripe marks the subscription canceled. + await transition({ + userId: user.id, + beforeStatus: 'ACTIVE', + afterStatus: 'CANCELED', + beforeCancelAtPeriodEnd: true, + }); + + expect(await db.analyticsEvent.count({ where: { name: 'SUBSCRIPTION_CANCELED' } })).toBe(1); + }); + + it('records a reactivation when the customer changes their mind', async () => { + const user = await createUser(); + + await transition({ + userId: user.id, + beforeStatus: 'ACTIVE', + afterStatus: 'ACTIVE', + afterCancelAtPeriodEnd: true, + }); + await transition({ + userId: user.id, + beforeStatus: 'ACTIVE', + afterStatus: 'ACTIVE', + beforeCancelAtPeriodEnd: true, + afterCancelAtPeriodEnd: false, + }); + + expect(await eventNames()).toEqual(['SUBSCRIPTION_CANCELED', 'SUBSCRIPTION_REACTIVATED']); + }); + + it('records nothing when nothing changed', async () => { + const user = await createUser(); + + await transition({ userId: user.id, beforeStatus: 'ACTIVE', afterStatus: 'ACTIVE' }); + + expect(await eventNames()).toEqual([]); + }); + + it('writes nothing when the flag is off', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false'); + const user = await createUser(); + + await transition({ userId: user.id, beforeStatus: 'FREE', afterStatus: 'ACTIVE' }); + + expect(await db.analyticsEvent.count()).toBe(0); + }); +}); diff --git a/tests/api/analytics-scoreboard.test.ts b/tests/api/analytics-scoreboard.test.ts new file mode 100644 index 0000000..d128614 --- /dev/null +++ b/tests/api/analytics-scoreboard.test.ts @@ -0,0 +1,155 @@ +// Exercises the scoreboard queries against a real database. +// +// These are raw SQL: a date_trunc grouping, a COALESCE across two tables and a +// filtered left join. None of that is checked by the type system, so a seeded +// week with known counts is the only thing standing between a renamed column and +// a growth page that renders zeros forever. + +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client'; +import { db } from '@/lib/db'; +import { AT_RISK_SILENT_DAYS, getScoreboard } from '@/lib/analytics/scoreboard'; +import { createUser } from '../factories'; + +function daysAgo(days: number): Date { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - days); + return date; +} + +let sequence = 0; + +async function seedEvent(params: { + name: AnalyticsEventName; + occurredAt: Date; + userId?: string; + anonymousId?: string; + channel?: AcquisitionChannel; +}) { + sequence += 1; + await db.analyticsEvent.create({ + data: { + name: params.name, + dedupeKey: `${params.name}:seed-${sequence}`, + occurredAt: params.occurredAt, + userId: params.userId ?? null, + anonymousId: params.anonymousId ?? null, + channel: params.channel ?? null, + }, + }); +} + +beforeEach(() => { + sequence = 0; + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getScoreboard', () => { + it('returns an empty week for every week in the window when nothing happened', async () => { + const scoreboard = await getScoreboard({ weeks: 4 }); + + expect(scoreboard.weeks).toHaveLength(4); + expect(scoreboard.weeks.every((week) => week.visitors === 0)).toBe(true); + expect(scoreboard.channels).toEqual([]); + expect(scoreboard.paidAccounts).toEqual([]); + }); + + it('counts a returning visitor once per week, not once per visit', async () => { + // Landing views are deduped per visitor per day, so the same person on three + // days is three rows. Weekly visitors is a distinct count over the id. + for (const days of [1, 2, 3]) { + await seedEvent({ + name: 'LANDING_VIEW', + occurredAt: daysAgo(days), + anonymousId: 'visitor-one', + channel: 'GITHUB', + }); + } + await seedEvent({ + name: 'LANDING_VIEW', + occurredAt: daysAgo(1), + anonymousId: 'visitor-two', + channel: 'GOOGLE', + }); + + const scoreboard = await getScoreboard({ weeks: 2 }); + const total = scoreboard.weeks.reduce((sum, week) => sum + week.visitors, 0); + + expect(total).toBe(2); + }); + + it('reads a signed-up visitor through the channel on their account', async () => { + const user = await createUser(); + await db.userAcquisition.create({ + data: { userId: user.id, channel: 'YOUTUBE', anonymousId: 'visitor-three' }, + }); + + // The visitor event carries GITHUB from the cookie, but the account says + // YouTube. The account wins, so correcting a channel corrects its history. + await seedEvent({ + name: 'LANDING_VIEW', + occurredAt: daysAgo(2), + anonymousId: 'visitor-three', + channel: 'GITHUB', + userId: user.id, + }); + await seedEvent({ + name: 'SIGNUP_COMPLETED', + occurredAt: daysAgo(2), + userId: user.id, + anonymousId: 'visitor-three', + }); + + const scoreboard = await getScoreboard({ weeks: 2 }); + const youtube = scoreboard.channels.find((row) => row.channel === 'YOUTUBE'); + + expect(youtube).toMatchObject({ visitors: 1, signups: 1 }); + expect(scoreboard.channels.find((row) => row.channel === 'GITHUB')).toBeUndefined(); + }); + + it('carries subscriptions started before the window into the running total', async () => { + await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: daysAgo(120) }); + await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: daysAgo(3) }); + await seedEvent({ name: 'SUBSCRIPTION_CANCELED', occurredAt: daysAgo(3) }); + + const scoreboard = await getScoreboard({ weeks: 2 }); + const last = scoreboard.weeks[scoreboard.weeks.length - 1]; + + // One from before the window, plus one started and one canceled inside it. + expect(last?.activePaid).toBe(1); + expect(last?.newPaid).toBe(1); + expect(last?.canceled).toBe(1); + }); + + it('flags a paid account that has produced nothing recently', async () => { + const busy = await createUser({ subscriptionStatus: 'ACTIVE' }); + const silent = await createUser({ subscriptionStatus: 'ACTIVE' }); + const trialing = await createUser({ subscriptionStatus: 'TRIALING' }); + await createUser({ subscriptionStatus: 'FREE' }); + + await seedEvent({ name: 'VIDEO_ADDED', occurredAt: daysAgo(2), userId: busy.id }); + await seedEvent({ name: 'SHARE_LINK_CREATED', occurredAt: daysAgo(20), userId: busy.id }); + await seedEvent({ + name: 'VIDEO_ADDED', + occurredAt: daysAgo(AT_RISK_SILENT_DAYS + 5), + userId: silent.id, + }); + // A signup is not a value event, so it must not clear the risk flag. + await seedEvent({ name: 'SIGNUP_COMPLETED', occurredAt: daysAgo(1), userId: trialing.id }); + + const scoreboard = await getScoreboard({ weeks: 4 }); + const ids = scoreboard.paidAccounts.map((row) => row.userId).sort(); + const atRisk = scoreboard.atRisk.map((row) => row.userId).sort(); + + expect(ids).toEqual([busy.id, silent.id, trialing.id].sort()); + expect(atRisk).toEqual([silent.id, trialing.id].sort()); + + const busyRow = scoreboard.paidAccounts.find((row) => row.userId === busy.id); + expect(busyRow?.valueEvents7).toBe(1); + expect(busyRow?.valueEvents30).toBe(2); + }); +}); diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts index 14fa3cd..6be06e2 100644 --- a/tests/api/auth-matrix.test.ts +++ b/tests/api/auth-matrix.test.ts @@ -44,6 +44,7 @@ import { } from '../factories'; import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route'; +import * as adminGrowthRoute from '@/app/api/admin/growth/route'; import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route'; import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route'; import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route'; @@ -54,6 +55,7 @@ import * as commentRoute from '@/app/api/comments/[commentId]/route'; import * as feedbackRoute from '@/app/api/feedback/route'; import * as feedbackUploadRoute from '@/app/api/feedback/upload/route'; import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route'; +import * as onboardingSourceRoute from '@/app/api/onboarding/source/route'; import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route'; import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route'; import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route'; @@ -143,7 +145,7 @@ vi.mock('@/lib/r2', async (importOriginal) => { // The count guard // --------------------------------------------------------------------------- // Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES. -const EXPECTED_ROUTE_MODULE_COUNT = 60; +const EXPECTED_ROUTE_MODULE_COUNT = 63; /** * Routes that are public by design, and why. Everything else must reject an @@ -176,6 +178,15 @@ const PUBLIC_ROUTES: ReadonlyMap = new Map([ // so it cannot be used to enumerate accounts. 'resend of the verification email, for users who cannot sign in yet', ], + [ + 'events/route.ts', + // The CTA-click beacon. Its whole job is to hear from visitors who have no + // account yet, so a session cannot be the guard. It is bounded three ways + // instead: same-origin only, IP rate limited, and it accepts exactly one + // event name, so nothing a caller sends can forge a signup or a payment. + // Covered in tests/api/analytics-events.test.ts. + 'anonymous CTA beacon, restricted to one event name and to same-origin callers', + ], [ 'stripe/webhook/route.ts', // Called by Stripe, not by a browser. Authenticated by the HMAC signature @@ -350,6 +361,11 @@ const ROUTE_CASES: readonly RouteCase[] = [ url: (f) => `/api/admin/feedback/${f.feedbackId}`, params: (f) => ({ feedbackId: f.feedbackId }), }, + { + file: 'admin/growth/route.ts', + module: adminGrowthRoute, + url: () => '/api/admin/growth', + }, { file: 'admin/stats/refresh-r2/route.ts', module: adminRefreshR2Route, @@ -405,6 +421,12 @@ const ROUTE_CASES: readonly RouteCase[] = [ module: onboardingCompleteRoute, url: () => '/api/onboarding/complete', }, + { + file: 'onboarding/source/route.ts', + module: onboardingSourceRoute, + url: () => '/api/onboarding/source', + body: { source: 'GITHUB' }, + }, { file: 'projects/[projectId]/approval-candidates/route.ts', module: approvalCandidatesRoute, diff --git a/tests/setup/db-global.ts b/tests/setup/db-global.ts index 3df05c5..ce68aed 100644 --- a/tests/setup/db-global.ts +++ b/tests/setup/db-global.ts @@ -66,6 +66,7 @@ const REVIEWED_MIGRATIONS = [ '20260613120000_add_r2_video_asset_provider', '20260614160000_add_project_allow_downloads', '20260627140000_add_video_upload_multipart_id', + '20260801120000_add_acquisition_analytics', ]; /** Objects POST_PUSH_SQL must have produced. Verified after it runs. */ diff --git a/tests/unit/lib/analytics-channel.test.ts b/tests/unit/lib/analytics-channel.test.ts new file mode 100644 index 0000000..c44a175 --- /dev/null +++ b/tests/unit/lib/analytics-channel.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { + classifyChannel, + extractReferrerHost, + normalizeHost, + sanitizeLandingPath, + sanitizeTag, +} from '@/lib/analytics/channel'; + +// Every expected value below is written by hand. Deriving them from the lookup +// tables in the module would mean deleting an entry from a table also deletes +// its own test case. + +describe('sanitizeTag', () => { + it('lowercases and trims', () => { + expect(sanitizeTag(' GitHub ')).toBe('github'); + }); + + it('rejects a tag carrying markup or control characters', () => { + expect(sanitizeTag('')).toBe('/'); + expect(sanitizeLandingPath('/ok\nX-Injected: 1')).toBe('/'); + expect(sanitizeLandingPath('/a b')).toBe('/'); + }); +}); + +describe('classifyChannel', () => { + it('is DIRECT with no tags and no referrer', () => { + expect(classifyChannel({})).toBe('DIRECT'); + }); + + it('reads the referring host when there are no tags', () => { + expect(classifyChannel({ referrerHost: 'github.com' })).toBe('GITHUB'); + expect(classifyChannel({ referrerHost: 'gist.github.com' })).toBe('GITHUB'); + expect(classifyChannel({ referrerHost: 'youtu.be' })).toBe('YOUTUBE'); + expect(classifyChannel({ referrerHost: 'www.producthunt.com' })).toBe('REVIEW_LINK'); + expect(classifyChannel({ referrerHost: 'news.ycombinator.com' })).toBe('COMMUNITY'); + }); + + it('treats every Google country domain as search', () => { + expect(classifyChannel({ referrerHost: 'google.com' })).toBe('GOOGLE'); + expect(classifyChannel({ referrerHost: 'google.com.tr' })).toBe('GOOGLE'); + expect(classifyChannel({ referrerHost: 'news.google.co.uk' })).toBe('GOOGLE'); + }); + + it('does not mistake a lookalike domain for the real one', () => { + expect(classifyChannel({ referrerHost: 'notgithub.com' })).toBe('REFERRAL'); + expect(classifyChannel({ referrerHost: 'google.com.evil.example' })).toBe('REFERRAL'); + }); + + it('counts an unrecognised site that links to us as a referral', () => { + expect(classifyChannel({ referrerHost: 'someblog.example' })).toBe('REFERRAL'); + }); + + it('prefers an explicit utm_source over the referring host', () => { + expect(classifyChannel({ utmSource: 'youtube', referrerHost: 'google.com' })).toBe('YOUTUBE'); + }); + + it('reads a utm_source that was written as a domain', () => { + expect(classifyChannel({ utmSource: 'github.com' })).toBe('GITHUB'); + }); + + it('files a tagged campaign we do not recognise as OTHER, not DIRECT', () => { + expect(classifyChannel({ utmSource: 'conference-flyer' })).toBe('OTHER'); + }); + + it('lets the medium that names the motion win over the source that names the place', () => { + expect(classifyChannel({ utmSource: 'linkedin', utmMedium: 'outbound' })).toBe('OUTBOUND'); + expect(classifyChannel({ utmSource: 'github', utmMedium: 'email' })).toBe('OUTBOUND'); + expect(classifyChannel({ utmSource: 'someone', utmMedium: 'referral' })).toBe('REFERRAL'); + }); + + it('ignores a source that fails sanitizing and falls back to the referrer', () => { + expect(classifyChannel({ utmSource: '', r: 'not a host' }) + ); + expect(decoded?.utmSource).toBeNull(); + expect(decoded?.referrerHost).toBeNull(); + }); + + it('re-sanitizes a landing path that could only have been hand-written', () => { + expect(decodeFirstTouch(body({ c: 'DIRECT', p: '/' }))?.landingPath).toBe( + '/' + ); + }); + + it('returns null for garbage and for an absent cookie', () => { + expect(decodeFirstTouch('%%%not-base64%%%')).toBeNull(); + expect(decodeFirstTouch(null)).toBeNull(); + expect(decodeFirstTouch(body(['DIRECT']))).toBeNull(); + }); +}); + +describe('anonymous id', () => { + it('generates an id the validator accepts', () => { + expect(isValidAnonymousId(generateAnonymousId())).toBe(true); + }); + + it('generates a different id each time', () => { + expect(generateAnonymousId()).not.toBe(generateAnonymousId()); + }); + + it('rejects an id that is too short, too long or not base36', () => { + expect(isValidAnonymousId('abc')).toBe(false); + expect(isValidAnonymousId('a'.repeat(65))).toBe(false); + expect(isValidAnonymousId('ABCDEF0123456789ABCD')).toBe(false); + expect(isValidAnonymousId(undefined)).toBe(false); + }); +}); + +// Everything above tests the unsigned inner layer. Nothing outside the module +// uses it: a cookie is only a visitor once the signature says this deployment +// issued it, which is what stops a caller from inventing one with curl. +describe('signed cookies', () => { + const TOUCH_TO_SIGN: FirstTouch = { + channel: 'YOUTUBE', + utmSource: 'yt', + utmMedium: null, + utmCampaign: null, + referrerHost: 'youtube.com', + landingPath: '/', + }; + + it('round-trips an id and a first touch', async () => { + const id = generateAnonymousId(); + expect(await readAnonymousIdCookie(await signAnonymousId(id))).toBe(id); + expect(await readFirstTouchCookie(await signFirstTouch(TOUCH_TO_SIGN))).toEqual(TOUCH_TO_SIGN); + }); + + it('rejects a well-formed id that carries no signature', async () => { + expect(await readAnonymousIdCookie('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull(); + }); + + it('rejects a value whose body was edited under a valid signature', async () => { + const signed = (await signAnonymousId(generateAnonymousId())) ?? ''; + const [mac] = signed.split('.'); + + expect(await readAnonymousIdCookie(`${mac}.a1b2c3d4e5f60718293a4b5c6d7e8f90`)).toBeNull(); + }); + + it('rejects a first touch re-signed to name another channel', async () => { + const forgedBody = encodeFirstTouch({ ...TOUCH_TO_SIGN, channel: 'GITHUB' }); + const signed = (await signFirstTouch(TOUCH_TO_SIGN)) ?? ''; + const [mac] = signed.split('.'); + + expect(await readFirstTouchCookie(`${mac}.${forgedBody}`)).toBeNull(); + }); + + it('rejects a cookie signed with another deployment key', async () => { + const signed = await signAnonymousId(generateAnonymousId()); + + vi.stubEnv('NEXTAUTH_SECRET', 'someone-elses-secret'); + + expect(await readAnonymousIdCookie(signed)).toBeNull(); + }); + + it('signs nothing and accepts nothing when there is no secret', async () => { + const signed = await signAnonymousId(generateAnonymousId()); + + vi.stubEnv('NEXTAUTH_SECRET', undefined); + vi.stubEnv('AUTH_SECRET', undefined); + + expect(await signAnonymousId(generateAnonymousId())).toBeNull(); + expect(await readAnonymousIdCookie(signed)).toBeNull(); + }); + + it('rejects the empty, the truncated and the separator-less', async () => { + expect(await readAnonymousIdCookie('')).toBeNull(); + expect(await readAnonymousIdCookie(undefined)).toBeNull(); + expect(await readAnonymousIdCookie('.')).toBeNull(); + expect(await readAnonymousIdCookie('a'.repeat(22))).toBeNull(); + expect(await readFirstTouchCookie('not-signed-at-all')).toBeNull(); + }); +}); + +describe('isAcquisitionChannel', () => { + it('accepts the nine buckets and nothing else', () => { + expect(isAcquisitionChannel('REVIEW_LINK')).toBe(true); + expect(isAcquisitionChannel('direct')).toBe(false); + expect(isAcquisitionChannel(7)).toBe(false); + }); +}); + +describe('isLikelyBot', () => { + it('passes a real browser through', () => { + expect( + isLikelyBot( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36' + ) + ).toBe(false); + }); + + it('catches crawlers, link previewers and scripts', () => { + expect(isLikelyBot('Googlebot/2.1 (+http://www.google.com/bot.html)')).toBe(true); + expect(isLikelyBot('facebookexternalhit/1.1')).toBe(true); + expect(isLikelyBot('curl/8.4.0')).toBe(true); + expect(isLikelyBot('python-requests/2.31.0')).toBe(true); + expect(isLikelyBot('HeadlessChrome/120.0.0.0')).toBe(true); + }); + + it('treats a missing user agent as a bot', () => { + expect(isLikelyBot('')).toBe(true); + expect(isLikelyBot(null)).toBe(true); + }); +}); + +describe('isCountableDocumentRequest', () => { + it('counts a real page load', () => { + expect(isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'document' }))).toBe(true); + }); + + it('does not count a prefetch of the register page', () => { + expect( + isCountableDocumentRequest( + new Headers({ 'sec-fetch-dest': 'document', 'sec-purpose': 'prefetch;prerender' }) + ) + ).toBe(false); + expect( + isCountableDocumentRequest( + new Headers({ 'sec-fetch-dest': 'document', 'next-router-prefetch': '1' }) + ) + ).toBe(false); + }); + + it('does not count an RSC navigation or a subresource', () => { + expect( + isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'document', rsc: '1' })) + ).toBe(false); + expect(isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'image' }))).toBe(false); + }); + + it('falls back to the accept header when fetch metadata is missing', () => { + expect(isCountableDocumentRequest(new Headers({ accept: 'text/html,*/*' }))).toBe(true); + expect(isCountableDocumentRequest(new Headers({ accept: 'application/json' }))).toBe(false); + expect(isCountableDocumentRequest(new Headers())).toBe(false); + }); +}); diff --git a/tests/unit/lib/analytics-scoreboard.test.ts b/tests/unit/lib/analytics-scoreboard.test.ts new file mode 100644 index 0000000..92c224e --- /dev/null +++ b/tests/unit/lib/analytics-scoreboard.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { conversionRates } from '@/lib/analytics/scoreboard'; + +const WEEK = { + visitors: 200, + signups: 20, + firstVideo: 10, + shareLinks: 5, + externalFeedback: 1, + trials: 4, + newPaid: 1, +}; + +describe('conversionRates', () => { + it('divides each step by the one above it', () => { + const rates = conversionRates(WEEK); + expect(rates.visitorToSignup).toBeCloseTo(0.1); + expect(rates.signupToFirstVideo).toBeCloseTo(0.5); + expect(rates.firstVideoToShare).toBeCloseTo(0.5); + expect(rates.shareToFeedback).toBeCloseTo(0.2); + expect(rates.trialToPaid).toBeCloseTo(0.25); + }); + + it('returns null rather than zero when the denominator is zero', () => { + const rates = conversionRates({ ...WEEK, visitors: 0, trials: 0 }); + expect(rates.visitorToSignup).toBeNull(); + expect(rates.trialToPaid).toBeNull(); + // "nobody arrived" and "nobody converted" are different facts, and the rest + // of the funnel still has to report normally. + expect(rates.signupToFirstVideo).toBeCloseTo(0.5); + }); + + it('reports a step where nobody converted as zero, not as missing', () => { + expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0); + }); +}); diff --git a/tests/unit/proxy.test.ts b/tests/unit/proxy.test.ts new file mode 100644 index 0000000..a1860dc --- /dev/null +++ b/tests/unit/proxy.test.ts @@ -0,0 +1,229 @@ +// The proxy is where a visitor gets an identity, and it is the only place that +// can: it runs on the edge, before the page, on every document request. +// +// Two load-bearing details below. The id is written to the *request* as well as +// the response, because a cookie set only on the response is invisible to the +// page rendering that same request, so the very first landing view, the one +// carrying the campaign tags that brought the visitor, would go unrecorded. And +// both cookies are signed, because they are read straight into database columns +// and httpOnly stops JavaScript, not curl. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { proxy } from '@/proxy'; +import { + ANONYMOUS_ID_COOKIE, + FIRST_TOUCH_COOKIE, + readAnonymousIdCookie, + readFirstTouchCookie, +} from '@/lib/analytics/cookies'; + +const BROWSER_UA = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36'; + +function documentRequest( + url: string, + init?: { headers?: Record; cookies?: Record } +) { + const headers = new Headers({ + 'user-agent': BROWSER_UA, + 'sec-fetch-dest': 'document', + ...init?.headers, + }); + const cookies = Object.entries(init?.cookies ?? {}); + if (cookies.length > 0) { + headers.set('cookie', cookies.map(([name, value]) => `${name}=${value}`).join('; ')); + } + return new NextRequest(new URL(url), { headers }); +} + +beforeEach(() => { + vi.stubEnv('NEXTAUTH_SECRET', 'proxy-test-secret'); + // getPublicOrigin prefers a configured origin over the request URL, so the + // tests that care about the request URL have to start from neither being set. + vi.stubEnv('NEXTAUTH_URL', undefined); + vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('proxy', () => { + it('always sets the content security policy', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false'); + + const response = await proxy(documentRequest('https://open-frame.net/')); + + expect(response.headers.get('Content-Security-Policy')).toContain("default-src 'self'"); + }); + + it('sets no acquisition cookie at all when the flag is off', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false'); + + const response = await proxy(documentRequest('https://open-frame.net/?utm_source=github')); + + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined(); + expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined(); + }); + + it('gives a new visitor an id and stores what brought them', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + const request = documentRequest( + 'https://open-frame.net/?utm_source=youtube&utm_medium=video&utm_campaign=launch' + ); + + const response = await proxy(request); + + const cookie = response.cookies.get(ANONYMOUS_ID_COOKIE); + expect(await readAnonymousIdCookie(cookie?.value)).toMatch(/^[a-z0-9]{32}$/); + expect(cookie?.httpOnly).toBe(true); + expect(cookie?.sameSite).toBe('lax'); + expect(cookie?.secure).toBe(true); + + const touch = await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value); + expect(touch).toEqual({ + channel: 'YOUTUBE', + utmSource: 'youtube', + utmMedium: 'video', + utmCampaign: 'launch', + referrerHost: null, + landingPath: '/', + }); + + // The page rendering this same request has to be able to read both. + expect(request.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toBe(cookie?.value); + expect(request.cookies.get(FIRST_TOUCH_COOKIE)?.value).toBeDefined(); + }); + + it('classifies a visit that only carries a referrer', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + + const response = await proxy( + documentRequest('https://open-frame.net/vs/frameio', { + headers: { referer: 'https://github.com/yusufipk/OpenFrame' }, + }) + ); + + expect( + await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value) + ).toMatchObject({ + channel: 'GITHUB', + referrerHost: 'github.com', + landingPath: '/vs/frameio', + }); + }); + + it('does not overwrite the first touch of a returning visitor', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + const issued = await proxy(documentRequest('https://open-frame.net/?utm_source=github')); + + const response = await proxy( + documentRequest('https://open-frame.net/?utm_source=google', { + cookies: { + [ANONYMOUS_ID_COOKIE]: issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? '', + [FIRST_TOUCH_COOKIE]: issued.cookies.get(FIRST_TOUCH_COOKIE)?.value ?? '', + }, + }) + ); + + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined(); + expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined(); + }); + + it('replaces an id it did not sign, however well formed it looks', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + + // The shape a real id has, chosen by the caller rather than issued here. + // Accepting it would let anyone mint visitors, and claim the events of one + // whose id they guessed. + const forged = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + + const response = await proxy( + documentRequest('https://open-frame.net/', { + cookies: { [ANONYMOUS_ID_COOKIE]: forged }, + }) + ); + + const issued = await readAnonymousIdCookie(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value); + expect(issued).toMatch(/^[a-z0-9]{32}$/); + expect(issued).not.toBe(forged); + }); + + it('replaces an id signed with another deployment key', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + const issued = await proxy(documentRequest('https://open-frame.net/')); + const stolen = issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? ''; + + vi.stubEnv('NEXTAUTH_SECRET', 'a-different-secret'); + const response = await proxy( + documentRequest('https://open-frame.net/', { + cookies: { [ANONYMOUS_ID_COOKIE]: stolen }, + }) + ); + + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toBeDefined(); + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).not.toBe(stolen); + }); + + it('sets nothing when there is no secret to sign with', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + vi.stubEnv('NEXTAUTH_SECRET', undefined); + vi.stubEnv('AUTH_SECRET', undefined); + + const response = await proxy(documentRequest('https://open-frame.net/')); + + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined(); + expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined(); + }); + + it('ignores crawlers, so they never enter the visitor count', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + + const response = await proxy( + documentRequest('https://open-frame.net/', { + headers: { 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' }, + }) + ); + + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined(); + }); + + it('ignores a prefetch and an API call', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + + const prefetch = await proxy( + documentRequest('https://open-frame.net/register', { + headers: { 'next-router-prefetch': '1' }, + }) + ); + const api = await proxy( + documentRequest('https://open-frame.net/api/projects', { + headers: { 'sec-fetch-dest': 'empty' }, + }) + ); + + expect(prefetch.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined(); + expect(api.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined(); + }); + + it('leaves the cookie insecure on plain http, so local development works', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + + const response = await proxy(documentRequest('http://localhost:3000/')); + + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(false); + }); + + it('keeps the cookie secure behind a TLS-terminating proxy', async () => { + vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true'); + // What a Docker deployment looks like from inside the container: the request + // arrived over http on an internal address, and only the configured origin + // knows the site is served over TLS. + vi.stubEnv('NEXTAUTH_URL', 'https://open-frame.net'); + + const response = await proxy(documentRequest('http://localhost:3000/')); + + expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(true); + }); +});