From f61dfb42dc1c06bfa71847a7486349633b62f98b Mon Sep 17 00:00:00 2001 From: suguanYang Date: Thu, 23 Jul 2026 21:05:48 +0800 Subject: [PATCH 1/3] Add first-party acquisition attribution --- app/(dashboard)/billing/page.tsx | 2 +- app/(landing)/_components/hero-playground.tsx | 8 +- app/api/acquisition-attribution/bind/route.ts | 39 + .../acquisition-attribution/session/route.ts | 43 + app/layout.tsx | 17 +- drizzle/0008_noisy_chimera.sql | 21 + drizzle/meta/0008_snapshot.json | 954 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + hooks/use-payment-redirect-tracking.ts | 2 +- lib/acquisition-attribution/client.ts | 61 ++ lib/acquisition-attribution/core.test.ts | 130 +++ lib/acquisition-attribution/core.ts | 333 ++++++ lib/analytics/adapters/google-analytics.ts | 165 +++ lib/analytics/adapters/posthog.ts | 378 +++++++ lib/analytics/client-state.ts | 215 ++++ lib/analytics/core.test.ts | 73 ++ lib/analytics/core.ts | 125 +++ lib/analytics/index.ts | 65 ++ lib/analytics/payment-redirect.ts | 166 +++ lib/analytics/types.ts | 155 +++ lib/db/schema.ts | 30 + lib/google-analytics.ts | 78 +- lib/posthog-checkout.test.ts | 40 +- lib/posthog-checkout.ts | 125 +-- lib/posthog.ts | 722 +++++-------- .../acquisition-attribution-provider.tsx | 41 + providers/analytics-auth-sync.tsx | 151 +++ providers/analytics-provider.tsx | 46 + .../authenticated-job-analytics-sync.tsx | 25 + providers/providers.tsx | 6 +- server/acquisition-attribution.ts | 79 ++ 31 files changed, 3608 insertions(+), 694 deletions(-) create mode 100644 app/api/acquisition-attribution/bind/route.ts create mode 100644 app/api/acquisition-attribution/session/route.ts create mode 100644 drizzle/0008_noisy_chimera.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 lib/acquisition-attribution/client.ts create mode 100644 lib/acquisition-attribution/core.test.ts create mode 100644 lib/acquisition-attribution/core.ts create mode 100644 lib/analytics/adapters/google-analytics.ts create mode 100644 lib/analytics/adapters/posthog.ts create mode 100644 lib/analytics/client-state.ts create mode 100644 lib/analytics/core.test.ts create mode 100644 lib/analytics/core.ts create mode 100644 lib/analytics/index.ts create mode 100644 lib/analytics/payment-redirect.ts create mode 100644 lib/analytics/types.ts create mode 100644 providers/acquisition-attribution-provider.tsx create mode 100644 providers/analytics-auth-sync.tsx create mode 100644 providers/analytics-provider.tsx create mode 100644 providers/authenticated-job-analytics-sync.tsx create mode 100644 server/acquisition-attribution.ts diff --git a/app/(dashboard)/billing/page.tsx b/app/(dashboard)/billing/page.tsx index e0fbfc2..5f5b889 100644 --- a/app/(dashboard)/billing/page.tsx +++ b/app/(dashboard)/billing/page.tsx @@ -6,7 +6,7 @@ import { Button } from "@components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@components/ui/card"; import { Skeleton } from "@components/ui/skeleton"; import { useCredits } from "@hooks/use-credits"; -import { trackPaymentRedirectFromSearchParams } from "@lib/posthog-checkout"; +import { trackPaymentRedirectFromSearchParams } from "@lib/analytics/payment-redirect"; import type { Subscription } from "@server/external-api/subscriptions"; import { CheckCircle2, CreditCard, XCircle } from "lucide-react"; import Link from "next/link"; diff --git a/app/(landing)/_components/hero-playground.tsx b/app/(landing)/_components/hero-playground.tsx index 5bdc8a9..526bac3 100644 --- a/app/(landing)/_components/hero-playground.tsx +++ b/app/(landing)/_components/hero-playground.tsx @@ -5,7 +5,7 @@ import { trackLandingInteraction, } from "@app/(landing)/_components/landing-tracked-link"; import { Dialog, DialogContent, DialogTitle } from "@components/ui/dialog"; -import { mirrorPlaygroundParseStarted } from "@lib/google-analytics"; +import { trackAnalyticsEvent } from "@lib/analytics"; import { cn } from "@lib/utils"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { @@ -1663,7 +1663,11 @@ export const HeroPlayground = () => { file_name: sample.cardLabel, sample_id: sampleId, }); - mirrorPlaygroundParseStarted(sample.cardLabel); + trackAnalyticsEvent({ + fileName: sample.cardLabel, + name: "playground.parse_started", + timestamp: new Date().toISOString(), + }); setActiveSampleId(sampleId); setParsedSampleId(sampleId); diff --git a/app/api/acquisition-attribution/bind/route.ts b/app/api/acquisition-attribution/bind/route.ts new file mode 100644 index 0000000..95561cd --- /dev/null +++ b/app/api/acquisition-attribution/bind/route.ts @@ -0,0 +1,39 @@ +import { ACQUISITION_ATTRIBUTION_COOKIE_NAME } from "@lib/acquisition-attribution/core"; +import { auth } from "@lib/auth"; +import { bindAcquisitionSessionToUser } from "@server/acquisition-attribution"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const bindRequestSchema = z.object({ + userId: z.string().min(1), +}); + +export async function POST(request: Request): Promise { + const sessionData = await auth.api.getSession({ headers: request.headers }); + if (!sessionData?.user?.id) { + return NextResponse.json({ message: "Authentication required" }, { status: 401 }); + } + + const requestBody = await request.json().catch(() => null); + const parsedRequest = bindRequestSchema.safeParse(requestBody); + if (!parsedRequest.success) { + return NextResponse.json({ message: "Invalid acquisition bind payload" }, { status: 400 }); + } + + if (parsedRequest.data.userId !== sessionData.user.id) { + return NextResponse.json( + { message: "Cannot bind attribution to another user" }, + { status: 403 } + ); + } + + const cookieStore = await cookies(); + const sessionId = cookieStore.get(ACQUISITION_ATTRIBUTION_COOKIE_NAME)?.value ?? null; + const result = await bindAcquisitionSessionToUser({ + sessionId, + userId: sessionData.user.id, + }); + + return NextResponse.json(result); +} diff --git a/app/api/acquisition-attribution/session/route.ts b/app/api/acquisition-attribution/session/route.ts new file mode 100644 index 0000000..01392d4 --- /dev/null +++ b/app/api/acquisition-attribution/session/route.ts @@ -0,0 +1,43 @@ +import { + ACQUISITION_ATTRIBUTION_COOKIE_NAME, + ACQUISITION_ATTRIBUTION_TTL_SECONDS, +} from "@lib/acquisition-attribution/core"; +import { captureAcquisitionSession } from "@server/acquisition-attribution"; +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const captureRequestSchema = z.object({ + landingUrl: z.url(), + referrer: z.string().optional(), +}); + +export async function POST(request: Request): Promise { + const requestBody = await request.json().catch(() => null); + const parsedRequest = captureRequestSchema.safeParse(requestBody); + + if (!parsedRequest.success) { + return NextResponse.json({ message: "Invalid acquisition payload" }, { status: 400 }); + } + + const cookieStore = await cookies(); + const existingSessionId = cookieStore.get(ACQUISITION_ATTRIBUTION_COOKIE_NAME)?.value ?? null; + const result = await captureAcquisitionSession({ + existingSessionId, + landingUrl: parsedRequest.data.landingUrl, + referrer: parsedRequest.data.referrer, + }); + const response = NextResponse.json(result); + + if (result.sessionId) { + response.cookies.set(ACQUISITION_ATTRIBUTION_COOKIE_NAME, result.sessionId, { + httpOnly: true, + maxAge: ACQUISITION_ATTRIBUTION_TTL_SECONDS, + path: "/", + sameSite: "lax", + secure: new URL(request.url).protocol === "https:", + }); + } + + return response; +} diff --git a/app/layout.tsx b/app/layout.tsx index 9bd3c99..07b3325 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,9 +6,8 @@ import "./globals.css"; import { ThemeProvider } from "@components/theme-provider"; import { appMetadata } from "@lib/app-metadata"; import { getDefaultConfig } from "@lib/config"; +import { AnalyticsProvider } from "@providers/analytics-provider"; import { ConfigProvider } from "@providers/config-provider"; -import { GoogleAnalyticsProvider } from "@providers/google-analytics-provider"; -import PostHogProvider from "@providers/posthog-provider"; import { Providers } from "@providers/providers"; import { NextIntlClientProvider } from "next-intl"; import { getMessages } from "next-intl/server"; @@ -42,17 +41,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( - + - - - -
{children}
-
-
-
+ + +
{children}
+
+
diff --git a/drizzle/0008_noisy_chimera.sql b/drizzle/0008_noisy_chimera.sql new file mode 100644 index 0000000..d2ca185 --- /dev/null +++ b/drizzle/0008_noisy_chimera.sql @@ -0,0 +1,21 @@ +CREATE TABLE "marketing_attribution_sessions" ( + "session_id" text PRIMARY KEY NOT NULL, + "source" text NOT NULL, + "channel" text NOT NULL, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_content" text, + "utm_term" text, + "oppref" text, + "landing_path" text NOT NULL, + "referrer_host" text, + "captured_at" timestamp with time zone DEFAULT now() NOT NULL, + "bound_user_id" text, + "bound_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "marketing_attribution_sessions" ADD CONSTRAINT "marketing_attribution_sessions_bound_user_id_user_id_fk" FOREIGN KEY ("bound_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "marketingAttributionSession_boundUserId_idx" ON "marketing_attribution_sessions" USING btree ("bound_user_id");--> statement-breakpoint +CREATE INDEX "marketingAttributionSession_capturedAt_idx" ON "marketing_attribution_sessions" USING btree ("captured_at");--> statement-breakpoint +CREATE INDEX "marketingAttributionSession_sourceCampaign_idx" ON "marketing_attribution_sessions" USING btree ("source","utm_campaign"); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..7f5769f --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,954 @@ +{ + "id": "5cd047a4-9347-4eaf-a3e5-f7b6533ff5ed", + "prevId": "a425e1d7-2823-423a-b11f-83a17f94afbd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emailVerificationToken": { + "name": "emailVerificationToken", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "emailVerificationToken_userId_user_id_fk": { + "name": "emailVerificationToken_userId_user_id_fk", + "tableFrom": "emailVerificationToken", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "emailVerificationToken_token_unique": { + "name": "emailVerificationToken_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privateKey": { + "name": "privateKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "usageWelcomeStatus": { + "name": "usageWelcomeStatus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "usageWelcomeApiKey": { + "name": "usageWelcomeApiKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketing_attribution_sessions": { + "name": "marketing_attribution_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oppref": { + "name": "oppref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_path": { + "name": "landing_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_host": { + "name": "referrer_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bound_user_id": { + "name": "bound_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bound_at": { + "name": "bound_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "marketingAttributionSession_boundUserId_idx": { + "name": "marketingAttributionSession_boundUserId_idx", + "columns": [ + { + "expression": "bound_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "marketingAttributionSession_capturedAt_idx": { + "name": "marketingAttributionSession_capturedAt_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "marketingAttributionSession_sourceCampaign_idx": { + "name": "marketingAttributionSession_sourceCampaign_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "utm_campaign", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "marketing_attribution_sessions_bound_user_id_user_id_fk": { + "name": "marketing_attribution_sessions_bound_user_id_user_id_fk", + "tableFrom": "marketing_attribution_sessions", + "tableTo": "user", + "columnsFrom": ["bound_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.newsletterSubscription": { + "name": "newsletterSubscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmationTokenHash": { + "name": "confirmationTokenHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmationTokenExpiresAt": { + "name": "confirmationTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmationSentAt": { + "name": "confirmationSentAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmedAt": { + "name": "confirmedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unsubscribedAt": { + "name": "unsubscribedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "newsletterSubscription_email_unique": { + "name": "newsletterSubscription_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletterSubscription_confirmationTokenHash_unique": { + "name": "newsletterSubscription_confirmationTokenHash_unique", + "columns": [ + { + "expression": "confirmationTokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletterSubscription_status_idx": { + "name": "newsletterSubscription_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauthAuthorizationCode": { + "name": "oauthAuthorizationCode", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirectUri": { + "name": "redirectUri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientName": { + "name": "clientName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full_access'" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthAuthorizationCode_codeHash_unique": { + "name": "oauthAuthorizationCode_codeHash_unique", + "columns": [ + { + "expression": "codeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAuthorizationCode_userId_idx": { + "name": "oauthAuthorizationCode_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauthAuthorizationCode_userId_user_id_fk": { + "name": "oauthAuthorizationCode_userId_user_id_fk", + "tableFrom": "oauthAuthorizationCode", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauthRefreshToken": { + "name": "oauthRefreshToken", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full_access'" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthRefreshToken_tokenHash_unique": { + "name": "oauthRefreshToken_tokenHash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauthRefreshToken_userId_user_id_fk": { + "name": "oauthRefreshToken_userId_user_id_fk", + "tableFrom": "oauthRefreshToken", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8ab5fc6..e8713e5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1782446108466, "tag": "0007_add-newsletter-unsubscribe", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1784811344172, + "tag": "0008_noisy_chimera", + "breakpoints": true } ] } diff --git a/hooks/use-payment-redirect-tracking.ts b/hooks/use-payment-redirect-tracking.ts index 9b4293c..0a07109 100644 --- a/hooks/use-payment-redirect-tracking.ts +++ b/hooks/use-payment-redirect-tracking.ts @@ -1,7 +1,7 @@ "use client"; import { useCredits } from "@hooks/use-credits"; -import { trackPaymentRedirectFromSearchParams } from "@lib/posthog-checkout"; +import { trackPaymentRedirectFromSearchParams } from "@lib/analytics/payment-redirect"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useRef } from "react"; import { useAppConfigContext } from "@/providers/config-provider"; diff --git a/lib/acquisition-attribution/client.ts b/lib/acquisition-attribution/client.ts new file mode 100644 index 0000000..31e2190 --- /dev/null +++ b/lib/acquisition-attribution/client.ts @@ -0,0 +1,61 @@ +type CaptureAcquisitionSessionRequest = { + readonly landingUrl: string; + readonly referrer?: string; +}; + +type BindAcquisitionSessionRequest = { + readonly userId: string; +}; + +const CAPTURE_PATH_PREFIXES = ["/claw", "/comparison", "/versus"] as const; +const CAPTURE_AUTH_PATHS = new Set(["/forgot-password", "/login", "/register", "/reset-password"]); + +export function shouldCaptureAcquisitionPath(pathname: string): boolean { + if (pathname === "/") { + return true; + } + + if (CAPTURE_AUTH_PATHS.has(pathname)) { + return true; + } + + return CAPTURE_PATH_PREFIXES.some((prefix: string): boolean => { + return pathname === prefix || pathname.startsWith(`${prefix}/`); + }); +} + +export async function requestAcquisitionSessionCapture( + input: CaptureAcquisitionSessionRequest +): Promise { + if (typeof window === "undefined") { + return; + } + + await fetch("/api/acquisition-attribution/session", { + body: JSON.stringify(input), + credentials: "same-origin", + headers: { + "content-type": "application/json", + }, + keepalive: true, + method: "POST", + }); +} + +export async function requestAcquisitionSessionBind( + input: BindAcquisitionSessionRequest +): Promise { + if (typeof window === "undefined") { + return; + } + + await fetch("/api/acquisition-attribution/bind", { + body: JSON.stringify(input), + credentials: "same-origin", + headers: { + "content-type": "application/json", + }, + keepalive: true, + method: "POST", + }); +} diff --git a/lib/acquisition-attribution/core.test.ts b/lib/acquisition-attribution/core.test.ts new file mode 100644 index 0000000..5e7857b --- /dev/null +++ b/lib/acquisition-attribution/core.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + type AcquisitionAttributionRepository, + type AcquisitionAttributionSessionInsert, + createAcquisitionAttributionService, +} from "@/lib/acquisition-attribution/core"; + +function createMemoryRepository(): { + readonly repository: AcquisitionAttributionRepository; + readonly sessions: Map< + string, + AcquisitionAttributionSessionInsert & { boundUserId: string | null } + >; +} { + const sessions = new Map< + string, + AcquisitionAttributionSessionInsert & { boundUserId: string | null } + >(); + + return { + repository: { + bindSessionToUser: async ({ boundAt: _boundAt, sessionId, userId }): Promise => { + const session = sessions.get(sessionId); + if (!session || session.boundUserId !== null) { + return false; + } + + sessions.set(sessionId, { + ...session, + boundUserId: userId, + }); + return true; + }, + findSessionById: async (sessionId: string) => { + const session = sessions.get(sessionId); + if (!session) { + return null; + } + + return { + boundUserId: session.boundUserId, + capturedAt: session.capturedAt, + sessionId: session.sessionId, + }; + }, + insertSession: async (session: AcquisitionAttributionSessionInsert): Promise => { + if (sessions.has(session.sessionId)) { + return false; + } + + sessions.set(session.sessionId, { + ...session, + boundUserId: null, + }); + return true; + }, + }, + sessions, + }; +} + +describe("acquisition attribution", () => { + it("captures first-touch landing data once and does not overwrite it", async () => { + const { repository, sessions } = createMemoryRepository(); + const service = createAcquisitionAttributionService({ + createSessionId: (): string => "session_first", + getNow: (): Date => new Date("2026-07-23T00:00:00.000Z"), + repository, + }); + + const firstCapture = await service.captureAcquisitionSession({ + landingUrl: + "https://knowhereto.ai/?utm_source=OpenAI&utm_medium=Paid_Search&utm_campaign=launch&oppref=click_123", + referrer: "https://chatgpt.com/search", + }); + const duplicateCapture = await service.captureAcquisitionSession({ + existingSessionId: "session_first", + landingUrl: "https://knowhereto.ai/claw?utm_source=google&utm_campaign=overwrite", + referrer: "https://google.com/search", + }); + + expect(firstCapture).toEqual({ captured: true, sessionId: "session_first" }); + expect(duplicateCapture).toEqual({ + captured: false, + reason: "duplicate", + sessionId: "session_first", + }); + expect(sessions.size).toBe(1); + expect(sessions.get("session_first")).toMatchObject({ + channel: "paid_search", + landingPath: "/", + oppref: "click_123", + referrerHost: "chatgpt.com", + source: "openai", + utmCampaign: "launch", + utmMedium: "paid_search", + utmSource: "openai", + }); + }); + + it("binds a signup user exactly once", async () => { + const { repository, sessions } = createMemoryRepository(); + const service = createAcquisitionAttributionService({ + createSessionId: (): string => "session_signup", + getNow: (): Date => new Date("2026-07-23T00:00:00.000Z"), + repository, + }); + + await service.captureAcquisitionSession({ + landingUrl: "https://knowhereto.ai/?utm_source=openai", + }); + + const firstBind = await service.bindAcquisitionSessionToUser({ + sessionId: "session_signup", + userId: "user_123", + }); + const secondBind = await service.bindAcquisitionSessionToUser({ + sessionId: "session_signup", + userId: "user_456", + }); + + expect(firstBind).toEqual({ bound: true, sessionId: "session_signup" }); + expect(secondBind).toEqual({ + bound: false, + reason: "bound_to_other_user", + sessionId: "session_signup", + }); + expect(sessions.get("session_signup")?.boundUserId).toBe("user_123"); + }); +}); diff --git a/lib/acquisition-attribution/core.ts b/lib/acquisition-attribution/core.ts new file mode 100644 index 0000000..cd06b7d --- /dev/null +++ b/lib/acquisition-attribution/core.ts @@ -0,0 +1,333 @@ +const PARAMETER_MAX_LENGTH = 255; +const LANDING_PATH_MAX_LENGTH = 2048; +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/; + +export const ACQUISITION_ATTRIBUTION_COOKIE_NAME = "kh_acquisition_session"; +export const ACQUISITION_ATTRIBUTION_TTL_DAYS = 90; +export const ACQUISITION_ATTRIBUTION_TTL_SECONDS = ACQUISITION_ATTRIBUTION_TTL_DAYS * 24 * 60 * 60; + +export type AcquisitionCaptureInput = { + readonly existingSessionId?: string | null; + readonly landingUrl: string; + readonly referrer?: string | null; +}; + +export type AcquisitionBindInput = { + readonly sessionId?: string | null; + readonly userId: string; +}; + +export type AcquisitionAttributionSessionInsert = { + readonly sessionId: string; + readonly source: string; + readonly channel: string; + readonly utmSource: string | null; + readonly utmMedium: string | null; + readonly utmCampaign: string | null; + readonly utmContent: string | null; + readonly utmTerm: string | null; + readonly oppref: string | null; + readonly landingPath: string; + readonly referrerHost: string | null; + readonly capturedAt: Date; +}; + +export type AcquisitionAttributionStoredSession = { + readonly sessionId: string; + readonly boundUserId: string | null; + readonly capturedAt: Date; +}; + +export type AcquisitionAttributionRepository = { + readonly bindSessionToUser: (input: { + readonly boundAt: Date; + readonly sessionId: string; + readonly userId: string; + }) => Promise; + readonly findSessionById: ( + sessionId: string + ) => Promise; + readonly insertSession: (session: AcquisitionAttributionSessionInsert) => Promise; +}; + +export type AcquisitionCaptureResult = + | { + readonly captured: false; + readonly reason: "duplicate"; + readonly sessionId: string; + } + | { + readonly captured: false; + readonly reason: "invalid_landing_url"; + readonly sessionId: null; + } + | { + readonly captured: true; + readonly sessionId: string; + }; + +export type AcquisitionBindResult = + | { + readonly bound: false; + readonly reason: "already_bound" | "bound_to_other_user" | "missing_session" | "not_found"; + readonly sessionId: string | null; + } + | { + readonly bound: true; + readonly sessionId: string; + }; + +export type AcquisitionAttributionService = { + readonly bindAcquisitionSessionToUser: ( + input: AcquisitionBindInput + ) => Promise; + readonly captureAcquisitionSession: ( + input: AcquisitionCaptureInput + ) => Promise; +}; + +type AcquisitionAttributionServiceOptions = { + readonly createSessionId: () => string; + readonly getNow: () => Date; + readonly repository: AcquisitionAttributionRepository; +}; + +type NormalizedAcquisitionCapture = Omit< + AcquisitionAttributionSessionInsert, + "capturedAt" | "sessionId" +>; + +function normalizeNullableText(value: string | null | undefined): string | null { + const trimmedValue = value?.trim(); + if (!trimmedValue) { + return null; + } + + return trimmedValue.slice(0, PARAMETER_MAX_LENGTH); +} + +function normalizeSlugText(value: string | null | undefined): string | null { + return normalizeNullableText(value)?.toLowerCase() ?? null; +} + +function normalizeSessionId(sessionId: string | null | undefined): string | null { + const trimmedSessionId = sessionId?.trim(); + if (!trimmedSessionId || !SESSION_ID_PATTERN.test(trimmedSessionId)) { + return null; + } + + return trimmedSessionId; +} + +function parseHttpUrl(value: string): URL | null { + try { + const parsedUrl = new URL(value); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + return null; + } + + return parsedUrl; + } catch { + return null; + } +} + +function normalizeLandingPath(parsedLandingUrl: URL): string { + const landingPath = parsedLandingUrl.pathname || "/"; + return landingPath.slice(0, LANDING_PATH_MAX_LENGTH); +} + +function normalizeReferrerHost( + referrer: string | null | undefined, + landingHost: string +): string | null { + if (!referrer) { + return null; + } + + const parsedReferrer = parseHttpUrl(referrer); + if (!parsedReferrer) { + return null; + } + + const referrerHost = parsedReferrer.host.toLowerCase(); + if (!referrerHost || referrerHost === landingHost.toLowerCase()) { + return null; + } + + return referrerHost.slice(0, PARAMETER_MAX_LENGTH); +} + +function deriveSource({ + oppref, + referrerHost, + utmSource, +}: { + readonly oppref: string | null; + readonly referrerHost: string | null; + readonly utmSource: string | null; +}): string { + if (utmSource) { + return utmSource; + } + + if (oppref) { + return "openai"; + } + + if (referrerHost) { + return referrerHost; + } + + return "direct"; +} + +function deriveChannel({ + oppref, + referrerHost, + utmMedium, +}: { + readonly oppref: string | null; + readonly referrerHost: string | null; + readonly utmMedium: string | null; +}): string { + if (utmMedium) { + return utmMedium; + } + + if (oppref) { + return "paid"; + } + + if (referrerHost) { + return "referral"; + } + + return "direct"; +} + +function normalizeCaptureInput( + input: AcquisitionCaptureInput +): NormalizedAcquisitionCapture | null { + const parsedLandingUrl = parseHttpUrl(input.landingUrl); + if (!parsedLandingUrl) { + return null; + } + + const searchParams = parsedLandingUrl.searchParams; + const utmSource = normalizeSlugText(searchParams.get("utm_source")); + const utmMedium = normalizeSlugText(searchParams.get("utm_medium")); + const utmCampaign = normalizeNullableText(searchParams.get("utm_campaign")); + const utmContent = normalizeNullableText(searchParams.get("utm_content")); + const utmTerm = normalizeNullableText(searchParams.get("utm_term")); + const oppref = normalizeNullableText(searchParams.get("oppref")); + const referrerHost = normalizeReferrerHost(input.referrer, parsedLandingUrl.host); + const source = deriveSource({ oppref, referrerHost, utmSource }); + const channel = deriveChannel({ oppref, referrerHost, utmMedium }); + + return { + channel, + landingPath: normalizeLandingPath(parsedLandingUrl), + oppref, + referrerHost, + source, + utmCampaign, + utmContent, + utmMedium, + utmSource, + utmTerm, + }; +} + +export function createAcquisitionAttributionService({ + createSessionId, + getNow, + repository, +}: AcquisitionAttributionServiceOptions): AcquisitionAttributionService { + return { + bindAcquisitionSessionToUser: async ( + input: AcquisitionBindInput + ): Promise => { + const sessionId = normalizeSessionId(input.sessionId); + if (!sessionId) { + return { + bound: false, + reason: "missing_session", + sessionId: null, + }; + } + + const boundAt = getNow(); + const didBind = await repository.bindSessionToUser({ + boundAt, + sessionId, + userId: input.userId, + }); + + if (didBind) { + return { + bound: true, + sessionId, + }; + } + + const existingSession = await repository.findSessionById(sessionId); + if (!existingSession) { + return { + bound: false, + reason: "not_found", + sessionId, + }; + } + + return { + bound: false, + reason: + existingSession.boundUserId === input.userId ? "already_bound" : "bound_to_other_user", + sessionId, + }; + }, + captureAcquisitionSession: async ( + input: AcquisitionCaptureInput + ): Promise => { + const capture = normalizeCaptureInput(input); + if (!capture) { + return { + captured: false, + reason: "invalid_landing_url", + sessionId: null, + }; + } + + const existingSessionId = normalizeSessionId(input.existingSessionId); + if (existingSessionId) { + const existingSession = await repository.findSessionById(existingSessionId); + if (existingSession) { + return { + captured: false, + reason: "duplicate", + sessionId: existingSession.sessionId, + }; + } + } + + const sessionId = existingSessionId ?? createSessionId(); + const didInsert = await repository.insertSession({ + ...capture, + capturedAt: getNow(), + sessionId, + }); + + return didInsert + ? { + captured: true, + sessionId, + } + : { + captured: false, + reason: "duplicate", + sessionId, + }; + }, + }; +} diff --git a/lib/analytics/adapters/google-analytics.ts b/lib/analytics/adapters/google-analytics.ts new file mode 100644 index 0000000..9c2d808 --- /dev/null +++ b/lib/analytics/adapters/google-analytics.ts @@ -0,0 +1,165 @@ +import type { AnalyticsAdapter, AnalyticsConfig } from "@/lib/analytics/core"; +import type { AnalyticsEvent } from "@/lib/analytics/types"; + +type GAEventParams = Record; + +declare global { + interface Window { + gtag?: (...args: unknown[]) => void; + } +} + +let gaMeasurementId = ""; + +const cleanGAEventParams = ( + params?: GAEventParams +): Record | undefined => { + if (!params) { + return undefined; + } + + return Object.fromEntries( + Object.entries(params).filter((entry): entry is [string, string | number | boolean] => { + return entry[1] !== undefined; + }) + ); +}; + +export function trackGoogleAnalyticsEvent(eventName: string, params?: GAEventParams): void { + if (typeof window === "undefined" || typeof window.gtag !== "function") { + return; + } + + window.gtag("event", eventName, cleanGAEventParams(params)); +} + +export function setGoogleAnalyticsUserId(userId: string | null): void { + if (typeof window === "undefined" || typeof window.gtag !== "function") { + return; + } + + if (userId) { + window.gtag("set", { user_id: userId }); + return; + } + + window.gtag("set", { user_id: undefined }); +} + +const isGoogleAnalyticsEnabled = (): boolean => { + return typeof window !== "undefined" && typeof window.gtag === "function"; +}; + +const trackGoogleAnalyticsPageView = (pagePath: string): void => { + if (!gaMeasurementId || !isGoogleAnalyticsEnabled()) { + return; + } + + window.gtag?.("config", gaMeasurementId, { + page_path: pagePath, + }); +}; + +const trackGoogleAnalyticsAnalyticsEvent = (event: AnalyticsEvent): void => { + switch (event.name) { + case "auth.login": + trackGoogleAnalyticsEvent("login", { method: event.method }); + return; + case "auth.signup": + trackGoogleAnalyticsEvent("sign_up", { method: event.method }); + return; + case "api_key.created": + trackGoogleAnalyticsEvent("api_key_created", { source: event.source }); + return; + case "billing.buy_credits_clicked": + trackGoogleAnalyticsEvent("buy_credits_clicked", { source: event.source }); + return; + case "marketing.landing_cta_clicked": + trackGoogleAnalyticsEvent("select_content", { + content_type: "landing_cta", + item_id: event.ctaId, + source_section: event.sourceSection, + }); + return; + case "marketing.contact_sales_clicked": + trackGoogleAnalyticsEvent("generate_lead", { + lead_source: event.sourceSection, + }); + return; + case "billing.checkout_started": + trackGoogleAnalyticsEvent("begin_checkout", { + checkout_type: event.checkoutType, + }); + return; + case "billing.checkout_canceled": + trackGoogleAnalyticsEvent("checkout_canceled", { + checkout_type: event.checkoutType, + }); + return; + case "billing.credits_purchased": + trackGoogleAnalyticsEvent("purchase", { + currency: "USD", + item_category: "credits_package", + transaction_id: event.transactionId, + value: event.amount, + }); + return; + case "billing.subscription_purchased": + trackGoogleAnalyticsEvent("purchase", { + currency: "USD", + item_category: "subscription", + item_id: event.planId, + transaction_id: event.transactionId, + }); + return; + case "billing.checkout_purchase_unknown": + trackGoogleAnalyticsEvent("checkout_purchase_unknown", { + amount: event.amount, + plan_id: event.planId, + transaction_id: event.transactionId, + }); + return; + case "job.created": + trackGoogleAnalyticsEvent("job_created", { + source_type: event.sourceType, + }); + return; + case "job.completed": + trackGoogleAnalyticsEvent("job_completed", { + processing_time_ms: event.processingTimeMs, + }); + return; + case "job.failed": + trackGoogleAnalyticsEvent("job_failed", { + error_message: event.errorMessage, + }); + return; + case "file.uploaded": + trackGoogleAnalyticsEvent("file_uploaded", { + file_type: event.fileType, + upload_method: event.uploadMethod, + }); + return; + case "playground.parse_started": + trackGoogleAnalyticsEvent("playground_parse_started", { + file_name: event.fileName, + }); + return; + default: + return; + } +}; + +export function createGoogleAnalyticsAdapter(): AnalyticsAdapter { + return { + name: "google-analytics", + identifyUser: setGoogleAnalyticsUserId, + initialize: (config: AnalyticsConfig): void => { + gaMeasurementId = config.googleAnalyticsMeasurementId ?? gaMeasurementId; + }, + isEnabled: isGoogleAnalyticsEnabled, + resetUser: (): void => setGoogleAnalyticsUserId(null), + trackEvent: trackGoogleAnalyticsAnalyticsEvent, + trackPageView: trackGoogleAnalyticsPageView, + }; +} diff --git a/lib/analytics/adapters/posthog.ts b/lib/analytics/adapters/posthog.ts new file mode 100644 index 0000000..5530715 --- /dev/null +++ b/lib/analytics/adapters/posthog.ts @@ -0,0 +1,378 @@ +import type { PostHog } from "posthog-js"; +import type { AnalyticsAdapter } from "@/lib/analytics/core"; +import type { AnalyticsEvent, AnalyticsProperties } from "@/lib/analytics/types"; +import { env } from "@/lib/env"; + +const POSTHOG_KEY = env.NEXT_PUBLIC_POSTHOG_KEY; +const POSTHOG_HOST = env.NEXT_PUBLIC_POSTHOG_HOST; + +export const isPostHogAdapterEnabled = Boolean(POSTHOG_KEY); + +type QueuedAction = + | { + readonly eventName: string; + readonly properties?: AnalyticsProperties; + readonly type: "capture"; + } + | { + readonly properties?: AnalyticsProperties; + readonly type: "identify"; + readonly userId: string; + } + | { readonly type: "reset" } + | { readonly properties: AnalyticsProperties; readonly type: "people.set" }; + +type PostHogCapture = { + readonly eventName: string; + readonly properties?: AnalyticsProperties; +}; + +let posthog: PostHog | null = null; +let isPostHogReady = false; + +const eventQueue: QueuedAction[] = []; + +const flushQueue = (): void => { + if (!posthog || !isPostHogReady) { + return; + } + + while (eventQueue.length > 0) { + const action = eventQueue.shift(); + if (!action) { + continue; + } + + switch (action.type) { + case "capture": + posthog.capture(action.eventName, action.properties); + break; + case "identify": + posthog.identify(action.userId, action.properties); + break; + case "reset": + posthog.reset(); + break; + case "people.set": + posthog.people.set(action.properties); + break; + default: + break; + } + } +}; + +const capturePostHogEvent = (eventName: string, properties?: AnalyticsProperties): void => { + if (typeof window === "undefined" || !isPostHogAdapterEnabled) { + return; + } + + if (posthog && isPostHogReady) { + posthog.capture(eventName, properties); + return; + } + + eventQueue.push({ eventName, properties, type: "capture" }); +}; + +export const initPostHogClient = (): void => { + const key = POSTHOG_KEY; + + if (!key || typeof window === "undefined" || posthog) { + return; + } + + import("posthog-js") + .then((module: { readonly default: PostHog }): void => { + posthog = module.default; + posthog.init(key, { + api_host: POSTHOG_HOST, + capture_pageleave: true, + capture_pageview: false, + loaded: (loadedPosthog: PostHog): void => { + posthog = loadedPosthog; + isPostHogReady = true; + flushQueue(); + if (env.NODE_ENV === "development") { + console.log("PostHog loaded"); + } + }, + person_profiles: "identified_only", + }); + }) + .catch((error: unknown): void => { + console.error("Failed to load PostHog:", error); + }); +}; + +export const getPostHogClient = (): PostHog | null => posthog; + +const mapAnalyticsEventToPostHogCapture = (event: AnalyticsEvent): PostHogCapture | null => { + switch (event.name) { + case "auth.login": + return { + eventName: "user_login", + properties: { + method: event.method, + timestamp: event.timestamp, + user_id: event.userId, + }, + }; + case "auth.signup": + return { + eventName: "user_signup", + properties: { + method: event.method, + timestamp: event.timestamp, + user_id: event.userId, + }, + }; + case "api_key.created": + return { + eventName: "api_key_created", + properties: { + key_id: event.keyId, + key_name: event.keyName, + source: event.source, + timestamp: event.timestamp, + }, + }; + case "api_key.deleted": + return { + eventName: "api_key_deleted", + properties: { + key_id: event.keyId, + timestamp: event.timestamp, + }, + }; + case "billing.credits_purchased": + return { + eventName: "credits_purchased", + properties: { + amount: event.amount, + plan_type: event.planType, + timestamp: event.timestamp, + transaction_id: event.transactionId, + }, + }; + case "billing.subscription_purchased": + return { + eventName: "subscription_purchased", + properties: { + plan_id: event.planId, + timestamp: event.timestamp, + transaction_id: event.transactionId, + }, + }; + case "billing.checkout_purchase_unknown": + return { + eventName: "checkout_purchase_unknown", + properties: { + amount: event.amount, + plan_id: event.planId, + transaction_id: event.transactionId, + ...event.properties, + timestamp: event.timestamp, + }, + }; + case "billing.checkout_started": + return { + eventName: "checkout_started", + properties: { + checkout_type: event.checkoutType, + ...event.properties, + timestamp: event.timestamp, + }, + }; + case "billing.checkout_canceled": + return { + eventName: "checkout_canceled", + properties: { + checkout_type: event.checkoutType, + timestamp: event.timestamp, + }, + }; + case "billing.buy_credits_clicked": + return { + eventName: "buy_credits_clicked", + properties: { + source: event.source, + timestamp: event.timestamp, + }, + }; + case "marketing.contact_sales_clicked": + return { + eventName: "contact_sales_clicked", + properties: { + source_section: event.sourceSection, + timestamp: event.timestamp, + }, + }; + case "marketing.landing_cta_clicked": + return { + eventName: "landing_cta_clicked", + properties: { + cta_id: event.ctaId, + page_path: event.pagePath, + ...event.properties, + timestamp: event.timestamp, + }, + }; + case "job.created": + return { + eventName: "job_created", + properties: { + job_id: event.jobId, + job_type: event.jobType, + source_type: event.sourceType, + timestamp: event.timestamp, + }, + }; + case "job.completed": + return { + eventName: "job_completed", + properties: { + job_id: event.jobId, + job_type: event.jobType, + processing_time_ms: event.processingTimeMs, + timestamp: event.timestamp, + }, + }; + case "job.failed": + return { + eventName: "job_failed", + properties: { + error_message: event.errorMessage, + job_id: event.jobId, + job_type: event.jobType, + timestamp: event.timestamp, + }, + }; + case "file.uploaded": + return { + eventName: "file_uploaded", + properties: { + file_size: event.fileSize, + file_type: event.fileType, + timestamp: event.timestamp, + upload_method: event.uploadMethod, + }, + }; + case "webhook.configured": + return { + eventName: "webhook_configured", + properties: { + timestamp: event.timestamp, + webhook_url: event.webhookUrl, + }, + }; + case "webhook.secret_revoked": + return { + eventName: "webhook_secret_revoked", + properties: { + secret_id: event.secretId, + timestamp: event.timestamp, + }, + }; + case "error.occurred": + return { + eventName: "error_occurred", + properties: { + error_context: event.errorContext, + error_message: event.errorMessage, + timestamp: event.timestamp, + }, + }; + case "feature.used": + return { + eventName: "feature_used", + properties: { + feature_name: event.featureName, + ...event.properties, + timestamp: event.timestamp, + }, + }; + case "legacy.event": + return { + eventName: event.eventName, + properties: event.properties, + }; + default: + return null; + } +}; + +const identifyPostHogUser = (userId: string, properties?: AnalyticsProperties): void => { + if (typeof window === "undefined") { + return; + } + + if (!isPostHogAdapterEnabled) { + return; + } + + if (posthog && isPostHogReady) { + posthog.identify(userId, properties); + return; + } + + eventQueue.push({ properties, type: "identify", userId }); +}; + +const resetPostHogUser = (): void => { + if (typeof window === "undefined") { + return; + } + + if (!isPostHogAdapterEnabled) { + return; + } + + if (posthog && isPostHogReady) { + posthog.reset(); + return; + } + + eventQueue.push({ type: "reset" }); +}; + +const setPostHogUserProperties = (properties: AnalyticsProperties): void => { + if (typeof window === "undefined" || !isPostHogAdapterEnabled) { + return; + } + + if (posthog && isPostHogReady) { + posthog.people.set(properties); + return; + } + + eventQueue.push({ properties, type: "people.set" }); +}; + +const trackPostHogAnalyticsEvent = (event: AnalyticsEvent): void => { + const capture = mapAnalyticsEventToPostHogCapture(event); + if (!capture) { + return; + } + + capturePostHogEvent(capture.eventName, capture.properties); +}; + +const trackPostHogPageView = (pagePath: string): void => { + capturePostHogEvent("$pageview", { + page: pagePath, + }); +}; + +export function createPostHogAnalyticsAdapter(): AnalyticsAdapter { + return { + name: "posthog", + identifyUser: identifyPostHogUser, + initialize: initPostHogClient, + isEnabled: (): boolean => isPostHogAdapterEnabled, + resetUser: resetPostHogUser, + setUserProperties: setPostHogUserProperties, + trackEvent: trackPostHogAnalyticsEvent, + trackPageView: trackPostHogPageView, + }; +} diff --git a/lib/analytics/client-state.ts b/lib/analytics/client-state.ts new file mode 100644 index 0000000..25b7627 --- /dev/null +++ b/lib/analytics/client-state.ts @@ -0,0 +1,215 @@ +import type { PendingCheckout } from "@/lib/analytics/types"; +import { authRedirect } from "@/lib/auth-redirect"; + +const AUTH_EVENT_TRACKED_KEY = "ph_auth_event_tracked"; +const PENDING_AUTH_LOGIN_KEY = "ph_pending_auth_login"; +const PENDING_MAGIC_LINK_AUTH_KEY = "ph_pending_magic_link_auth"; +const ANALYTICS_AUTH_CALLBACK_URL_KEY = "ph_auth_callback_url"; +const ANALYTICS_AUTH_FLAG_KEY = "ph_auth"; +const PENDING_CHECKOUT_KEY = "ph_pending_checkout"; +const BUY_CREDITS_ENTRY_TS_KEY = "ph_buy_credits_entry_ts"; +const PENDING_OAUTH_TTL_MS = 30 * 60 * 1000; +const PENDING_MAGIC_LINK_TTL_MS = 30 * 60 * 1000; + +export const BUY_CREDITS_DEDUPE_MS = 5000; +export const NEW_USER_WINDOW_MS = 5 * 60 * 1000; + +type SearchParamsLike = { + readonly get: (name: string) => string | null; + readonly toString: () => string; +}; + +export function markAuthEventTracked(): void { + if (typeof window === "undefined") { + return; + } + + sessionStorage.setItem(AUTH_EVENT_TRACKED_KEY, "1"); +} + +export function isAuthEventTracked(): boolean { + if (typeof window === "undefined") { + return false; + } + + return sessionStorage.getItem(AUTH_EVENT_TRACKED_KEY) === "1"; +} + +export function markPendingAuthLogin(): void { + if (typeof window === "undefined") { + return; + } + + sessionStorage.setItem(PENDING_AUTH_LOGIN_KEY, String(Date.now())); +} + +export function hasPendingAuthLogin(): boolean { + if (typeof window === "undefined") { + return false; + } + + const raw = sessionStorage.getItem(PENDING_AUTH_LOGIN_KEY); + if (!raw) { + return false; + } + + const storedAt = Number(raw); + if (Number.isNaN(storedAt) || Date.now() - storedAt > PENDING_OAUTH_TTL_MS) { + sessionStorage.removeItem(PENDING_AUTH_LOGIN_KEY); + return false; + } + + return true; +} + +export function clearPendingAuthLogin(): void { + if (typeof window === "undefined") { + return; + } + + sessionStorage.removeItem(PENDING_AUTH_LOGIN_KEY); +} + +export function markPendingMagicLinkAuth(): void { + if (typeof window === "undefined") { + return; + } + + localStorage.setItem(PENDING_MAGIC_LINK_AUTH_KEY, String(Date.now())); +} + +export function consumePendingMagicLinkAuth(): boolean { + if (typeof window === "undefined") { + return false; + } + + const raw = localStorage.getItem(PENDING_MAGIC_LINK_AUTH_KEY); + if (!raw) { + return false; + } + + localStorage.removeItem(PENDING_MAGIC_LINK_AUTH_KEY); + const storedAt = Number(raw); + if (Number.isNaN(storedAt) || Date.now() - storedAt > PENDING_MAGIC_LINK_TTL_MS) { + return false; + } + + return true; +} + +export function buildAnalyticsAuthCallbackURL(callbackURL: string, flag?: string): string { + if (typeof window === "undefined") { + return callbackURL; + } + + const parsed = new URL(authRedirect.defaultPath, window.location.origin); + parsed.searchParams.set(ANALYTICS_AUTH_CALLBACK_URL_KEY, callbackURL); + + if (flag) { + parsed.searchParams.set(ANALYTICS_AUTH_FLAG_KEY, flag); + } + + return `${parsed.pathname}${parsed.search}`; +} + +export function getAnalyticsAuthCallbackURL( + searchParams: Pick +): string | null { + const callbackURL = searchParams.get(ANALYTICS_AUTH_CALLBACK_URL_KEY); + return authRedirect.getSafeCallbackURL(callbackURL); +} + +export function buildAnalyticsAuthCleanupPath( + pathname: string, + searchParams: SearchParamsLike +): string { + const params = new URLSearchParams(searchParams.toString()); + params.delete(ANALYTICS_AUTH_FLAG_KEY); + params.delete(ANALYTICS_AUTH_CALLBACK_URL_KEY); + + const nextSearch = params.toString(); + return nextSearch ? `${pathname}?${nextSearch}` : pathname; +} + +export function storePendingCheckout(data: PendingCheckout): void { + if (typeof window === "undefined") { + return; + } + + localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(data)); +} + +export function peekPendingCheckout(): PendingCheckout | null { + if (typeof window === "undefined") { + return null; + } + + const raw = localStorage.getItem(PENDING_CHECKOUT_KEY); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as PendingCheckout; + } catch { + return null; + } +} + +export function consumePendingCheckout(): PendingCheckout | null { + const data = peekPendingCheckout(); + if (typeof window !== "undefined") { + localStorage.removeItem(PENDING_CHECKOUT_KEY); + } + + return data; +} + +export function isLikelyNewUser(createdAt?: Date | string): boolean { + if (!createdAt) { + return false; + } + + const created = new Date(createdAt).getTime(); + if (Number.isNaN(created)) { + return false; + } + + return Date.now() - created < NEW_USER_WINDOW_MS; +} + +export function clearAnalyticsAuthAndCheckoutState(): void { + if (typeof window === "undefined") { + return; + } + + sessionStorage.removeItem(AUTH_EVENT_TRACKED_KEY); + sessionStorage.removeItem(PENDING_AUTH_LOGIN_KEY); + sessionStorage.removeItem(BUY_CREDITS_ENTRY_TS_KEY); + localStorage.removeItem(PENDING_MAGIC_LINK_AUTH_KEY); + localStorage.removeItem(PENDING_CHECKOUT_KEY); + + for (const key of Object.keys(localStorage)) { + if (key.startsWith("ph_welcome_api_key_tracked_")) { + localStorage.removeItem(key); + } + } +} + +export function shouldTrackBuyCreditsClick(source: string): boolean { + if (typeof window === "undefined") { + return true; + } + + if (source === "deep_link") { + const lastEntry = sessionStorage.getItem(BUY_CREDITS_ENTRY_TS_KEY); + if (lastEntry && Date.now() - Number(lastEntry) < BUY_CREDITS_DEDUPE_MS) { + return false; + } + + return true; + } + + sessionStorage.setItem(BUY_CREDITS_ENTRY_TS_KEY, String(Date.now())); + return true; +} diff --git a/lib/analytics/core.test.ts b/lib/analytics/core.test.ts new file mode 100644 index 0000000..566cccc --- /dev/null +++ b/lib/analytics/core.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { type AnalyticsAdapter, createAnalyticsController } from "@/lib/analytics/core"; +import type { AnalyticsEvent } from "@/lib/analytics/types"; + +const createTestEvent = (): AnalyticsEvent => ({ + method: "email", + name: "auth.signup", + timestamp: "2026-07-22T00:00:00.000Z", + userId: "user_123", +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("analytics controller", () => { + it("dispatches events to enabled adapters", () => { + const receivedEvents: AnalyticsEvent[] = []; + const adapter: AnalyticsAdapter = { + isEnabled: (): boolean => true, + name: "enabled", + trackEvent: (event: AnalyticsEvent): void => { + receivedEvents.push(event); + }, + }; + const controller = createAnalyticsController([adapter]); + const event = createTestEvent(); + + controller.trackEvent(event); + + expect(receivedEvents).toEqual([event]); + }); + + it("does not dispatch events to disabled adapters", () => { + const trackEvent = vi.fn(); + const adapter: AnalyticsAdapter = { + isEnabled: (): boolean => false, + name: "disabled", + trackEvent, + }; + const controller = createAnalyticsController([adapter]); + + controller.trackEvent(createTestEvent()); + + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it("keeps dispatching when one adapter throws", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation((): void => {}); + const receivedEvents: AnalyticsEvent[] = []; + const failingAdapter: AnalyticsAdapter = { + isEnabled: (): boolean => true, + name: "failing", + trackEvent: (): void => { + throw new Error("adapter failed"); + }, + }; + const healthyAdapter: AnalyticsAdapter = { + isEnabled: (): boolean => true, + name: "healthy", + trackEvent: (event: AnalyticsEvent): void => { + receivedEvents.push(event); + }, + }; + const event = createTestEvent(); + const controller = createAnalyticsController([failingAdapter, healthyAdapter]); + + controller.trackEvent(event); + + expect(receivedEvents).toEqual([event]); + expect(consoleError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/analytics/core.ts b/lib/analytics/core.ts new file mode 100644 index 0000000..8007b98 --- /dev/null +++ b/lib/analytics/core.ts @@ -0,0 +1,125 @@ +import type { AnalyticsEvent, AnalyticsProperties } from "@/lib/analytics/types"; + +export type AnalyticsConfig = { + readonly googleAnalyticsMeasurementId?: string; +}; + +export type AnalyticsAdapter = { + readonly name: string; + readonly identifyUser?: (userId: string, properties?: AnalyticsProperties) => void; + readonly initialize?: (config: AnalyticsConfig) => void; + readonly isEnabled: () => boolean; + readonly resetUser?: () => void; + readonly setUserProperties?: (properties: AnalyticsProperties) => void; + readonly trackEvent?: (event: AnalyticsEvent) => void; + readonly trackPageView?: (pagePath: string) => void; +}; + +export type AnalyticsController = { + readonly hasEnabledEventAdapter: () => boolean; + readonly identifyUser: (userId: string, properties?: AnalyticsProperties) => void; + readonly initialize: (config?: AnalyticsConfig) => void; + readonly resetUser: () => void; + readonly setUserProperties: (properties: AnalyticsProperties) => void; + readonly trackEvent: (event: AnalyticsEvent) => void; + readonly trackPageView: (pagePath: string) => void; +}; + +type AdapterAction = + | "hasEnabledEventAdapter" + | "identifyUser" + | "initialize" + | "isEnabled" + | "resetUser" + | "setUserProperties" + | "trackEvent" + | "trackPageView"; + +const reportAnalyticsAdapterError = ( + adapterName: string, + action: AdapterAction, + error: unknown +): void => { + console.error(`[analytics] ${adapterName} ${action} failed`, error); +}; + +const isAdapterEnabled = (adapter: AnalyticsAdapter): boolean => { + try { + return adapter.isEnabled(); + } catch (error) { + reportAnalyticsAdapterError(adapter.name, "isEnabled", error); + return false; + } +}; + +const runEnabledAdapterAction = ( + adapters: readonly AnalyticsAdapter[], + action: AdapterAction, + callback: (adapter: AnalyticsAdapter) => void +): void => { + for (const adapter of adapters) { + if (!isAdapterEnabled(adapter)) { + continue; + } + + try { + callback(adapter); + } catch (error) { + reportAnalyticsAdapterError(adapter.name, action, error); + } + } +}; + +export function createAnalyticsController( + adapters: readonly AnalyticsAdapter[] +): AnalyticsController { + return { + hasEnabledEventAdapter: (): boolean => { + for (const adapter of adapters) { + if (!adapter.trackEvent) { + continue; + } + + if (isAdapterEnabled(adapter)) { + return true; + } + } + + return false; + }, + identifyUser: (userId: string, properties?: AnalyticsProperties): void => { + runEnabledAdapterAction(adapters, "identifyUser", (adapter: AnalyticsAdapter): void => { + adapter.identifyUser?.(userId, properties); + }); + }, + initialize: (config: AnalyticsConfig = {}): void => { + for (const adapter of adapters) { + try { + adapter.initialize?.(config); + } catch (error) { + reportAnalyticsAdapterError(adapter.name, "initialize", error); + } + } + }, + resetUser: (): void => { + runEnabledAdapterAction(adapters, "resetUser", (adapter: AnalyticsAdapter): void => { + adapter.resetUser?.(); + }); + }, + setUserProperties: (properties: AnalyticsProperties): void => { + runEnabledAdapterAction(adapters, "setUserProperties", (adapter: AnalyticsAdapter): void => { + adapter.setUserProperties?.(properties); + }); + }, + trackEvent: (event: AnalyticsEvent): void => { + runEnabledAdapterAction(adapters, "trackEvent", (adapter: AnalyticsAdapter): void => { + adapter.trackEvent?.(event); + }); + }, + trackPageView: (pagePath: string): void => { + runEnabledAdapterAction(adapters, "trackPageView", (adapter: AnalyticsAdapter): void => { + adapter.trackPageView?.(pagePath); + }); + }, + }; +} diff --git a/lib/analytics/index.ts b/lib/analytics/index.ts new file mode 100644 index 0000000..6340c1b --- /dev/null +++ b/lib/analytics/index.ts @@ -0,0 +1,65 @@ +import { createGoogleAnalyticsAdapter } from "@/lib/analytics/adapters/google-analytics"; +import { createPostHogAnalyticsAdapter } from "@/lib/analytics/adapters/posthog"; +import { type AnalyticsConfig, createAnalyticsController } from "@/lib/analytics/core"; +import type { AnalyticsEvent, AnalyticsProperties } from "@/lib/analytics/types"; + +export { + buildAnalyticsAuthCallbackURL, + buildAnalyticsAuthCleanupPath, + clearAnalyticsAuthAndCheckoutState, + clearPendingAuthLogin, + consumePendingCheckout, + consumePendingMagicLinkAuth, + getAnalyticsAuthCallbackURL, + hasPendingAuthLogin, + isAuthEventTracked, + isLikelyNewUser, + markAuthEventTracked, + markPendingAuthLogin, + markPendingMagicLinkAuth, + NEW_USER_WINDOW_MS, + peekPendingCheckout, + shouldTrackBuyCreditsClick, + storePendingCheckout, +} from "@/lib/analytics/client-state"; +export type { + AnalyticsEvent, + AnalyticsProperties, + AuthMethod, + CheckoutType, + NormalizedCheckoutType, + PendingCheckout, +} from "@/lib/analytics/types"; + +const analyticsController = createAnalyticsController([ + createPostHogAnalyticsAdapter(), + createGoogleAnalyticsAdapter(), +]); + +export function initializeAnalytics(config: AnalyticsConfig = {}): void { + analyticsController.initialize(config); +} + +export function trackAnalyticsEvent(event: AnalyticsEvent): void { + analyticsController.trackEvent(event); +} + +export function trackAnalyticsPageView(pagePath: string): void { + analyticsController.trackPageView(pagePath); +} + +export function identifyAnalyticsUser(userId: string, properties?: AnalyticsProperties): void { + analyticsController.identifyUser(userId, properties); +} + +export function resetAnalyticsUser(): void { + analyticsController.resetUser(); +} + +export function setAnalyticsUserProperties(properties: AnalyticsProperties): void { + analyticsController.setUserProperties(properties); +} + +export function isAnalyticsEventTrackingEnabled(): boolean { + return analyticsController.hasEnabledEventAdapter(); +} diff --git a/lib/analytics/payment-redirect.ts b/lib/analytics/payment-redirect.ts new file mode 100644 index 0000000..eb008ac --- /dev/null +++ b/lib/analytics/payment-redirect.ts @@ -0,0 +1,166 @@ +import { trackAnalyticsEvent } from "@/lib/analytics"; +import { consumePendingCheckout } from "@/lib/analytics/client-state"; +import type { CheckoutType } from "@/lib/analytics/types"; + +export type PaymentRedirectResult = { + readonly handled: boolean; + readonly kind?: "canceled" | "success"; +}; + +type SearchParamsLike = { + readonly get: (name: string) => string | null; +}; + +const PAYMENT_REDIRECT_DEDUPE_KEY = "ph_tracked_payment_redirects"; +const MAX_TRACKED_PAYMENT_REDIRECTS = 50; + +const createTimestamp = (): string => new Date().toISOString(); + +const loadTrackedPaymentRedirects = (): Set => { + if (typeof window === "undefined") { + return new Set(); + } + + try { + const raw = localStorage.getItem(PAYMENT_REDIRECT_DEDUPE_KEY); + if (!raw) { + return new Set(); + } + + const parsed = JSON.parse(raw) as string[]; + return new Set(Array.isArray(parsed) ? parsed : []); + } catch { + return new Set(); + } +}; + +const persistTrackedPaymentRedirects = (trackedRedirects: Set): void => { + if (typeof window === "undefined") { + return; + } + + const entries = Array.from(trackedRedirects).slice(-MAX_TRACKED_PAYMENT_REDIRECTS); + localStorage.setItem(PAYMENT_REDIRECT_DEDUPE_KEY, JSON.stringify(entries)); +}; + +const markPaymentRedirectTracked = ( + kind: "canceled" | "success", + transactionId: string +): boolean => { + if (!transactionId) { + return true; + } + + const dedupeId = `${kind}:${transactionId}`; + const trackedRedirects = loadTrackedPaymentRedirects(); + if (trackedRedirects.has(dedupeId)) { + return false; + } + + trackedRedirects.add(dedupeId); + persistTrackedPaymentRedirects(trackedRedirects); + return true; +}; + +const getCheckoutTypeFromParam = (checkoutTypeParam: string | null): CheckoutType | undefined => { + if (checkoutTypeParam === "credits_package" || checkoutTypeParam === "subscription") { + return checkoutTypeParam; + } + + return undefined; +}; + +const trackSuccessfulPaymentRedirect = (searchParams: SearchParamsLike): PaymentRedirectResult => { + const pendingCheckout = consumePendingCheckout(); + const checkoutType = + getCheckoutTypeFromParam(searchParams.get("type")) ?? pendingCheckout?.checkout_type; + const transactionId = searchParams.get("session_id") || pendingCheckout?.session_id || ""; + const planId = searchParams.get("plan_id") ?? pendingCheckout?.plan_id; + const rawAmount = searchParams.get("amount"); + const amountParam = rawAmount ? Number.parseFloat(rawAmount) : Number.NaN; + const amountFallback = typeof pendingCheckout?.amount === "number" ? pendingCheckout.amount : 0; + const amount = Number.isFinite(amountParam) ? amountParam : amountFallback; + + if (!markPaymentRedirectTracked("success", transactionId)) { + return { handled: true, kind: "success" }; + } + + if (checkoutType === "credits_package") { + trackAnalyticsEvent({ + amount, + name: "billing.credits_purchased", + planType: checkoutType, + timestamp: createTimestamp(), + transactionId, + }); + } else if (checkoutType === "subscription") { + trackAnalyticsEvent({ + name: "billing.subscription_purchased", + planId: planId ?? "unknown", + timestamp: createTimestamp(), + transactionId, + }); + } else if (planId) { + trackAnalyticsEvent({ + name: "billing.subscription_purchased", + planId, + timestamp: createTimestamp(), + transactionId, + }); + } else if (amount > 0) { + trackAnalyticsEvent({ + amount, + name: "billing.credits_purchased", + planType: "unknown", + timestamp: createTimestamp(), + transactionId, + }); + } else { + trackAnalyticsEvent({ + amount, + name: "billing.checkout_purchase_unknown", + planId, + properties: { amount, plan_id: planId }, + timestamp: createTimestamp(), + transactionId, + }); + } + + return { handled: true, kind: "success" }; +}; + +const trackCanceledPaymentRedirect = (searchParams: SearchParamsLike): PaymentRedirectResult => { + const pendingCheckout = consumePendingCheckout(); + const checkoutType = + getCheckoutTypeFromParam(searchParams.get("type")) ?? pendingCheckout?.checkout_type; + const transactionId = searchParams.get("session_id") || pendingCheckout?.session_id || ""; + + if (!markPaymentRedirectTracked("canceled", transactionId)) { + return { handled: true, kind: "canceled" }; + } + + trackAnalyticsEvent({ + checkoutType: checkoutType ?? "unknown", + name: "billing.checkout_canceled", + timestamp: createTimestamp(), + }); + + return { handled: true, kind: "canceled" }; +}; + +export function trackPaymentRedirectFromSearchParams( + searchParams: SearchParamsLike +): PaymentRedirectResult { + const isSuccess = searchParams.get("success") === "true"; + const isCanceled = searchParams.get("canceled") === "true"; + + if (!isSuccess && !isCanceled) { + return { handled: false }; + } + + if (isSuccess) { + return trackSuccessfulPaymentRedirect(searchParams); + } + + return trackCanceledPaymentRedirect(searchParams); +} diff --git a/lib/analytics/types.ts b/lib/analytics/types.ts new file mode 100644 index 0000000..2f345e9 --- /dev/null +++ b/lib/analytics/types.ts @@ -0,0 +1,155 @@ +export type AnalyticsProperties = Record; + +export type AuthMethod = "apple" | "email" | "github" | "google"; + +export type CheckoutType = "credits_package" | "subscription"; + +export type NormalizedCheckoutType = CheckoutType | "unknown"; + +export type PendingCheckout = { + readonly checkout_type: CheckoutType; + readonly session_id: string; + readonly amount?: number; + readonly plan_id?: string; + readonly price_id?: string; +}; + +export type AnalyticsEvent = + | { + readonly name: "auth.login"; + readonly method: AuthMethod; + readonly userId: string; + readonly timestamp: string; + } + | { + readonly name: "auth.signup"; + readonly method: AuthMethod; + readonly userId: string; + readonly timestamp: string; + } + | { + readonly name: "api_key.created"; + readonly keyId: string; + readonly keyName: string; + readonly source: string; + readonly timestamp: string; + } + | { + readonly name: "api_key.deleted"; + readonly keyId: string; + readonly timestamp: string; + } + | { + readonly name: "billing.credits_purchased"; + readonly amount: number; + readonly planType: string; + readonly transactionId: string; + readonly timestamp: string; + } + | { + readonly name: "billing.subscription_purchased"; + readonly planId: string; + readonly transactionId: string; + readonly timestamp: string; + } + | { + readonly name: "billing.checkout_purchase_unknown"; + readonly transactionId: string; + readonly amount?: number; + readonly planId?: string; + readonly properties?: AnalyticsProperties; + readonly timestamp: string; + } + | { + readonly name: "billing.checkout_started"; + readonly checkoutType: CheckoutType; + readonly amount?: number; + readonly planId?: string; + readonly priceId?: string; + readonly sessionId?: string; + readonly properties?: AnalyticsProperties; + readonly timestamp: string; + } + | { + readonly name: "billing.checkout_canceled"; + readonly checkoutType: NormalizedCheckoutType; + readonly timestamp: string; + } + | { + readonly name: "billing.buy_credits_clicked"; + readonly source: string; + readonly timestamp: string; + } + | { + readonly name: "marketing.contact_sales_clicked"; + readonly sourceSection: string; + readonly timestamp: string; + } + | { + readonly name: "marketing.landing_cta_clicked"; + readonly ctaId: string; + readonly pagePath?: string; + readonly properties?: AnalyticsProperties; + readonly sourceSection: string; + readonly timestamp: string; + } + | { + readonly name: "job.created"; + readonly jobType: "kb_management"; + readonly jobId: string; + readonly sourceType: "direct_upload" | "url"; + readonly timestamp: string; + } + | { + readonly name: "job.completed"; + readonly jobType: "kb_management"; + readonly jobId: string; + readonly processingTimeMs: number; + readonly timestamp: string; + } + | { + readonly name: "job.failed"; + readonly jobType: "kb_management"; + readonly jobId: string; + readonly errorMessage: string; + readonly timestamp: string; + } + | { + readonly name: "file.uploaded"; + readonly fileType: string; + readonly fileSize: number; + readonly uploadMethod: "direct" | "url"; + readonly timestamp: string; + } + | { + readonly name: "webhook.configured"; + readonly webhookUrl: string; + readonly timestamp: string; + } + | { + readonly name: "webhook.secret_revoked"; + readonly secretId: string; + readonly timestamp: string; + } + | { + readonly name: "error.occurred"; + readonly errorContext?: AnalyticsProperties; + readonly errorMessage: string; + readonly timestamp: string; + } + | { + readonly name: "feature.used"; + readonly featureName: string; + readonly properties?: AnalyticsProperties; + readonly timestamp: string; + } + | { + readonly name: "playground.parse_started"; + readonly fileName: string; + readonly timestamp: string; + } + | { + readonly name: "legacy.event"; + readonly eventName: string; + readonly properties?: AnalyticsProperties; + }; diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 8e6c1e8..80acfa5 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -50,3 +50,33 @@ export const oauthRefreshToken = pgTable( userIdIndex: index("oauthRefreshToken_userId_idx").on(table.userId), }) ); + +export const marketingAttributionSession = pgTable( + "marketing_attribution_sessions", + { + sessionId: text("session_id") + .primaryKey() + .$defaultFn(() => createId()), + source: text("source").notNull(), + channel: text("channel").notNull(), + utmSource: text("utm_source"), + utmMedium: text("utm_medium"), + utmCampaign: text("utm_campaign"), + utmContent: text("utm_content"), + utmTerm: text("utm_term"), + oppref: text("oppref"), + landingPath: text("landing_path").notNull(), + referrerHost: text("referrer_host"), + capturedAt: timestamp("captured_at", { withTimezone: true }).notNull().defaultNow(), + boundUserId: text("bound_user_id").references(() => user.id, { onDelete: "set null" }), + boundAt: timestamp("bound_at", { withTimezone: true }), + }, + (table) => ({ + boundUserIdIndex: index("marketingAttributionSession_boundUserId_idx").on(table.boundUserId), + capturedAtIndex: index("marketingAttributionSession_capturedAt_idx").on(table.capturedAt), + sourceCampaignIndex: index("marketingAttributionSession_sourceCampaign_idx").on( + table.source, + table.utmCampaign + ), + }) +); diff --git a/lib/google-analytics.ts b/lib/google-analytics.ts index 1a4aac7..8065c51 100644 --- a/lib/google-analytics.ts +++ b/lib/google-analytics.ts @@ -1,57 +1,35 @@ -type GAEventParams = Record; - -declare global { - interface Window { - gtag?: (...args: unknown[]) => void; - } -} - -export const trackGAEvent = (eventName: string, params?: GAEventParams) => { - if (typeof window === "undefined" || typeof window.gtag !== "function") { - return; - } +import { + setGoogleAnalyticsUserId, + trackGoogleAnalyticsEvent, +} from "@/lib/analytics/adapters/google-analytics"; - const cleanedParams = params - ? Object.fromEntries( - Object.entries(params).filter((entry): entry is [string, string | number | boolean] => { - return entry[1] !== undefined; - }) - ) - : undefined; +type GAEventParams = Record; - window.gtag("event", eventName, cleanedParams); +export const trackGAEvent = (eventName: string, params?: GAEventParams): void => { + trackGoogleAnalyticsEvent(eventName, params); }; -export const setGAUserId = (userId: string | null) => { - if (typeof window === "undefined" || typeof window.gtag !== "function") { - return; - } - - if (userId) { - window.gtag("set", { user_id: userId }); - return; - } - - window.gtag("set", { user_id: undefined }); +export const setGAUserId = (userId: string | null): void => { + setGoogleAnalyticsUserId(userId); }; -export const mirrorAuthLogin = (method: string) => { +export const mirrorAuthLogin = (method: string): void => { trackGAEvent("login", { method }); }; -export const mirrorAuthSignUp = (method: string) => { +export const mirrorAuthSignUp = (method: string): void => { trackGAEvent("sign_up", { method }); }; -export const mirrorApiKeyCreated = (source: string) => { +export const mirrorApiKeyCreated = (source: string): void => { trackGAEvent("api_key_created", { source }); }; -export const mirrorBuyCreditsClicked = (source: string) => { +export const mirrorBuyCreditsClicked = (source: string): void => { trackGAEvent("buy_credits_clicked", { source }); }; -export const mirrorLandingCtaClick = (ctaId: string, sourceSection: string) => { +export const mirrorLandingCtaClick = (ctaId: string, sourceSection: string): void => { trackGAEvent("select_content", { content_type: "landing_cta", item_id: ctaId, @@ -59,39 +37,39 @@ export const mirrorLandingCtaClick = (ctaId: string, sourceSection: string) => { }); }; -export const mirrorContactSalesClick = (sourceSection: string) => { +export const mirrorContactSalesClick = (sourceSection: string): void => { trackGAEvent("generate_lead", { lead_source: sourceSection, }); }; -export const mirrorCheckoutStarted = (checkoutType: string) => { +export const mirrorCheckoutStarted = (checkoutType: string): void => { trackGAEvent("begin_checkout", { checkout_type: checkoutType, }); }; -export const mirrorCheckoutCanceled = (checkoutType: string) => { +export const mirrorCheckoutCanceled = (checkoutType: string): void => { trackGAEvent("checkout_canceled", { checkout_type: checkoutType, }); }; -export const mirrorCreditsPurchased = (amount: number, transactionId: string) => { +export const mirrorCreditsPurchased = (amount: number, transactionId: string): void => { trackGAEvent("purchase", { currency: "USD", + item_category: "credits_package", transaction_id: transactionId, value: amount, - item_category: "credits_package", }); }; -export const mirrorSubscriptionPurchased = (planId: string, transactionId: string) => { +export const mirrorSubscriptionPurchased = (planId: string, transactionId: string): void => { trackGAEvent("purchase", { currency: "USD", - transaction_id: transactionId, item_category: "subscription", item_id: planId, + transaction_id: transactionId, }); }; @@ -99,40 +77,40 @@ export const mirrorCheckoutPurchaseUnknown = ( transactionId: string, amount?: number, planId?: string -) => { +): void => { trackGAEvent("checkout_purchase_unknown", { - transaction_id: transactionId, amount, plan_id: planId, + transaction_id: transactionId, }); }; -export const mirrorJobCreated = (sourceType: string) => { +export const mirrorJobCreated = (sourceType: string): void => { trackGAEvent("job_created", { source_type: sourceType, }); }; -export const mirrorJobCompleted = (processingTimeMs: number) => { +export const mirrorJobCompleted = (processingTimeMs: number): void => { trackGAEvent("job_completed", { processing_time_ms: processingTimeMs, }); }; -export const mirrorJobFailed = (errorMessage: string) => { +export const mirrorJobFailed = (errorMessage: string): void => { trackGAEvent("job_failed", { error_message: errorMessage, }); }; -export const mirrorFileUploaded = (fileType: string, uploadMethod: string) => { +export const mirrorFileUploaded = (fileType: string, uploadMethod: string): void => { trackGAEvent("file_uploaded", { file_type: fileType, upload_method: uploadMethod, }); }; -export const mirrorPlaygroundParseStarted = (fileName: string) => { +export const mirrorPlaygroundParseStarted = (fileName: string): void => { trackGAEvent("playground_parse_started", { file_name: fileName, }); diff --git a/lib/posthog-checkout.test.ts b/lib/posthog-checkout.test.ts index b6e788e..ddc00c7 100644 --- a/lib/posthog-checkout.test.ts +++ b/lib/posthog-checkout.test.ts @@ -1,16 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const posthogMocks = vi.hoisted(() => ({ +const analyticsMocks = vi.hoisted(() => ({ + trackAnalyticsEvent: vi.fn(), +})); +const clientStateMocks = vi.hoisted(() => ({ consumePendingCheckout: vi.fn(), - trackCheckoutCanceled: vi.fn(), - trackCheckoutPurchaseUnknown: vi.fn(), - trackCreditsPurchased: vi.fn(), - trackSubscriptionPurchased: vi.fn(), })); -vi.mock("@lib/posthog", () => posthogMocks); +vi.mock("@/lib/analytics", () => analyticsMocks); +vi.mock("@/lib/analytics/client-state", () => clientStateMocks); -import { trackPaymentRedirectFromSearchParams } from "@/lib/posthog-checkout"; +import { trackPaymentRedirectFromSearchParams } from "@/lib/analytics/payment-redirect"; const stubLocalStorage = () => { const storage = new Map(); @@ -28,10 +28,10 @@ const stubLocalStorage = () => { vi.stubGlobal("localStorage", localStorageMock); }; -describe("payment redirect PostHog tracking", () => { +describe("payment redirect analytics tracking", () => { beforeEach(() => { vi.clearAllMocks(); - posthogMocks.consumePendingCheckout.mockReturnValue(null); + clientStateMocks.consumePendingCheckout.mockReturnValue(null); stubLocalStorage(); }); @@ -53,11 +53,14 @@ describe("payment redirect PostHog tracking", () => { kind: "success", }); - expect(posthogMocks.trackCreditsPurchased).toHaveBeenCalledTimes(1); - expect(posthogMocks.trackCreditsPurchased).toHaveBeenCalledWith( - 20, - "credits_package", - "cs_test_123" + expect(analyticsMocks.trackAnalyticsEvent).toHaveBeenCalledTimes(1); + expect(analyticsMocks.trackAnalyticsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 20, + name: "billing.credits_purchased", + planType: "credits_package", + transactionId: "cs_test_123", + }) ); }); @@ -75,7 +78,12 @@ describe("payment redirect PostHog tracking", () => { kind: "canceled", }); - expect(posthogMocks.trackCheckoutCanceled).toHaveBeenCalledTimes(1); - expect(posthogMocks.trackCheckoutCanceled).toHaveBeenCalledWith("subscription"); + expect(analyticsMocks.trackAnalyticsEvent).toHaveBeenCalledTimes(1); + expect(analyticsMocks.trackAnalyticsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + checkoutType: "subscription", + name: "billing.checkout_canceled", + }) + ); }); }); diff --git a/lib/posthog-checkout.ts b/lib/posthog-checkout.ts index 70f0b2c..b1f8822 100644 --- a/lib/posthog-checkout.ts +++ b/lib/posthog-checkout.ts @@ -1,123 +1,2 @@ -import { - type CheckoutType, - consumePendingCheckout, - trackCheckoutCanceled, - trackCheckoutPurchaseUnknown, - trackCreditsPurchased, - trackSubscriptionPurchased, -} from "@lib/posthog"; - -export type PaymentRedirectResult = { - handled: boolean; - kind?: "success" | "canceled"; -}; - -type SearchParamsLike = { - get(name: string): string | null; -}; - -const PAYMENT_REDIRECT_DEDUPE_KEY = "ph_tracked_payment_redirects"; -const MAX_TRACKED_PAYMENT_REDIRECTS = 50; - -const loadTrackedPaymentRedirects = (): Set => { - if (typeof window === "undefined") { - return new Set(); - } - - try { - const raw = localStorage.getItem(PAYMENT_REDIRECT_DEDUPE_KEY); - if (!raw) { - return new Set(); - } - - const parsed = JSON.parse(raw) as string[]; - return new Set(Array.isArray(parsed) ? parsed : []); - } catch { - return new Set(); - } -}; - -const persistTrackedPaymentRedirects = (trackedRedirects: Set) => { - if (typeof window === "undefined") { - return; - } - - const entries = Array.from(trackedRedirects).slice(-MAX_TRACKED_PAYMENT_REDIRECTS); - localStorage.setItem(PAYMENT_REDIRECT_DEDUPE_KEY, JSON.stringify(entries)); -}; - -const markPaymentRedirectTracked = (kind: "success" | "canceled", transactionId: string) => { - if (!transactionId) { - return true; - } - - const dedupeId = `${kind}:${transactionId}`; - const trackedRedirects = loadTrackedPaymentRedirects(); - if (trackedRedirects.has(dedupeId)) { - return false; - } - - trackedRedirects.add(dedupeId); - persistTrackedPaymentRedirects(trackedRedirects); - return true; -}; - -export const trackPaymentRedirectFromSearchParams = ( - searchParams: SearchParamsLike -): PaymentRedirectResult => { - const isSuccess = searchParams.get("success") === "true"; - const isCanceled = searchParams.get("canceled") === "true"; - - if (!isSuccess && !isCanceled) { - return { handled: false }; - } - - const checkoutTypeParam = searchParams.get("type"); - const sessionId = searchParams.get("session_id") ?? ""; - - if (isSuccess) { - const pendingCheckout = consumePendingCheckout(); - const checkoutType = - checkoutTypeParam === "credits_package" || checkoutTypeParam === "subscription" - ? checkoutTypeParam - : pendingCheckout?.checkout_type; - const transactionId = sessionId || pendingCheckout?.session_id || ""; - const planId = searchParams.get("plan_id") ?? pendingCheckout?.plan_id; - const rawAmount = searchParams.get("amount"); - const amountParam = rawAmount ? Number.parseFloat(rawAmount) : Number.NaN; - const amountFallback = typeof pendingCheckout?.amount === "number" ? pendingCheckout.amount : 0; - const amount = Number.isFinite(amountParam) ? amountParam : amountFallback; - - if (!markPaymentRedirectTracked("success", transactionId)) { - return { handled: true, kind: "success" }; - } - - if (checkoutType === "credits_package") { - trackCreditsPurchased(amount, checkoutType, transactionId); - } else if (checkoutType === "subscription") { - trackSubscriptionPurchased(planId ?? "unknown", transactionId); - } else if (planId) { - trackSubscriptionPurchased(planId, transactionId); - } else if (amount > 0) { - trackCreditsPurchased(amount, "unknown", transactionId); - } else { - trackCheckoutPurchaseUnknown(transactionId, { amount, plan_id: planId }); - } - - return { handled: true, kind: "success" }; - } - - const pendingCheckout = consumePendingCheckout(); - const checkoutType: CheckoutType | undefined = - checkoutTypeParam === "subscription" || checkoutTypeParam === "credits_package" - ? checkoutTypeParam - : pendingCheckout?.checkout_type; - const transactionId = sessionId || pendingCheckout?.session_id || ""; - if (!markPaymentRedirectTracked("canceled", transactionId)) { - return { handled: true, kind: "canceled" }; - } - - trackCheckoutCanceled(checkoutType); - - return { handled: true, kind: "canceled" }; -}; +export type { PaymentRedirectResult } from "@/lib/analytics/payment-redirect"; +export { trackPaymentRedirectFromSearchParams } from "@/lib/analytics/payment-redirect"; diff --git a/lib/posthog.ts b/lib/posthog.ts index 2a8f239..b91e7f4 100644 --- a/lib/posthog.ts +++ b/lib/posthog.ts @@ -1,547 +1,281 @@ -/** - * PostHog 用户行为追踪 - 官方推荐实现 - */ - import type { PostHog } from "posthog-js"; -import { authRedirect } from "@/lib/auth-redirect"; -import { env } from "@/lib/env"; +import { requestAcquisitionSessionBind } from "@/lib/acquisition-attribution/client"; +import { + type AnalyticsProperties, + type AuthMethod, + buildAnalyticsAuthCallbackURL, + buildAnalyticsAuthCleanupPath, + type CheckoutType, + clearAnalyticsAuthAndCheckoutState, + clearPendingAuthLogin, + consumePendingCheckout, + consumePendingMagicLinkAuth, + getAnalyticsAuthCallbackURL, + hasPendingAuthLogin, + identifyAnalyticsUser, + isAuthEventTracked, + isLikelyNewUser, + markAuthEventTracked, + markPendingAuthLogin, + markPendingMagicLinkAuth, + type PendingCheckout, + peekPendingCheckout, + resetAnalyticsUser, + setAnalyticsUserProperties, + shouldTrackBuyCreditsClick, + storePendingCheckout, + trackAnalyticsEvent, + trackAnalyticsPageView, +} from "@/lib/analytics"; import { - mirrorApiKeyCreated, - mirrorAuthLogin, - mirrorAuthSignUp, - mirrorBuyCreditsClicked, - mirrorCheckoutCanceled, - mirrorCheckoutPurchaseUnknown, - mirrorCheckoutStarted, - mirrorContactSalesClick, - mirrorCreditsPurchased, - mirrorFileUploaded, - mirrorJobCompleted, - mirrorJobCreated, - mirrorJobFailed, - mirrorLandingCtaClick, - mirrorSubscriptionPurchased, - setGAUserId, -} from "@/lib/google-analytics"; + getPostHogClient, + initPostHogClient, + isPostHogAdapterEnabled, +} from "@/lib/analytics/adapters/posthog"; import { clearTrackedJobEvents } from "@/lib/job-posthog-tracking"; -const POSTHOG_KEY = env.NEXT_PUBLIC_POSTHOG_KEY; -const POSTHOG_HOST = env.NEXT_PUBLIC_POSTHOG_HOST; -export const isPostHogEnabled = Boolean(POSTHOG_KEY); - -let posthog: PostHog | null = null; -let isPostHogReady = false; - -type QueuedAction = - | { type: "capture"; eventName: string; properties?: Record } - | { type: "identify"; userId: string; userProperties?: Record } - | { type: "reset" } - | { type: "people.set"; properties: Record }; - -const eventQueue: QueuedAction[] = []; - -const AUTH_EVENT_TRACKED_KEY = "ph_auth_event_tracked"; -const PENDING_AUTH_LOGIN_KEY = "ph_pending_auth_login"; -const PENDING_MAGIC_LINK_AUTH_KEY = "ph_pending_magic_link_auth"; -const POSTHOG_AUTH_CALLBACK_URL_KEY = "ph_auth_callback_url"; -const POSTHOG_AUTH_FLAG_KEY = "ph_auth"; -const PENDING_CHECKOUT_KEY = "ph_pending_checkout"; -const BUY_CREDITS_ENTRY_TS_KEY = "ph_buy_credits_entry_ts"; -const BUY_CREDITS_DEDUPE_MS = 5000; -const PENDING_OAUTH_TTL_MS = 30 * 60 * 1000; -const PENDING_MAGIC_LINK_TTL_MS = 30 * 60 * 1000; -export const NEW_USER_WINDOW_MS = 5 * 60 * 1000; - -export type CheckoutType = "credits_package" | "subscription"; - -const timestamp = () => new Date().toISOString(); - -export const markAuthEventTracked = () => { - if (typeof window === "undefined") { - return; - } - - sessionStorage.setItem(AUTH_EVENT_TRACKED_KEY, "1"); -}; - -export const isAuthEventTracked = () => { - if (typeof window === "undefined") { - return false; - } - - return sessionStorage.getItem(AUTH_EVENT_TRACKED_KEY) === "1"; -}; - -export const markPendingAuthLogin = () => { - if (typeof window === "undefined") { - return; - } - - sessionStorage.setItem(PENDING_AUTH_LOGIN_KEY, String(Date.now())); -}; - -export const hasPendingAuthLogin = () => { - if (typeof window === "undefined") { - return false; - } - - const raw = sessionStorage.getItem(PENDING_AUTH_LOGIN_KEY); - if (!raw) { - return false; - } - - const timestamp = Number(raw); - if (Number.isNaN(timestamp) || Date.now() - timestamp > PENDING_OAUTH_TTL_MS) { - sessionStorage.removeItem(PENDING_AUTH_LOGIN_KEY); - return false; - } - - return true; -}; - -export const clearPendingAuthLogin = () => { - if (typeof window === "undefined") { - return; - } - - sessionStorage.removeItem(PENDING_AUTH_LOGIN_KEY); -}; - -export const markPendingMagicLinkAuth = () => { - if (typeof window === "undefined") { - return; - } - - localStorage.setItem(PENDING_MAGIC_LINK_AUTH_KEY, String(Date.now())); -}; - -export const consumePendingMagicLinkAuth = () => { - if (typeof window === "undefined") { - return false; - } - - const raw = localStorage.getItem(PENDING_MAGIC_LINK_AUTH_KEY); - if (!raw) { - return false; - } - - localStorage.removeItem(PENDING_MAGIC_LINK_AUTH_KEY); - const timestamp = Number(raw); - if (Number.isNaN(timestamp) || Date.now() - timestamp > PENDING_MAGIC_LINK_TTL_MS) { - return false; - } - - return true; -}; - -type SearchParamsLike = { - get(name: string): string | null; - toString(): string; -}; - -export const buildPostHogAuthCallbackURL = (callbackURL: string, flag?: string) => { - if (typeof window === "undefined") { - return callbackURL; - } - - const parsed = new URL(authRedirect.defaultPath, window.location.origin); - parsed.searchParams.set(POSTHOG_AUTH_CALLBACK_URL_KEY, callbackURL); - - if (flag) { - parsed.searchParams.set(POSTHOG_AUTH_FLAG_KEY, flag); - } - - return `${parsed.pathname}${parsed.search}`; -}; - -export const getPostHogAuthCallbackURL = (searchParams: Pick) => { - const callbackURL = searchParams.get(POSTHOG_AUTH_CALLBACK_URL_KEY); - return authRedirect.getSafeCallbackURL(callbackURL); -}; - -export const buildPostHogAuthCleanupPath = (pathname: string, searchParams: SearchParamsLike) => { - const params = new URLSearchParams(searchParams.toString()); - params.delete(POSTHOG_AUTH_FLAG_KEY); - params.delete(POSTHOG_AUTH_CALLBACK_URL_KEY); - - const nextSearch = params.toString(); - return nextSearch ? `${pathname}?${nextSearch}` : pathname; -}; - -export type PendingCheckout = { - checkout_type: CheckoutType; - session_id: string; - amount?: number; - plan_id?: string; - price_id?: string; -}; +export type { CheckoutType, PendingCheckout }; -export const storePendingCheckout = (data: PendingCheckout) => { - if (typeof window === "undefined") { - return; - } - - localStorage.setItem(PENDING_CHECKOUT_KEY, JSON.stringify(data)); +export { + buildAnalyticsAuthCallbackURL as buildPostHogAuthCallbackURL, + buildAnalyticsAuthCleanupPath as buildPostHogAuthCleanupPath, + clearPendingAuthLogin, + consumePendingCheckout, + consumePendingMagicLinkAuth, + getAnalyticsAuthCallbackURL as getPostHogAuthCallbackURL, + hasPendingAuthLogin, + initPostHogClient, + isAuthEventTracked, + isLikelyNewUser, + markAuthEventTracked, + markPendingAuthLogin, + markPendingMagicLinkAuth, + peekPendingCheckout, + storePendingCheckout, }; -export const peekPendingCheckout = (): PendingCheckout | null => { - if (typeof window === "undefined") { - return null; - } +export const isPostHogEnabled = isPostHogAdapterEnabled; - const raw = localStorage.getItem(PENDING_CHECKOUT_KEY); - if (!raw) { - return null; - } +const createTimestamp = (): string => new Date().toISOString(); - try { - return JSON.parse(raw) as PendingCheckout; - } catch { - return null; - } +const getStringProperty = ( + properties: AnalyticsProperties | undefined, + key: string +): string | undefined => { + const value = properties?.[key]; + return typeof value === "string" ? value : undefined; }; -export const consumePendingCheckout = (): PendingCheckout | null => { - const data = peekPendingCheckout(); - if (typeof window !== "undefined") { - localStorage.removeItem(PENDING_CHECKOUT_KEY); - } - - return data; +const getNumberProperty = ( + properties: AnalyticsProperties | undefined, + key: string +): number | undefined => { + const value = properties?.[key]; + return typeof value === "number" ? value : undefined; }; -export const isLikelyNewUser = (createdAt?: Date | string) => { - if (!createdAt) { - return false; - } - - const created = new Date(createdAt).getTime(); - if (Number.isNaN(created)) { - return false; - } - - return Date.now() - created < NEW_USER_WINDOW_MS; -}; - -const clearAuthTrackingState = () => { - if (typeof window === "undefined") { - return; - } - - sessionStorage.removeItem(AUTH_EVENT_TRACKED_KEY); - sessionStorage.removeItem(PENDING_AUTH_LOGIN_KEY); - localStorage.removeItem(PENDING_MAGIC_LINK_AUTH_KEY); - localStorage.removeItem(PENDING_CHECKOUT_KEY); +const clearAuthTrackingState = (): void => { + clearAnalyticsAuthAndCheckoutState(); clearTrackedJobEvents(); - sessionStorage.removeItem(BUY_CREDITS_ENTRY_TS_KEY); - - for (const key of Object.keys(localStorage)) { - if (key.startsWith("ph_welcome_api_key_tracked_")) { - localStorage.removeItem(key); - } - } -}; - -const flushQueue = () => { - if (!posthog || !isPostHogReady) { - return; - } - - while (eventQueue.length > 0) { - const action = eventQueue.shift(); - if (!action) { - continue; - } - - switch (action.type) { - case "capture": - posthog.capture(action.eventName, action.properties); - break; - case "identify": - posthog.identify(action.userId, action.userProperties); - setGAUserId(action.userId); - break; - case "reset": - posthog.reset(); - break; - case "people.set": - posthog.people.set(action.properties); - break; - default: - break; - } - } -}; - -const captureEvent = (eventName: string, properties?: Record) => { - if (typeof window === "undefined" || !isPostHogEnabled) { - return; - } - - if (posthog && isPostHogReady) { - posthog.capture(eventName, properties); - return; - } - - eventQueue.push({ type: "capture", eventName, properties }); }; -const initPostHog = () => { - const key = POSTHOG_KEY; - - if (!key || typeof window === "undefined" || posthog) { - return; - } - - import("posthog-js") - .then((module) => { - posthog = module.default; - posthog.init(key, { - api_host: POSTHOG_HOST, - person_profiles: "identified_only", - capture_pageview: false, - capture_pageleave: true, - loaded: (loadedPosthog: PostHog) => { - posthog = loadedPosthog; - isPostHogReady = true; - flushQueue(); - if (env.NODE_ENV === "development") { - console.log("PostHog loaded"); - } - }, - }); - }) - .catch((error) => { - console.error("Failed to load PostHog:", error); - }); +export const identifyUser = (userId: string, userProperties?: AnalyticsProperties): void => { + identifyAnalyticsUser(userId, userProperties); }; -const getPostHog = () => posthog; - -export const initPostHogClient = initPostHog; - -export const identifyUser = (userId: string, userProperties?: Record) => { - if (typeof window === "undefined") { - return; - } - - setGAUserId(userId); - - if (!isPostHogEnabled) { - return; - } - - if (posthog && isPostHogReady) { - posthog.identify(userId, userProperties); - return; - } - - eventQueue.push({ type: "identify", userId, userProperties }); -}; - -export const resetUser = () => { - if (typeof window === "undefined") { - return; - } - - setGAUserId(null); +export const resetUser = (): void => { clearAuthTrackingState(); + resetAnalyticsUser(); +}; - if (!isPostHogEnabled) { - return; - } +export const trackPageView = (pageName?: string): void => { + const pagePath = + pageName || (typeof window !== "undefined" ? window.location.pathname : undefined); - if (posthog && isPostHogReady) { - posthog.reset(); + if (!pagePath) { return; } - eventQueue.push({ type: "reset" }); + trackAnalyticsPageView(pagePath); }; -export const trackPageView = (pageName?: string) => { - captureEvent("$pageview", { - page: pageName || (typeof window !== "undefined" ? window.location.pathname : undefined), +export const trackEvent = (eventName: string, properties?: AnalyticsProperties): void => { + trackAnalyticsEvent({ + eventName, + name: "legacy.event", + properties, }); }; -export const trackEvent = (eventName: string, properties?: Record) => { - captureEvent(eventName, properties); +export const setUserProperties = (properties: AnalyticsProperties): void => { + setAnalyticsUserProperties(properties); }; -export const setUserProperties = (properties: Record) => { - if (typeof window === "undefined" || !isPostHogEnabled) { - return; - } - - if (posthog && isPostHogReady) { - posthog.people.set(properties); - return; - } - - eventQueue.push({ type: "people.set", properties }); -}; - -export const trackLogin = (method: "google" | "github" | "apple" | "email", userId: string) => { +export const trackLogin = (method: AuthMethod, userId: string): void => { clearPendingAuthLogin(); markAuthEventTracked(); - mirrorAuthLogin(method); - trackEvent("user_login", { + trackAnalyticsEvent({ method, - user_id: userId, - timestamp: timestamp(), + name: "auth.login", + timestamp: createTimestamp(), + userId, }); }; -export const trackSignUp = (method: "google" | "github" | "apple" | "email", userId: string) => { +export const trackSignUp = (method: AuthMethod, userId: string): void => { clearPendingAuthLogin(); markAuthEventTracked(); - mirrorAuthSignUp(method); - trackEvent("user_signup", { + trackAnalyticsEvent({ method, - user_id: userId, - timestamp: timestamp(), + name: "auth.signup", + timestamp: createTimestamp(), + userId, + }); + void requestAcquisitionSessionBind({ userId }).catch((error: unknown): void => { + console.error("Failed to bind acquisition attribution session:", error); }); }; -export const trackApiKeyCreated = (keyId: string, keyName: string, source = "dashboard") => { - mirrorApiKeyCreated(source); - trackEvent("api_key_created", { - key_id: keyId, - key_name: keyName, +export const trackApiKeyCreated = (keyId: string, keyName: string, source = "dashboard"): void => { + trackAnalyticsEvent({ + keyId, + keyName, + name: "api_key.created", source, - timestamp: timestamp(), + timestamp: createTimestamp(), }); }; -export const trackApiKeyDeleted = (keyId: string) => { - trackEvent("api_key_deleted", { - key_id: keyId, - timestamp: timestamp(), +export const trackApiKeyDeleted = (keyId: string): void => { + trackAnalyticsEvent({ + keyId, + name: "api_key.deleted", + timestamp: createTimestamp(), }); }; -export const trackCreditsPurchased = (amount: number, planType: string, transactionId: string) => { - mirrorCreditsPurchased(amount, transactionId); - trackEvent("credits_purchased", { +export const trackCreditsPurchased = ( + amount: number, + planType: string, + transactionId: string +): void => { + trackAnalyticsEvent({ amount, - plan_type: planType, - transaction_id: transactionId, - timestamp: timestamp(), + name: "billing.credits_purchased", + planType, + timestamp: createTimestamp(), + transactionId, }); }; -export const trackSubscriptionPurchased = (planId: string, transactionId?: string) => { - mirrorSubscriptionPurchased(planId, transactionId ?? ""); - trackEvent("subscription_purchased", { - plan_id: planId, - transaction_id: transactionId ?? "", - timestamp: timestamp(), +export const trackSubscriptionPurchased = (planId: string, transactionId?: string): void => { + trackAnalyticsEvent({ + name: "billing.subscription_purchased", + planId, + timestamp: createTimestamp(), + transactionId: transactionId ?? "", }); }; export const trackCheckoutPurchaseUnknown = ( transactionId: string, - properties?: Record -) => { - const amount = typeof properties?.amount === "number" ? properties.amount : undefined; - const planId = typeof properties?.plan_id === "string" ? properties.plan_id : undefined; - - mirrorCheckoutPurchaseUnknown(transactionId, amount, planId); - trackEvent("checkout_purchase_unknown", { - transaction_id: transactionId, - amount, - plan_id: planId, - ...properties, - timestamp: timestamp(), + properties?: AnalyticsProperties +): void => { + trackAnalyticsEvent({ + amount: getNumberProperty(properties, "amount"), + name: "billing.checkout_purchase_unknown", + planId: getStringProperty(properties, "plan_id"), + properties, + timestamp: createTimestamp(), + transactionId, }); }; export const trackCheckoutStarted = ( checkoutType: CheckoutType, - properties?: Record -) => { - mirrorCheckoutStarted(checkoutType); - trackEvent("checkout_started", { - checkout_type: checkoutType, - ...properties, - timestamp: timestamp(), + properties?: AnalyticsProperties +): void => { + const sessionId = getStringProperty(properties, "session_id") ?? ""; + const amount = getNumberProperty(properties, "amount"); + const planId = getStringProperty(properties, "plan_id"); + const priceId = getStringProperty(properties, "price_id"); + + trackAnalyticsEvent({ + amount, + checkoutType, + name: "billing.checkout_started", + planId, + priceId, + properties, + sessionId, + timestamp: createTimestamp(), }); storePendingCheckout({ + amount, checkout_type: checkoutType, - session_id: String(properties?.session_id ?? ""), - amount: typeof properties?.amount === "number" ? properties.amount : undefined, - plan_id: typeof properties?.plan_id === "string" ? properties.plan_id : undefined, - price_id: typeof properties?.price_id === "string" ? properties.price_id : undefined, + plan_id: planId, + price_id: priceId, + session_id: sessionId, }); }; -export const trackCheckoutCanceled = (checkoutType?: CheckoutType) => { - const normalizedType = checkoutType ?? "unknown"; - mirrorCheckoutCanceled(normalizedType); - trackEvent("checkout_canceled", { - checkout_type: normalizedType, - timestamp: timestamp(), +export const trackCheckoutCanceled = (checkoutType?: CheckoutType): void => { + trackAnalyticsEvent({ + checkoutType: checkoutType ?? "unknown", + name: "billing.checkout_canceled", + timestamp: createTimestamp(), }); }; -export const trackBuyCreditsClicked = (source: string) => { - if (typeof window !== "undefined") { - if (source === "deep_link") { - const lastEntry = sessionStorage.getItem(BUY_CREDITS_ENTRY_TS_KEY); - if (lastEntry && Date.now() - Number(lastEntry) < BUY_CREDITS_DEDUPE_MS) { - return; - } - } else { - sessionStorage.setItem(BUY_CREDITS_ENTRY_TS_KEY, String(Date.now())); - } +export const trackBuyCreditsClicked = (source: string): void => { + if (!shouldTrackBuyCreditsClick(source)) { + return; } - mirrorBuyCreditsClicked(source); - trackEvent("buy_credits_clicked", { + trackAnalyticsEvent({ + name: "billing.buy_credits_clicked", source, - timestamp: timestamp(), + timestamp: createTimestamp(), }); }; -export const trackContactSalesClicked = (sourceSection: string) => { - mirrorContactSalesClick(sourceSection); - trackEvent("contact_sales_clicked", { - source_section: sourceSection, - timestamp: timestamp(), +export const trackContactSalesClicked = (sourceSection: string): void => { + trackAnalyticsEvent({ + name: "marketing.contact_sales_clicked", + sourceSection, + timestamp: createTimestamp(), }); }; -export const trackLandingCtaClick = (ctaId: string, properties?: Record) => { +export const trackLandingCtaClick = (ctaId: string, properties?: AnalyticsProperties): void => { const defaultPagePath = typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}` : undefined; - - trackEvent("landing_cta_clicked", { - cta_id: ctaId, - page_path: defaultPagePath, - ...properties, - timestamp: timestamp(), + const sourceSection = getStringProperty(properties, "source_section") ?? "unknown"; + + trackAnalyticsEvent({ + ctaId, + name: "marketing.landing_cta_clicked", + pagePath: defaultPagePath, + properties, + sourceSection, + timestamp: createTimestamp(), }); - - const sourceSection = - typeof properties?.source_section === "string" ? properties.source_section : "unknown"; - mirrorLandingCtaClick(ctaId, sourceSection); }; export const trackJobCreated = ( jobType: "kb_management", jobId: string, sourceType: "direct_upload" | "url" -) => { - mirrorJobCreated(sourceType); - trackEvent("job_created", { - job_type: jobType, - job_id: jobId, - source_type: sourceType, - timestamp: timestamp(), +): void => { + trackAnalyticsEvent({ + jobId, + jobType, + name: "job.created", + sourceType, + timestamp: createTimestamp(), }); }; @@ -549,23 +283,27 @@ export const trackJobCompleted = ( jobType: "kb_management", jobId: string, processingTimeMs: number -) => { - mirrorJobCompleted(processingTimeMs); - trackEvent("job_completed", { - job_type: jobType, - job_id: jobId, - processing_time_ms: processingTimeMs, - timestamp: timestamp(), +): void => { + trackAnalyticsEvent({ + jobId, + jobType, + name: "job.completed", + processingTimeMs, + timestamp: createTimestamp(), }); }; -export const trackJobFailed = (jobType: "kb_management", jobId: string, errorMessage: string) => { - mirrorJobFailed(errorMessage); - trackEvent("job_failed", { - job_type: jobType, - job_id: jobId, - error_message: errorMessage, - timestamp: timestamp(), +export const trackJobFailed = ( + jobType: "kb_management", + jobId: string, + errorMessage: string +): void => { + trackAnalyticsEvent({ + errorMessage, + jobId, + jobType, + name: "job.failed", + timestamp: createTimestamp(), }); }; @@ -573,44 +311,50 @@ export const trackFileUpload = ( fileType: string, fileSize: number, uploadMethod: "direct" | "url" -) => { - mirrorFileUploaded(fileType, uploadMethod); - trackEvent("file_uploaded", { - file_type: fileType, - file_size: fileSize, - upload_method: uploadMethod, - timestamp: timestamp(), +): void => { + trackAnalyticsEvent({ + fileSize, + fileType, + name: "file.uploaded", + timestamp: createTimestamp(), + uploadMethod, }); }; -export const trackWebhookConfigured = (webhookUrl: string) => { - trackEvent("webhook_configured", { - webhook_url: webhookUrl, - timestamp: timestamp(), +export const trackWebhookConfigured = (webhookUrl: string): void => { + trackAnalyticsEvent({ + name: "webhook.configured", + timestamp: createTimestamp(), + webhookUrl, }); }; -export const trackWebhookSecretRevoked = (secretId: string) => { - trackEvent("webhook_secret_revoked", { - secret_id: secretId, - timestamp: timestamp(), +export const trackWebhookSecretRevoked = (secretId: string): void => { + trackAnalyticsEvent({ + name: "webhook.secret_revoked", + secretId, + timestamp: createTimestamp(), }); }; -export const trackError = (errorMessage: string, errorContext?: Record) => { - trackEvent("error_occurred", { - error_message: errorMessage, - error_context: errorContext, - timestamp: timestamp(), +export const trackError = (errorMessage: string, errorContext?: AnalyticsProperties): void => { + trackAnalyticsEvent({ + errorContext, + errorMessage, + name: "error.occurred", + timestamp: createTimestamp(), }); }; -export const trackFeatureUsage = (featureName: string, properties?: Record) => { - trackEvent("feature_used", { - feature_name: featureName, - ...properties, - timestamp: timestamp(), +export const trackFeatureUsage = (featureName: string, properties?: AnalyticsProperties): void => { + trackAnalyticsEvent({ + featureName, + name: "feature.used", + properties, + timestamp: createTimestamp(), }); }; +const getPostHog = (): PostHog | null => getPostHogClient(); + export default getPostHog; diff --git a/providers/acquisition-attribution-provider.tsx b/providers/acquisition-attribution-provider.tsx new file mode 100644 index 0000000..0412d09 --- /dev/null +++ b/providers/acquisition-attribution-provider.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { + requestAcquisitionSessionCapture, + shouldCaptureAcquisitionPath, +} from "@lib/acquisition-attribution/client"; +import { usePathname, useSearchParams } from "next/navigation"; +import { useEffect, useRef } from "react"; + +export function AcquisitionAttributionProvider() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const search = searchParams.toString(); + const capturedLandingUrlRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + if (!shouldCaptureAcquisitionPath(pathname)) { + return; + } + + const landingUrl = `${window.location.origin}${pathname}${search ? `?${search}` : ""}`; + if (capturedLandingUrlRef.current === landingUrl) { + return; + } + + capturedLandingUrlRef.current = landingUrl; + + void requestAcquisitionSessionCapture({ + landingUrl, + referrer: document.referrer || undefined, + }).catch((error: unknown): void => { + console.error("Failed to capture acquisition attribution session:", error); + }); + }, [pathname, search]); + + return null; +} diff --git a/providers/analytics-auth-sync.tsx b/providers/analytics-auth-sync.tsx new file mode 100644 index 0000000..19cd608 --- /dev/null +++ b/providers/analytics-auth-sync.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { + type AuthMethod, + buildAnalyticsAuthCleanupPath, + clearPendingAuthLogin, + consumePendingMagicLinkAuth, + getAnalyticsAuthCallbackURL, + hasPendingAuthLogin, + identifyAnalyticsUser, + isAuthEventTracked, + isLikelyNewUser, +} from "@lib/analytics"; +import { trackLogin, trackSignUp } from "@lib/posthog"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useRef } from "react"; +import { authClient } from "@/lib/better-auth-client"; + +const EMAIL_PROVIDERS = new Set(["credential", "email", "magic-link"]); + +const resolveLoginMethod = async (): Promise => { + const { data: accounts } = await authClient.listAccounts(); + const oauthAccount = accounts?.find((account) => !EMAIL_PROVIDERS.has(account.providerId)); + + if (oauthAccount?.providerId === "google") { + return "google"; + } + + if (oauthAccount?.providerId === "github") { + return "github"; + } + + if (oauthAccount?.providerId === "apple") { + return "apple"; + } + + return "email"; +}; + +const trackAuthEvent = async (user: { + readonly createdAt?: Date | string; + readonly id: string; +}): Promise => { + if (isAuthEventTracked()) { + return; + } + + const method = await resolveLoginMethod(); + if (isAuthEventTracked()) { + return; + } + + if (isLikelyNewUser(user.createdAt)) { + trackSignUp(method, user.id); + return; + } + + trackLogin(method, user.id); +}; + +export function AnalyticsAuthSync() { + const { data: session, isPending } = authClient.useSession(); + const lastIdentifiedUserId = useRef(null); + const hasHandledMagicLinkParam = useRef(false); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const finishPostAuthRedirect = useCallback((): void => { + const callbackURL = getAnalyticsAuthCallbackURL(searchParams); + if (callbackURL) { + router.replace(callbackURL); + return; + } + + if (searchParams.get("ph_auth")) { + router.replace(buildAnalyticsAuthCleanupPath(pathname, searchParams)); + } + }, [pathname, router, searchParams]); + + useEffect(() => { + if (isPending) { + return; + } + + const user = session?.user; + if (!user?.id) { + lastIdentifiedUserId.current = null; + return; + } + + if (lastIdentifiedUserId.current !== user.id) { + identifyAnalyticsUser(user.id, { + email: user.email, + name: user.name, + }); + lastIdentifiedUserId.current = user.id; + } + }, [isPending, session?.user]); + + useEffect(() => { + if (isPending) { + return; + } + + const user = session?.user; + if (!user?.id) { + return; + } + + const hasPostAuthCallback = Boolean(getAnalyticsAuthCallbackURL(searchParams)); + const phAuth = searchParams.get("ph_auth"); + + if (isAuthEventTracked()) { + finishPostAuthRedirect(); + return; + } + + if (phAuth === "magic" && !hasHandledMagicLinkParam.current) { + hasHandledMagicLinkParam.current = true; + consumePendingMagicLinkAuth(); + void trackAuthEvent(user) + .catch((error: unknown): void => { + console.error("Failed to track magic-link auth event:", error); + }) + .finally(finishPostAuthRedirect); + return; + } + + const hasPendingOAuth = hasPendingAuthLogin(); + const hasPendingMagicLink = consumePendingMagicLinkAuth(); + if (!hasPendingOAuth && !hasPendingMagicLink) { + if (hasPostAuthCallback || phAuth) { + finishPostAuthRedirect(); + } + return; + } + + if (hasPendingOAuth) { + clearPendingAuthLogin(); + } + + void trackAuthEvent(user) + .catch((error: unknown): void => { + console.error("Failed to track auth event:", error); + }) + .finally(finishPostAuthRedirect); + }, [finishPostAuthRedirect, isPending, searchParams, session?.user]); + + return null; +} diff --git a/providers/analytics-provider.tsx b/providers/analytics-provider.tsx new file mode 100644 index 0000000..399bb33 --- /dev/null +++ b/providers/analytics-provider.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { initializeAnalytics, trackAnalyticsPageView } from "@lib/analytics"; +import { AnalyticsAuthSync } from "@providers/analytics-auth-sync"; +import { useAppConfigContext } from "@providers/config-provider"; +import { usePathname, useSearchParams } from "next/navigation"; +import type { ReactNode } from "react"; +import { Suspense, useEffect } from "react"; + +type AnalyticsProviderProps = { + readonly children: ReactNode; +}; + +function AnalyticsPageViewTracker() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { gaMeasurementId } = useAppConfigContext(); + + useEffect(() => { + initializeAnalytics({ googleAnalyticsMeasurementId: gaMeasurementId }); + + const query = searchParams.toString(); + const pagePath = query ? `${pathname}?${query}` : pathname; + trackAnalyticsPageView(pagePath); + }, [gaMeasurementId, pathname, searchParams]); + + return null; +} + +export function AnalyticsProvider({ children }: AnalyticsProviderProps) { + const { gaMeasurementId } = useAppConfigContext(); + + useEffect(() => { + initializeAnalytics({ googleAnalyticsMeasurementId: gaMeasurementId }); + }, [gaMeasurementId]); + + return ( + <> + + + + + {children} + + ); +} diff --git a/providers/authenticated-job-analytics-sync.tsx b/providers/authenticated-job-analytics-sync.tsx new file mode 100644 index 0000000..2e0a657 --- /dev/null +++ b/providers/authenticated-job-analytics-sync.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useJobPosthogTracking } from "@app/(dashboard)/usage/_hooks/use-job-posthog-tracking"; +import { useJobs } from "@app/(dashboard)/usage/_hooks/use-jobs"; +import { isPostHogEnabled } from "@lib/posthog"; +import { useAppConfigContext } from "@providers/config-provider"; +import { authClient } from "@/lib/better-auth-client"; + +export function AuthenticatedJobAnalyticsSync() { + const { data: session, isPending } = authClient.useSession(); + const { gaMeasurementId } = useAppConfigContext(); + const hasProductAnalyticsAdapter = isPostHogEnabled || Boolean(gaMeasurementId); + const shouldTrack = Boolean(session?.user?.id) && !isPending && hasProductAnalyticsAdapter; + + const { data } = useJobs({ + enabled: shouldTrack, + page: 1, + pageSize: 20, + recentDays: 1, + }); + + useJobPosthogTracking(data?.jobs ?? [], shouldTrack && data !== undefined); + + return null; +} diff --git a/providers/providers.tsx b/providers/providers.tsx index 92e2570..0c18f84 100644 --- a/providers/providers.tsx +++ b/providers/providers.tsx @@ -2,7 +2,8 @@ import { ErrorBoundary } from "@components/common/error-boundary"; import { Toaster } from "@components/ui/sonner"; -import { AuthenticatedJobPosthogSync } from "@providers/authenticated-job-posthog-sync"; +import { AcquisitionAttributionProvider } from "@providers/acquisition-attribution-provider"; +import { AuthenticatedJobAnalyticsSync } from "@providers/authenticated-job-analytics-sync"; import { QueryProvider } from "@providers/query-provider"; import { TimezoneSync } from "@providers/timezone-sync"; import { NuqsAdapter } from "nuqs/adapters/next/app"; @@ -11,8 +12,9 @@ export function Providers({ children }: { children: React.ReactNode }) { const content = ( + - + {children} diff --git a/server/acquisition-attribution.ts b/server/acquisition-attribution.ts new file mode 100644 index 0000000..6291542 --- /dev/null +++ b/server/acquisition-attribution.ts @@ -0,0 +1,79 @@ +import { db } from "@lib/db"; +import { marketingAttributionSession } from "@lib/db/schema"; +import { createId } from "@paralleldrive/cuid2"; +import { and, eq, isNull } from "drizzle-orm"; +import { + type AcquisitionAttributionRepository, + type AcquisitionAttributionSessionInsert, + type AcquisitionAttributionStoredSession, + type AcquisitionBindInput, + type AcquisitionBindResult, + type AcquisitionCaptureInput, + type AcquisitionCaptureResult, + createAcquisitionAttributionService, +} from "@/lib/acquisition-attribution/core"; + +const repository: AcquisitionAttributionRepository = { + bindSessionToUser: async ({ boundAt, sessionId, userId }): Promise => { + const [boundSession] = await db + .update(marketingAttributionSession) + .set({ + boundAt, + boundUserId: userId, + }) + .where( + and( + eq(marketingAttributionSession.sessionId, sessionId), + isNull(marketingAttributionSession.boundUserId) + ) + ) + .returning({ + sessionId: marketingAttributionSession.sessionId, + }); + + return Boolean(boundSession); + }, + findSessionById: async ( + sessionId: string + ): Promise => { + const session = await db.query.marketingAttributionSession.findFirst({ + columns: { + boundUserId: true, + capturedAt: true, + sessionId: true, + }, + where: eq(marketingAttributionSession.sessionId, sessionId), + }); + + return session ?? null; + }, + insertSession: async (session: AcquisitionAttributionSessionInsert): Promise => { + const [insertedSession] = await db + .insert(marketingAttributionSession) + .values(session) + .onConflictDoNothing() + .returning({ + sessionId: marketingAttributionSession.sessionId, + }); + + return Boolean(insertedSession); + }, +}; + +const service = createAcquisitionAttributionService({ + createSessionId: createId, + getNow: (): Date => new Date(), + repository, +}); + +export function captureAcquisitionSession( + input: AcquisitionCaptureInput +): Promise { + return service.captureAcquisitionSession(input); +} + +export function bindAcquisitionSessionToUser( + input: AcquisitionBindInput +): Promise { + return service.bindAcquisitionSessionToUser(input); +} From 9ade985de8204130a16b8cb0c11adf04e7c2237b Mon Sep 17 00:00:00 2001 From: suguanYang Date: Thu, 23 Jul 2026 22:06:12 +0800 Subject: [PATCH 2/3] Tighten acquisition attribution validation --- lib/acquisition-attribution/client.test.ts | 18 +++++ lib/acquisition-attribution/client.ts | 9 +-- lib/analytics/client-state.test.ts | 78 ++++++++++++++++++++++ lib/analytics/client-state.ts | 30 ++++++++- lib/analytics/payment-redirect.ts | 10 ++- lib/posthog-checkout.test.ts | 18 +++++ 6 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 lib/acquisition-attribution/client.test.ts create mode 100644 lib/analytics/client-state.test.ts diff --git a/lib/acquisition-attribution/client.test.ts b/lib/acquisition-attribution/client.test.ts new file mode 100644 index 0000000..41ac111 --- /dev/null +++ b/lib/acquisition-attribution/client.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { shouldCaptureAcquisitionPath } from "@/lib/acquisition-attribution/client"; + +describe("acquisition attribution client", () => { + it("captures public landing routes", () => { + expect(shouldCaptureAcquisitionPath("/")).toBe(true); + expect(shouldCaptureAcquisitionPath("/claw")).toBe(true); + expect(shouldCaptureAcquisitionPath("/comparison/openai")).toBe(true); + expect(shouldCaptureAcquisitionPath("/versus/chatgpt")).toBe(true); + }); + + it("does not count auth or dashboard routes as landing sessions", () => { + expect(shouldCaptureAcquisitionPath("/register")).toBe(false); + expect(shouldCaptureAcquisitionPath("/login")).toBe(false); + expect(shouldCaptureAcquisitionPath("/forgot-password")).toBe(false); + expect(shouldCaptureAcquisitionPath("/usage")).toBe(false); + }); +}); diff --git a/lib/acquisition-attribution/client.ts b/lib/acquisition-attribution/client.ts index 31e2190..f737a13 100644 --- a/lib/acquisition-attribution/client.ts +++ b/lib/acquisition-attribution/client.ts @@ -7,19 +7,14 @@ type BindAcquisitionSessionRequest = { readonly userId: string; }; -const CAPTURE_PATH_PREFIXES = ["/claw", "/comparison", "/versus"] as const; -const CAPTURE_AUTH_PATHS = new Set(["/forgot-password", "/login", "/register", "/reset-password"]); +const CAPTURE_LANDING_PATH_PREFIXES = ["/claw", "/comparison", "/versus"] as const; export function shouldCaptureAcquisitionPath(pathname: string): boolean { if (pathname === "/") { return true; } - if (CAPTURE_AUTH_PATHS.has(pathname)) { - return true; - } - - return CAPTURE_PATH_PREFIXES.some((prefix: string): boolean => { + return CAPTURE_LANDING_PATH_PREFIXES.some((prefix: string): boolean => { return pathname === prefix || pathname.startsWith(`${prefix}/`); }); } diff --git a/lib/analytics/client-state.test.ts b/lib/analytics/client-state.test.ts new file mode 100644 index 0000000..dd65252 --- /dev/null +++ b/lib/analytics/client-state.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { peekPendingCheckout, storePendingCheckout } from "@/lib/analytics/client-state"; + +const PENDING_CHECKOUT_KEY = "ph_pending_checkout"; + +vi.mock("@/lib/auth-redirect", () => ({ + authRedirect: { + defaultPath: "/login", + getSafeCallbackURL: (): string | null => null, + }, +})); + +function createStorageMock(): Storage { + const storage = new Map(); + + return { + get length(): number { + return storage.size; + }, + clear: (): void => { + storage.clear(); + }, + getItem: (key: string): string | null => storage.get(key) ?? null, + key: (index: number): string | null => Array.from(storage.keys())[index] ?? null, + removeItem: (key: string): void => { + storage.delete(key); + }, + setItem: (key: string, value: string): void => { + storage.set(key, value); + }, + }; +} + +describe("analytics client state", () => { + beforeEach(() => { + const localStorageMock = createStorageMock(); + const sessionStorageMock = createStorageMock(); + + vi.stubGlobal("localStorage", localStorageMock); + vi.stubGlobal("sessionStorage", sessionStorageMock); + vi.stubGlobal("window", { + localStorage: localStorageMock, + sessionStorage: sessionStorageMock, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns pending checkout data only when the stored payload matches the expected shape", () => { + storePendingCheckout({ + amount: 20, + checkout_type: "credits_package", + plan_id: "starter", + price_id: "price_123", + session_id: "cs_123", + }); + + expect(peekPendingCheckout()).toEqual({ + amount: 20, + checkout_type: "credits_package", + plan_id: "starter", + price_id: "price_123", + session_id: "cs_123", + }); + + localStorage.setItem( + PENDING_CHECKOUT_KEY, + JSON.stringify({ + checkout_type: "credits_package", + session_id: 123, + }) + ); + + expect(peekPendingCheckout()).toBeNull(); + }); +}); diff --git a/lib/analytics/client-state.ts b/lib/analytics/client-state.ts index 25b7627..7eb50f9 100644 --- a/lib/analytics/client-state.ts +++ b/lib/analytics/client-state.ts @@ -19,6 +19,33 @@ type SearchParamsLike = { readonly toString: () => string; }; +function isCheckoutType(value: unknown): value is PendingCheckout["checkout_type"] { + return value === "credits_package" || value === "subscription"; +} + +function isOptionalNumber(value: unknown): value is number | undefined { + return value === undefined || (typeof value === "number" && Number.isFinite(value)); +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === "string"; +} + +function isPendingCheckout(value: unknown): value is PendingCheckout { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + return ( + isCheckoutType(record.checkout_type) && + typeof record.session_id === "string" && + isOptionalNumber(record.amount) && + isOptionalString(record.plan_id) && + isOptionalString(record.price_id) + ); +} + export function markAuthEventTracked(): void { if (typeof window === "undefined") { return; @@ -150,7 +177,8 @@ export function peekPendingCheckout(): PendingCheckout | null { } try { - return JSON.parse(raw) as PendingCheckout; + const parsedCheckout: unknown = JSON.parse(raw); + return isPendingCheckout(parsedCheckout) ? parsedCheckout : null; } catch { return null; } diff --git a/lib/analytics/payment-redirect.ts b/lib/analytics/payment-redirect.ts index eb008ac..cb0f2c1 100644 --- a/lib/analytics/payment-redirect.ts +++ b/lib/analytics/payment-redirect.ts @@ -16,6 +16,12 @@ const MAX_TRACKED_PAYMENT_REDIRECTS = 50; const createTimestamp = (): string => new Date().toISOString(); +const isTrackedPaymentRedirectList = (value: unknown): value is readonly string[] => { + return ( + Array.isArray(value) && value.every((entry: unknown): boolean => typeof entry === "string") + ); +}; + const loadTrackedPaymentRedirects = (): Set => { if (typeof window === "undefined") { return new Set(); @@ -27,8 +33,8 @@ const loadTrackedPaymentRedirects = (): Set => { return new Set(); } - const parsed = JSON.parse(raw) as string[]; - return new Set(Array.isArray(parsed) ? parsed : []); + const parsedRedirects: unknown = JSON.parse(raw); + return new Set(isTrackedPaymentRedirectList(parsedRedirects) ? parsedRedirects : []); } catch { return new Set(); } diff --git a/lib/posthog-checkout.test.ts b/lib/posthog-checkout.test.ts index ddc00c7..9637ecd 100644 --- a/lib/posthog-checkout.test.ts +++ b/lib/posthog-checkout.test.ts @@ -86,4 +86,22 @@ describe("payment redirect analytics tracking", () => { }) ); }); + + it("ignores malformed tracked redirect cache entries", () => { + localStorage.setItem( + "ph_tracked_payment_redirects", + JSON.stringify([123, "success:cs_old", null]) + ); + const searchParams = new URLSearchParams( + "success=true&type=credits_package&session_id=cs_new&amount=20" + ); + + expect(trackPaymentRedirectFromSearchParams(searchParams)).toEqual({ + handled: true, + kind: "success", + }); + expect(JSON.parse(localStorage.getItem("ph_tracked_payment_redirects") ?? "[]")).toEqual([ + "success:cs_new", + ]); + }); }); From 17f711ef19429901d525cb9750fe1f8bb16d1e46 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Fri, 24 Jul 2026 10:25:10 +0800 Subject: [PATCH 3/3] Add OpenAI Ads conversion adapter --- .env.example | 2 + README.md | 2 + app/api/openai-ads/conversions/route.ts | 59 +++++++ app/layout.tsx | 18 ++ lib/analytics/adapters/openai-ads.test.ts | 99 +++++++++++ lib/analytics/adapters/openai-ads.ts | 204 ++++++++++++++++++++++ lib/analytics/core.ts | 1 + lib/analytics/index.ts | 2 + lib/config.ts | 3 + lib/env.test.ts | 22 +++ lib/env.ts | 4 + lib/server/openai-ads-conversions.test.ts | 104 +++++++++++ providers/analytics-provider.tsx | 12 +- providers/config-provider.tsx | 1 + server/openai-ads-conversions.ts | 121 +++++++++++++ 15 files changed, 648 insertions(+), 6 deletions(-) create mode 100644 app/api/openai-ads/conversions/route.ts create mode 100644 lib/analytics/adapters/openai-ads.test.ts create mode 100644 lib/analytics/adapters/openai-ads.ts create mode 100644 lib/server/openai-ads-conversions.test.ts create mode 100644 server/openai-ads-conversions.ts diff --git a/.env.example b/.env.example index 6d71646..531c8dc 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,8 @@ APPLE_CLIENT_ID= NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com GA_MEASUREMENT_ID= +OPENAI_ADS_PIXEL_ID= +OPENAI_ADS_CONVERSIONS_API_KEY= # Optional runtime branding COMPANY_NAME=Knowhere AI diff --git a/README.md b/README.md index fced721..c0566d7 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Optional: | --- | --- | | `NEXT_PUBLIC_POSTHOG_KEY`, `NEXT_PUBLIC_POSTHOG_HOST` | PostHog analytics. | | `GA_MEASUREMENT_ID` | Google Analytics measurement ID. | +| `OPENAI_ADS_PIXEL_ID` | OpenAI Ads Measurement Pixel ID. Set to `JDvSf6KLL8Y3e8QJhCmFF3` for the current pixel. | +| `OPENAI_ADS_CONVERSIONS_API_KEY` | Optional OpenAI Ads Conversions API key for server-side conversion delivery. | | `BILLING_ENABLED` | Set to `true` only when the API billing endpoints and payment configuration are available. Defaults to disabled for open-source self-hosted deployments. | | `PASSWORD_LOGIN_ENABLED` | Set to `true` to show the login page's password-login button. Defaults to hidden. | | `AUTH_COOKIE_DOMAIN` | Optional Better Auth parent cookie domain for cross-subdomain sessions. Leave unset for host-only cookies. | diff --git a/app/api/openai-ads/conversions/route.ts b/app/api/openai-ads/conversions/route.ts new file mode 100644 index 0000000..369503f --- /dev/null +++ b/app/api/openai-ads/conversions/route.ts @@ -0,0 +1,59 @@ +import { sendOpenAIAdsConversionEvent } from "@server/openai-ads-conversions"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const openAIAdsEventNameSchema = z.enum([ + "checkout_started", + "lead_created", + "order_created", + "registration_completed", + "subscription_created", +]); + +const openAIAdsConversionDataSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("customer_action"), + }) + .strict(), + z + .object({ + amount: z.number().int().nonnegative().optional(), + currency: z.literal("USD").optional(), + type: z.literal("contents"), + }) + .strict(), + z + .object({ + amount: z.number().int().nonnegative().optional(), + currency: z.literal("USD").optional(), + plan_id: z.string().min(1).optional(), + type: z.literal("plan_enrollment"), + }) + .strict(), +]); + +const openAIAdsConversionRequestSchema = z.object({ + data: openAIAdsConversionDataSchema, + eventId: z.string().min(1), + eventName: openAIAdsEventNameSchema, + sourceUrl: z.url().optional(), + timestamp: z.iso.datetime(), +}); + +export async function POST(request: Request): Promise { + const requestBody = await request.json().catch(() => null); + const parsedRequest = openAIAdsConversionRequestSchema.safeParse(requestBody); + + if (!parsedRequest.success) { + return NextResponse.json({ message: "Invalid OpenAI Ads conversion payload" }, { status: 400 }); + } + + try { + const result = await sendOpenAIAdsConversionEvent(parsedRequest.data); + return NextResponse.json(result, { status: 202 }); + } catch (error: unknown) { + console.error("[openai-ads] failed to send conversion event", error); + return NextResponse.json({ message: "OpenAI Ads conversion delivery failed" }, { status: 502 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 07b3325..ba99049 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -35,6 +35,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo // 在服务端读取环境变量(运行时配置,不带NEXT_PUBLIC_前缀) const appConfig = getDefaultConfig(); const gaMeasurementId = appConfig.gaMeasurementId; + const openAIAdsPixelId = appConfig.openAIAdsPixelId; // 获取翻译消息 const messages = await getMessages(); @@ -69,6 +70,23 @@ export default async function RootLayout({ children }: { children: React.ReactNo ) : null} + {openAIAdsPixelId ? ( + <> + +