From c87f74e544c5222d8b74794a55e1d106c7d0a109 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:54:22 +0300 Subject: [PATCH 01/16] feat: add shared product analytics contract --- apps/desktop/package.json | 1 + apps/web/package.json | 1 + packages/analytics/package.json | 13 + packages/analytics/src/index.test.ts | 241 ++++++++++++++ packages/analytics/src/index.ts | 448 +++++++++++++++++++++++++++ packages/analytics/tsconfig.json | 9 + packages/web-backend/package.json | 1 + pnpm-lock.yaml | 29 +- 8 files changed, 736 insertions(+), 7 deletions(-) create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/src/index.test.ts create mode 100644 packages/analytics/src/index.ts create mode 100644 packages/analytics/tsconfig.json diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3eb2586b91e..5b58754d170 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@aerofoil/rive-solid-canvas": "^2.1.4", + "@cap/analytics": "workspace:*", "@cap/database": "workspace:*", "@cap/ui-solid": "workspace:*", "@cap/utils": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index 4aa4d2d1f78..4de17b939df 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@aws-sdk/cloudfront-signer": "^3.821.0", "@aws-sdk/s3-presigned-post": "^3.485.0", "@aws-sdk/s3-request-presigner": "^3.485.0", + "@cap/analytics": "workspace:*", "@cap/database": "workspace:*", "@cap/env": "workspace:*", "@cap/recorder-core": "workspace:*", diff --git a/packages/analytics/package.json b/packages/analytics/package.json new file mode 100644 index 00000000000..ef81deb5796 --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,13 @@ +{ + "name": "@cap/analytics", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "type": "module", + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^3.2.0" + } +} diff --git a/packages/analytics/src/index.test.ts b/packages/analytics/src/index.test.ts new file mode 100644 index 00000000000..f4c753bb469 --- /dev/null +++ b/packages/analytics/src/index.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from "vitest"; +import { + CORE_EVENT_NAMES, + createProductEventRows, + isCoreEventName, + isServerOnlyEventName, + normalizeProductEventInput, + normalizeProductEventProperties, + PRODUCT_ANALYTICS_LIMITS, +} from "./index"; + +describe("product analytics contract", () => { + it("keeps the catalog unique and consistently named", () => { + expect(new Set(CORE_EVENT_NAMES).size).toBe(CORE_EVENT_NAMES.length); + for (const name of CORE_EVENT_NAMES) { + expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + } + }); + + it("rejects noisy events outside the core catalog", () => { + expect(isCoreEventName("recording_started")).toBe(true); + expect(isCoreEventName("mouse_moved")).toBe(false); + expect(isCoreEventName("$autocapture")).toBe(false); + }); + + it("marks revenue and lifecycle events as server authoritative", () => { + expect(isServerOnlyEventName("purchase_completed")).toBe(true); + expect(isServerOnlyEventName("user_signed_up")).toBe(true); + expect(isServerOnlyEventName("page_view")).toBe(false); + expect(isServerOnlyEventName("recording_completed")).toBe(false); + }); +}); + +describe("normalizeProductEventProperties", () => { + it("retains only finite scalar values", () => { + expect( + normalizeProductEventProperties({ + valid_string: "value", + valid_number: 42, + valid_boolean: false, + valid_null: null, + not_finite: Number.POSITIVE_INFINITY, + object_value: { nested: true }, + array_value: ["nope"], + }), + ).toEqual({ + valid_string: "value", + valid_number: 42, + valid_boolean: false, + valid_null: null, + }); + }); + + it("drops invalid keys and truncates strings", () => { + const longValue = "x".repeat( + PRODUCT_ANALYTICS_LIMITS.propertyStringLength + 20, + ); + expect( + normalizeProductEventProperties({ + valid_key: longValue, + "Invalid-Key": "drop", + ["x".repeat(PRODUCT_ANALYTICS_LIMITS.propertyKeyLength + 1)]: "drop", + }), + ).toEqual({ + valid_key: "x".repeat(PRODUCT_ANALYTICS_LIMITS.propertyStringLength), + }); + }); + + it("caps the number of retained properties", () => { + const properties = Object.fromEntries( + Array.from( + { length: PRODUCT_ANALYTICS_LIMITS.propertyCount + 10 }, + (_, i) => [`property_${i}`, i], + ), + ); + const normalized = normalizeProductEventProperties(properties); + expect(Object.keys(normalized ?? {})).toHaveLength( + PRODUCT_ANALYTICS_LIMITS.propertyCount, + ); + }); + + it("caps the serialized property payload", () => { + const properties = Object.fromEntries( + Array.from({ length: PRODUCT_ANALYTICS_LIMITS.propertyCount }, (_, i) => [ + `property_${i}`, + "🙂".repeat(PRODUCT_ANALYTICS_LIMITS.propertyStringLength), + ]), + ); + const normalized = normalizeProductEventProperties(properties); + expect( + new TextEncoder().encode(JSON.stringify(normalized)).byteLength, + ).toBeLessThanOrEqual(PRODUCT_ANALYTICS_LIMITS.propertiesBytes); + }); + + it("returns undefined when nothing is safe to retain", () => { + expect(normalizeProductEventProperties()).toBeUndefined(); + expect(normalizeProductEventProperties({ nested: {} })).toBeUndefined(); + }); + + it("drops property keys that could contain customer content", () => { + expect( + normalizeProductEventProperties({ + transcript: "private", + file_path: "/Users/private/recording.cap", + error: "upload failed at /Users/private/recording.cap", + reason: "raw operating system error", + video_id: "private-recording-id", + status: "completed", + }), + ).toEqual({ status: "completed" }); + }); +}); + +describe("normalizeProductEventInput", () => { + const now = Date.parse("2026-07-12T12:00:00.000Z"); + const baseEvent = { + eventId: "event-1", + eventName: "recording_started", + occurredAt: "2026-07-12T11:59:59.000Z", + anonymousId: "anonymous-1", + sessionId: "session-1", + platform: "desktop", + }; + + it("normalizes a valid event", () => { + expect(normalizeProductEventInput(baseEvent, now)).toEqual(baseEvent); + }); + + it.each([ + ["unknown event", { ...baseEvent, eventName: "mouse_moved" }], + ["missing id", { ...baseEvent, eventId: "" }], + ["unknown platform", { ...baseEvent, platform: "mobile" }], + ["server-authored platform", { ...baseEvent, platform: "server" }], + ["invalid timestamp", { ...baseEvent, occurredAt: "not-a-date" }], + [ + "stale timestamp", + { ...baseEvent, occurredAt: "2026-07-01T11:59:59.000Z" }, + ], + [ + "future timestamp", + { ...baseEvent, occurredAt: "2026-07-12T12:06:00.000Z" }, + ], + ])("rejects %s", (_label, event) => { + expect(normalizeProductEventInput(event, now)).toBeNull(); + }); + + it("truncates bounded context and sanitizes properties", () => { + const normalized = normalizeProductEventInput( + { + ...baseEvent, + pathname: `/${"short/".repeat(PRODUCT_ANALYTICS_LIMITS.pathnameLength)}`, + properties: { + status: "completed", + content: "private", + nested: { invalid: true }, + }, + }, + now, + ); + expect(normalized?.pathname).toHaveLength( + PRODUCT_ANALYTICS_LIMITS.pathnameLength, + ); + expect(normalized?.properties).toEqual({ status: "completed" }); + }); + + it("removes query strings and high-cardinality path segments", () => { + const normalized = normalizeProductEventInput( + { + ...baseEvent, + pathname: + "https://cap.so/s/019f1ad7-2deb-7730-8d27-916abc9cd4d8?token=private", + }, + now, + ); + expect(normalized?.pathname).toBe("/s/:id"); + }); + + it.each([ + "/screen-recorder-windows", + "/loom-alternative", + "/blog/how-to-record-your-screen-with-audio", + ])("preserves static acquisition route %s", (pathname) => { + expect( + normalizeProductEventInput({ ...baseEvent, pathname }, now)?.pathname, + ).toBe(pathname); + }); + + it("normalizes Cap IDs only on dynamic route segments", () => { + expect( + normalizeProductEventInput( + { ...baseEvent, pathname: "/dashboard/spaces/01abcdefghjkmnp" }, + now, + )?.pathname, + ).toBe("/dashboard/spaces/:id"); + }); + + it("keeps only the referrer hostname", () => { + const normalized = normalizeProductEventInput( + { + ...baseEvent, + referrer: "https://www.google.com/search?q=private", + }, + now, + ); + expect(normalized?.referrer).toBe("www.google.com"); + }); +}); + +describe("createProductEventRows", () => { + it("adds trusted server context without accepting client identity", () => { + const [row] = createProductEventRows( + [ + { + eventId: "event-1", + eventName: "purchase_completed", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "guest-checkout", + platform: "server", + properties: { plan: "monthly" }, + }, + ], + { + receivedAt: "2026-07-12T12:00:01.000Z", + source: "server", + userId: "user-1", + organizationId: "org-1", + country: "CY", + }, + ); + + expect(row).toMatchObject({ + event_id: "event-1", + event_name: "purchase_completed", + source: "server", + user_id: "user-1", + organization_id: "org-1", + country: "CY", + properties: '{"plan":"monthly"}', + }); + }); +}); diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts new file mode 100644 index 00000000000..0ee0de947d2 --- /dev/null +++ b/packages/analytics/src/index.ts @@ -0,0 +1,448 @@ +export const CORE_EVENT_NAMES = [ + "page_view", + "download_cta_clicked", + "pricing_cta_clicked", + "cli_install_command_copied", + "auth_started", + "auth_email_sent", + "user_signed_up", + "recording_started", + "recording_completed", + "multipart_upload_complete", + "multipart_upload_failed", + "export_button_clicked", + "create_shareable_link_clicked", + "checkout_started", + "guest_checkout_started", + "purchase_completed", + "organization_invite_sent", + "organization_member_joined", + "seat_quantity_changed", + "loom_import_started", + "loom_import_completed", + "loom_import_failed", + "first_view_received", + "recording_recovery_failed", +] as const; + +export const SERVER_ONLY_EVENT_NAMES = [ + "user_signed_up", + "checkout_started", + "guest_checkout_started", + "purchase_completed", + "organization_invite_sent", + "organization_member_joined", + "seat_quantity_changed", + "first_view_received", +] as const satisfies readonly CoreEventName[]; + +export type CoreEventName = (typeof CORE_EVENT_NAMES)[number]; +export type ProductEventPlatform = "web" | "desktop" | "server"; +export type ProductEventProperty = string | number | boolean | null; +export type ProductEventProperties = Record; + +export const PRODUCT_ANALYTICS_ANONYMOUS_ID_COOKIE = + "cap_analytics_anonymous_id"; + +export interface ProductEventInput { + eventId: string; + eventName: CoreEventName; + occurredAt: string; + anonymousId: string; + sessionId?: string; + platform: ProductEventPlatform; + appVersion?: string; + pathname?: string; + referrer?: string; + properties?: ProductEventProperties; +} + +export interface ProductEventContext { + receivedAt: string; + source: "client" | "server"; + userId?: string; + organizationId?: string; + country?: string; + region?: string; + city?: string; +} + +export interface ProductEventRow { + event_id: string; + occurred_at: string; + received_at: string; + event_name: CoreEventName; + schema_version: 1; + source: "client" | "server"; + platform: ProductEventPlatform; + anonymous_id: string; + session_id: string; + user_id: string; + organization_id: string; + app_version: string; + pathname: string; + referrer: string; + country: string; + region: string; + city: string; + properties: string; +} + +export const PRODUCT_ANALYTICS_LIMITS = { + batchSize: 20, + queueSize: 100, + requestBytes: 64 * 1024, + propertyCount: 32, + propertyKeyLength: 64, + propertyStringLength: 512, + propertiesBytes: 16 * 1024, + identifierLength: 128, + appVersionLength: 64, + pathnameLength: 2048, + referrerLength: 2048, + maxPastAgeMs: 7 * 24 * 60 * 60 * 1000, + maxFutureAgeMs: 5 * 60 * 1000, +} as const; + +export class ProductAnalyticsError extends Error { + readonly _tag = "ProductAnalyticsError"; + readonly retryable: boolean; + readonly status?: number; + + constructor(options: { + cause: unknown; + retryable: boolean; + status?: number; + }) { + super("Product analytics request failed", { cause: options.cause }); + this.name = "ProductAnalyticsError"; + this.retryable = options.retryable; + this.status = options.status; + } +} + +interface ProductAnalyticsTransportOptions { + host: string; + token: string; + rows: readonly ProductEventRow[]; + wait?: boolean; + maxAttempts?: number; + fetchImpl?: typeof fetch; +} + +export async function sendProductAnalyticsRows({ + host, + token, + rows, + wait = false, + maxAttempts = 2, + fetchImpl = fetch, +}: ProductAnalyticsTransportOptions) { + if (rows.length === 0) return; + + const url = new URL("/v0/events", host); + url.searchParams.set("name", "product_events_v1"); + url.searchParams.set("format", "ndjson"); + if (wait) url.searchParams.set("wait", "true"); + + const body = rows.map((row) => JSON.stringify(row)).join("\n"); + let lastError: ProductAnalyticsError | undefined; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/x-ndjson", + }, + body, + signal: AbortSignal.timeout(wait ? 10_000 : 2_000), + }); + + if (response.ok) return; + + const retryable = response.status === 429 || response.status >= 500; + lastError = new ProductAnalyticsError({ + cause: await response.text(), + retryable, + status: response.status, + }); + if (!retryable) throw lastError; + } catch (cause) { + if (cause instanceof ProductAnalyticsError) throw cause; + lastError = new ProductAnalyticsError({ cause, retryable: true }); + } + + if (attempt + 1 < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + throw ( + lastError ?? + new ProductAnalyticsError({ + cause: "Product analytics request failed", + retryable: false, + }) + ); +} + +const CORE_EVENT_NAME_SET = new Set(CORE_EVENT_NAMES); +const SERVER_ONLY_EVENT_NAME_SET = new Set(SERVER_ONLY_EVENT_NAMES); +const PROPERTY_KEY_PATTERN = /^[a-z][a-z0-9_]*$/; +const FORBIDDEN_PROPERTY_KEYS = new Set([ + "comment", + "content", + "email", + "error", + "error_message", + "file_name", + "file_path", + "raw_error", + "reason", + "recording_name", + "organization_id", + "session_id", + "subscription_id", + "title", + "transcript", + "user_email", + "user_id", + "video_id", +]); +const CLIENT_PRODUCT_EVENT_PLATFORMS = new Set([ + "web", + "desktop", +]); +const DYNAMIC_ID_PARENT_SEGMENTS = new Set([ + "apps", + "c", + "dev", + "embed", + "folder", + "invite", + "messenger", + "s", + "spaces", + "videos", +]); +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const CAP_NANOID_PATTERN = /^(?:[0-9abcdefghjkmnpqrstvwxyz]{15}){1,2}$/; + +export function isCoreEventName(value: string): value is CoreEventName { + return CORE_EVENT_NAME_SET.has(value); +} + +export function isServerOnlyEventName(value: CoreEventName) { + return SERVER_ONLY_EVENT_NAME_SET.has(value); +} + +export function normalizeProductEventProperties( + properties?: Record, +): ProductEventProperties | undefined { + if (!properties) return undefined; + + const normalized: ProductEventProperties = {}; + let count = 0; + + for (const [key, value] of Object.entries(properties)) { + if (count >= PRODUCT_ANALYTICS_LIMITS.propertyCount) break; + if ( + key.length > PRODUCT_ANALYTICS_LIMITS.propertyKeyLength || + !PROPERTY_KEY_PATTERN.test(key) || + FORBIDDEN_PROPERTY_KEYS.has(key) + ) { + continue; + } + + let normalizedValue: ProductEventProperty; + if (typeof value === "string") { + normalizedValue = value.slice( + 0, + PRODUCT_ANALYTICS_LIMITS.propertyStringLength, + ); + } else if (typeof value === "number" && Number.isFinite(value)) { + normalizedValue = value; + } else if (typeof value === "boolean" || value === null) { + normalizedValue = value; + } else { + continue; + } + + normalized[key] = normalizedValue; + if ( + new TextEncoder().encode(JSON.stringify(normalized)).byteLength > + PRODUCT_ANALYTICS_LIMITS.propertiesBytes + ) { + delete normalized[key]; + continue; + } + + count += 1; + } + + return count > 0 ? normalized : undefined; +} + +export function normalizeProductEventInput( + value: unknown, + now = Date.now(), +): ProductEventInput | null { + if (!isRecord(value)) return null; + + const eventId = normalizeIdentifier(value.eventId); + const anonymousId = normalizeIdentifier(value.anonymousId); + const sessionId = normalizeOptionalIdentifier(value.sessionId); + const eventName = value.eventName; + const platform = value.platform; + const occurredAt = normalizeOccurredAt(value.occurredAt, now); + + if ( + !eventId || + !anonymousId || + !occurredAt || + typeof eventName !== "string" || + !isCoreEventName(eventName) || + typeof platform !== "string" || + !CLIENT_PRODUCT_EVENT_PLATFORMS.has(platform as ProductEventPlatform) + ) { + return null; + } + + const properties = + "properties" in value && isRecord(value.properties) + ? normalizeProductEventProperties(value.properties) + : undefined; + + return { + eventId, + eventName, + occurredAt, + anonymousId, + ...(sessionId ? { sessionId } : {}), + platform: platform as ProductEventPlatform, + ...normalizeOptionalStringField( + "appVersion", + value.appVersion, + PRODUCT_ANALYTICS_LIMITS.appVersionLength, + ), + ...normalizeOptionalPathname(value.pathname), + ...normalizeOptionalReferrer(value.referrer), + ...(properties ? { properties } : {}), + }; +} + +export function createProductEventRows( + events: readonly ProductEventInput[], + context: ProductEventContext, +): ProductEventRow[] { + return events.map((event) => ({ + event_id: event.eventId, + occurred_at: event.occurredAt, + received_at: context.receivedAt, + event_name: event.eventName, + schema_version: 1, + source: context.source, + platform: event.platform, + anonymous_id: event.anonymousId, + session_id: event.sessionId ?? "", + user_id: context.userId ?? "", + organization_id: context.organizationId ?? "", + app_version: event.appVersion ?? "", + pathname: event.pathname ?? "", + referrer: event.referrer ?? "", + country: context.country ?? "", + region: context.region ?? "", + city: context.city ?? "", + properties: event.properties ? JSON.stringify(event.properties) : "{}", + })); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeIdentifier(value: unknown) { + if (typeof value !== "string") return null; + const normalized = value.trim(); + if ( + !normalized || + normalized.length > PRODUCT_ANALYTICS_LIMITS.identifierLength + ) { + return null; + } + return normalized; +} + +function normalizeOptionalIdentifier(value: unknown) { + if (value === undefined || value === null || value === "") return undefined; + return normalizeIdentifier(value) ?? undefined; +} + +function normalizeOccurredAt(value: unknown, now: number) { + if (typeof value !== "string") return null; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return null; + if (timestamp < now - PRODUCT_ANALYTICS_LIMITS.maxPastAgeMs) return null; + if (timestamp > now + PRODUCT_ANALYTICS_LIMITS.maxFutureAgeMs) return null; + return new Date(timestamp).toISOString(); +} + +function normalizeOptionalStringField( + key: Key, + value: unknown, + maxLength: number, +): Partial> { + if (typeof value !== "string") return {}; + const normalized = value.trim().slice(0, maxLength); + return normalized ? ({ [key]: normalized } as Record) : {}; +} + +function normalizeOptionalPathname(value: unknown) { + if (typeof value !== "string") return {}; + + let pathname = value.trim(); + try { + pathname = new URL(pathname).pathname; + } catch { + pathname = pathname.split(/[?#]/, 1)[0] ?? ""; + } + + const segments = pathname.split("/"); + const normalized = segments + .map((segment, index) => + isHighCardinalityPathSegment(segment, segments[index - 1]) + ? ":id" + : segment, + ) + .join("/") + .slice(0, PRODUCT_ANALYTICS_LIMITS.pathnameLength); + + return normalized ? { pathname: normalized } : {}; +} + +function normalizeOptionalReferrer(value: unknown) { + if (typeof value !== "string" || !value.trim()) return {}; + try { + return { + referrer: new URL(value).hostname.slice( + 0, + PRODUCT_ANALYTICS_LIMITS.referrerLength, + ), + }; + } catch { + return {}; + } +} + +function isHighCardinalityPathSegment(segment: string, parentSegment?: string) { + if (UUID_PATTERN.test(segment) || ULID_PATTERN.test(segment)) return true; + return Boolean( + parentSegment && + DYNAMIC_ID_PARENT_SEGMENTS.has(parentSegment) && + CAP_NANOID_PATTERN.test(segment), + ); +} diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json new file mode 100644 index 00000000000..e8b0bc24b9c --- /dev/null +++ b/packages/analytics/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../config/base.tsconfig.json", + "exclude": ["node_modules"], + "include": ["**/*.ts"], + "compilerOptions": { + "baseUrl": ".", + "moduleResolution": "bundler" + } +} diff --git a/packages/web-backend/package.json b/packages/web-backend/package.json index 6d644700853..90afa3f2465 100644 --- a/packages/web-backend/package.json +++ b/packages/web-backend/package.json @@ -17,6 +17,7 @@ "@aws-sdk/credential-providers": "^3.908.0", "@aws-sdk/s3-presigned-post": "^3.485.0", "@aws-sdk/s3-request-presigner": "^3.485.0", + "@cap/analytics": "workspace:*", "@cap/database": "workspace:*", "@cap/utils": "workspace:*", "@cap/web-domain": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e13e2503e9..ca004a5ccce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: '@aerofoil/rive-solid-canvas': specifier: ^2.1.4 version: 2.1.4 + '@cap/analytics': + specifier: workspace:* + version: link:../../packages/analytics '@cap/database': specifier: workspace:* version: link:../../packages/database @@ -300,7 +303,7 @@ importers: version: 4.3.2 '@tailwindcss/typography': specifier: ^0.5.9 - version: 0.5.20(tailwindcss@3.4.19(yaml@2.9.0)) + version: 0.5.20(tailwindcss@4.3.2) '@tauri-apps/cli': specifier: '>=2.1.0' version: 2.11.4 @@ -611,6 +614,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.485.0 version: 3.1077.0 + '@cap/analytics': + specifier: workspace:* + version: link:../../packages/analytics '@cap/database': specifier: workspace:* version: link:../../packages/database @@ -1128,6 +1134,12 @@ importers: specifier: 3.17.14 version: 3.17.14 + packages/analytics: + devDependencies: + vitest: + specifier: ^3.2.0 + version: 3.2.6(@types/debug@4.1.13)(@types/node@22.20.0)(@vitest/ui@3.2.6)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + packages/config: dependencies: '@vitejs/plugin-react': @@ -1598,6 +1610,9 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.485.0 version: 3.1077.0 + '@cap/analytics': + specifier: workspace:* + version: link:../analytics '@cap/database': specifier: workspace:* version: link:../database @@ -19958,7 +19973,7 @@ snapshots: semver: 7.8.5 string-width: 4.2.3 supports-color: 8.1.1 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 widest-line: 3.1.0 wordwrap: 1.0.0 wrap-ansi: 7.0.0 @@ -26505,7 +26520,7 @@ snapshots: eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) @@ -26547,7 +26562,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -26617,7 +26632,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -34257,7 +34272,7 @@ snapshots: werift-common@0.0.3: dependencies: '@shinyoshiaki/jspack': 0.0.6 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -34276,7 +34291,7 @@ snapshots: dependencies: '@shinyoshiaki/jspack': 0.0.6 buffer-crc32: 1.0.0 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) int64-buffer: 1.1.0 ip: 2.0.1 lodash: 4.18.1 From ca358875ac9153fa83e396a238263539dbef1805 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:54:31 +0300 Subject: [PATCH 02/16] feat: add first-party web analytics --- .../unit/guest-checkout-analytics.test.ts | 79 ++++ .../unit/product-analytics-queue.test.ts | 356 +++++++++++++++++ .../unit/product-analytics-request.test.ts | 161 ++++++++ .../unit/product-analytics-scheduler.test.ts | 47 +++ .../unit/product-analytics-server.test.ts | 52 +++ .../unit/product-analytics-transport.test.ts | 134 +++++++ .../subscription-analytics-webhook.test.ts | 246 ++++++++++++ .../actions/analytics/track-user-signed-up.ts | 50 ++- apps/web/actions/organization/send-invites.ts | 26 ++ .../organization/update-seat-quantity.ts | 22 + apps/web/app/Layout/PosthogIdentify.tsx | 5 +- apps/web/app/Layout/PosthogPageView.tsx | 28 +- .../app/Layout/ProductAnalyticsPageView.tsx | 29 ++ apps/web/app/Layout/providers.tsx | 8 +- apps/web/app/api/desktop/[...route]/root.ts | 53 ++- apps/web/app/api/events/route.ts | 152 +++++++ apps/web/app/api/invite/accept/route.ts | 27 ++ .../settings/billing/guest-checkout/route.ts | 54 ++- .../api/settings/billing/subscribe/route.ts | 52 ++- apps/web/app/api/webhooks/stripe/route.ts | 180 +++++++-- apps/web/app/layout.tsx | 2 + apps/web/app/utils/analytics.ts | 16 +- apps/web/app/utils/product-analytics.ts | 378 ++++++++++++++++++ apps/web/lib/analytics/request.ts | 148 +++++++ apps/web/lib/analytics/server-event.ts | 54 +++ apps/web/lib/analytics/server.ts | 77 ++++ apps/web/lib/rate-limit.ts | 1 + apps/web/lib/server.ts | 18 +- packages/env/server.ts | 2 + .../web-backend/src/ProductAnalytics/index.ts | 113 ++++++ packages/web-backend/src/index.ts | 8 + 31 files changed, 2436 insertions(+), 142 deletions(-) create mode 100644 apps/web/__tests__/unit/guest-checkout-analytics.test.ts create mode 100644 apps/web/__tests__/unit/product-analytics-queue.test.ts create mode 100644 apps/web/__tests__/unit/product-analytics-request.test.ts create mode 100644 apps/web/__tests__/unit/product-analytics-scheduler.test.ts create mode 100644 apps/web/__tests__/unit/product-analytics-server.test.ts create mode 100644 apps/web/__tests__/unit/product-analytics-transport.test.ts create mode 100644 apps/web/__tests__/unit/subscription-analytics-webhook.test.ts create mode 100644 apps/web/app/Layout/ProductAnalyticsPageView.tsx create mode 100644 apps/web/app/api/events/route.ts create mode 100644 apps/web/app/utils/product-analytics.ts create mode 100644 apps/web/lib/analytics/request.ts create mode 100644 apps/web/lib/analytics/server-event.ts create mode 100644 apps/web/lib/analytics/server.ts create mode 100644 packages/web-backend/src/ProductAnalytics/index.ts diff --git a/apps/web/__tests__/unit/guest-checkout-analytics.test.ts b/apps/web/__tests__/unit/guest-checkout-analytics.test.ts new file mode 100644 index 00000000000..8782675c934 --- /dev/null +++ b/apps/web/__tests__/unit/guest-checkout-analytics.test.ts @@ -0,0 +1,79 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + createSession: vi.fn(), + product: vi.fn(), + posthog: vi.fn(), + readAnonymousId: vi.fn(), +})); + +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ WEB_URL: "https://cap.so" }), +})); +vi.mock("@cap/utils", () => ({ + stripe: () => ({ + checkout: { sessions: { create: mocks.createSession } }, + }), +})); +vi.mock("@/lib/analytics/server", () => ({ + readAnalyticsAnonymousId: mocks.readAnonymousId, + scheduleLegacyPostHogEvent: mocks.posthog, + scheduleServerProductEvent: mocks.product, +})); + +describe("guest checkout analytics", () => { + let POST: typeof import("@/app/api/settings/billing/guest-checkout/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + mocks.createSession.mockResolvedValue({ + id: "cs_guest_1", + url: "https://checkout.stripe.com/session", + }); + POST = (await import("@/app/api/settings/billing/guest-checkout/route")) + .POST; + }); + + it("uses one stable fallback identity through checkout and purchase metadata", async () => { + mocks.readAnonymousId.mockReturnValue(undefined); + const response = await POST( + new NextRequest("https://cap.so/api/settings/billing/guest-checkout", { + method: "POST", + body: JSON.stringify({ priceId: "price_team", quantity: 3 }), + }), + ); + expect(response.status).toBe(200); + + const metadata = mocks.createSession.mock.calls[0]?.[0].metadata; + expect(metadata.analyticsAnonymousId).toMatch(/^guest:/); + expect(metadata.analyticsIsFirstPurchase).toBe("true"); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "checkout:cs_guest_1", + anonymousId: metadata.analyticsAnonymousId, + }), + ); + expect(mocks.posthog).toHaveBeenCalledWith( + expect.objectContaining({ + distinctId: metadata.analyticsAnonymousId, + properties: expect.objectContaining({ + $insert_id: "checkout:cs_guest_1", + }), + }), + ); + }); + + it("preserves an existing browser identity", async () => { + mocks.readAnonymousId.mockReturnValue("anonymous-browser-1"); + await POST( + new NextRequest("https://cap.so/api/settings/billing/guest-checkout", { + method: "POST", + body: JSON.stringify({ priceId: "price_team", quantity: 1 }), + }), + ); + expect( + mocks.createSession.mock.calls[0]?.[0].metadata.analyticsAnonymousId, + ).toBe("anonymous-browser-1"); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-queue.test.ts b/apps/web/__tests__/unit/product-analytics-queue.test.ts new file mode 100644 index 00000000000..6debe8cda01 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-queue.test.ts @@ -0,0 +1,356 @@ +import { + PRODUCT_ANALYTICS_LIMITS, + type ProductEventInput, +} from "@cap/analytics"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createProductEventId, + getOrCreateStorageId, + ProductAnalyticsQueue, + type ProductAnalyticsTransport, + readFirstTouchAttribution, + sendBrowserProductAnalytics, + shouldCaptureProductPageView, +} from "@/app/utils/product-analytics"; + +const makeEvent = (index: number): ProductEventInput => ({ + eventId: `event-${index}`, + eventName: "page_view", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + sessionId: "session-1", + platform: "web", +}); + +describe("ProductAnalyticsQueue", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("does not perform network work on enqueue", () => { + const transport = vi.fn(); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + expect(transport).not.toHaveBeenCalled(); + }); + + it("flushes one batch after the interval", async () => { + const transport = vi + .fn() + .mockResolvedValue("success"); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + queue.enqueue(makeEvent(2)); + + await vi.advanceTimersByTimeAsync(5_000); + expect(transport).toHaveBeenCalledTimes(1); + expect(transport.mock.calls[0]?.[0]).toHaveLength(2); + }); + + it("flushes immediately when a full batch is queued", async () => { + const transport = vi + .fn() + .mockResolvedValue("success"); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < PRODUCT_ANALYTICS_LIMITS.batchSize; i += 1) { + queue.enqueue(makeEvent(i)); + } + await queue.flush(); + expect(transport).toHaveBeenCalledTimes(1); + expect(transport.mock.calls[0]?.[0]).toHaveLength( + PRODUCT_ANALYTICS_LIMITS.batchSize, + ); + }); + + it("allows only one request in flight", async () => { + let resolveTransport: ((value: "success") => void) | undefined; + const transport = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveTransport = resolve; + }), + ); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + const first = queue.flush(); + const second = queue.flush(); + expect(first).toBe(second); + expect(transport).toHaveBeenCalledTimes(1); + resolveTransport?.("success"); + await first; + }); + + it("retries a failed batch once", async () => { + const transport = vi + .fn() + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("retry"); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + await queue.flush(); + await vi.advanceTimersByTimeAsync(3_000); + expect(transport).toHaveBeenCalledTimes(2); + expect(queue.size).toBe(0); + }); + + it("honors retry backoff for a full failed batch", async () => { + const transport = vi + .fn() + .mockResolvedValueOnce("retry") + .mockResolvedValueOnce("success"); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < PRODUCT_ANALYTICS_LIMITS.batchSize; i += 1) { + queue.enqueue(makeEvent(i)); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(transport).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1_999); + expect(transport).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(transport).toHaveBeenCalledTimes(2); + }); + + it("does not retry a rejected batch", async () => { + const transport = vi + .fn() + .mockResolvedValue("drop"); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue(makeEvent(1)); + await queue.flush(); + await vi.runAllTimersAsync(); + expect(transport).toHaveBeenCalledTimes(1); + }); + + it("bounds memory and drops the oldest queued events", async () => { + let resolveFirst: ((value: "success") => void) | undefined; + const transport = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue("success"); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < PRODUCT_ANALYTICS_LIMITS.queueSize + 30; i += 1) { + queue.enqueue(makeEvent(i)); + } + expect(queue.size).toBe(PRODUCT_ANALYTICS_LIMITS.queueSize); + expect(transport.mock.calls[0]?.[0][0]?.eventId).toBe("event-0"); + resolveFirst?.("success"); + await vi.waitFor(() => + expect(transport.mock.calls.length).toBeGreaterThanOrEqual(2), + ); + expect(transport.mock.calls[1]?.[0][0]?.eventId).toBe("event-30"); + }); + + it("keeps every request under the body size limit", async () => { + const requestSizes: number[] = []; + const transport = vi.fn(async (events) => { + requestSizes.push( + new TextEncoder().encode(JSON.stringify({ events })).byteLength, + ); + return "success"; + }); + const queue = new ProductAnalyticsQueue(transport); + for (let i = 0; i < 10; i += 1) { + queue.enqueue({ + ...makeEvent(i), + properties: { value: "x".repeat(20_000) }, + }); + } + + await vi.runAllTimersAsync(); + expect( + requestSizes.every( + (size) => size <= PRODUCT_ANALYTICS_LIMITS.requestBytes, + ), + ).toBe(true); + expect( + transport.mock.calls.reduce( + (count, [events]) => count + events.length, + 0, + ), + ).toBe(10); + }); + + it("drops a single event larger than the request limit", async () => { + const transport = vi.fn(); + const queue = new ProductAnalyticsQueue(transport); + queue.enqueue({ + ...makeEvent(1), + properties: { + value: "x".repeat(PRODUCT_ANALYTICS_LIMITS.requestBytes), + }, + }); + + await vi.runAllTimersAsync(); + expect(transport).not.toHaveBeenCalled(); + expect(queue.size).toBe(0); + }); +}); + +describe("browser analytics identity", () => { + it("falls back when secure UUID generation is unavailable", () => { + expect(createProductEventId(null, 1_000, 0.5)).toBe("fallback-rs-i"); + expect( + createProductEventId( + () => { + throw new Error("blocked"); + }, + 1_000, + 0.5, + ), + ).toBe("fallback-rs-i"); + }); + + it("reuses a persisted identifier", () => { + const storage = { + getItem: vi.fn(() => "existing-id"), + setItem: vi.fn(), + }; + expect(getOrCreateStorageId(storage, "key", () => "new-id")).toBe( + "existing-id", + ); + expect(storage.setItem).not.toHaveBeenCalled(); + }); + + it("creates and persists an identifier once", () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() }; + expect(getOrCreateStorageId(storage, "key", () => "new-id")).toBe("new-id"); + expect(storage.setItem).toHaveBeenCalledWith("key", "new-id"); + }); + + it("falls back when storage is unavailable", () => { + const storage = { + getItem: vi.fn(() => { + throw new Error("blocked"); + }), + setItem: vi.fn(), + }; + expect(getOrCreateStorageId(storage, "key", () => "memory-id")).toBe( + "memory-id", + ); + }); + + it("keeps one generated id when persistence is unavailable", () => { + const createId = vi.fn(() => "memory-id"); + const storage = { + getItem: vi.fn(() => null), + setItem: vi.fn(() => { + throw new Error("blocked"); + }), + }; + expect(getOrCreateStorageId(storage, "key", createId)).toBe("memory-id"); + expect(createId).toHaveBeenCalledOnce(); + }); +}); + +describe("first-touch attribution", () => { + it("stores only allowlisted attribution fields", () => { + const storage = { getItem: vi.fn(() => null), setItem: vi.fn() }; + const result = readFirstTouchAttribution( + "?utm_source=google&utm_campaign=launch&email=private%40example.com", + storage, + ); + expect(result).toEqual({ utm_source: "google", utm_campaign: "launch" }); + expect(storage.setItem).toHaveBeenCalledOnce(); + }); + + it("does not overwrite existing attribution", () => { + const storage = { + getItem: vi.fn(() => '{"utm_source":"original"}'), + setItem: vi.fn(), + }; + expect(readFirstTouchAttribution("?utm_source=new", storage)).toEqual({ + utm_source: "original", + }); + expect(storage.setItem).not.toHaveBeenCalled(); + }); +}); + +describe("product page views", () => { + it.each(["/", "/pricing", "/dashboard", "/dashboard/settings"])( + "captures %s", + (pathname) => { + expect(shouldCaptureProductPageView(pathname)).toBe(true); + }, + ); + + it.each(["/s/video-id", "/c/comment-id", "/embed/video-id"])( + "excludes high-volume viewer route %s", + (pathname) => { + expect(shouldCaptureProductPageView(pathname)).toBe(false); + }, + ); +}); + +describe("browser product analytics transport", () => { + it("uses a beacon during unload without also fetching", async () => { + const fetchImpl = vi.fn(); + const sendBeacon = vi.fn(() => true); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "unload", { + fetchImpl, + sendBeacon, + }), + ).resolves.toBe("success"); + expect(sendBeacon).toHaveBeenCalledOnce(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("falls back to keepalive fetch when a beacon is rejected", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(null, { status: 202 })); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "unload", { + fetchImpl, + sendBeacon: () => false, + }), + ).resolves.toBe("success"); + expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ keepalive: true }); + }); + + it.each([ + [429, "retry"], + [503, "retry"], + [400, "drop"], + ] as const)("maps HTTP %s to %s", async (status, result) => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(null, { status })); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "normal", { fetchImpl }), + ).resolves.toBe(result); + }); + + it("retries transport failures", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValue(new Error("offline")); + await expect( + sendBrowserProductAnalytics([makeEvent(1)], "normal", { fetchImpl }), + ).resolves.toBe("retry"); + }); + + it("times out a stalled request", async () => { + vi.useFakeTimers(); + const fetchImpl = vi.fn( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ); + }), + ); + const result = sendBrowserProductAnalytics([makeEvent(1)], "normal", { + fetchImpl, + }); + await vi.advanceTimersByTimeAsync(3_000); + await expect(result).resolves.toBe("retry"); + vi.useRealTimers(); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-request.test.ts b/apps/web/__tests__/unit/product-analytics-request.test.ts new file mode 100644 index 00000000000..561f60a1979 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-request.test.ts @@ -0,0 +1,161 @@ +import { PRODUCT_ANALYTICS_LIMITS } from "@cap/analytics"; +import { describe, expect, it } from "vitest"; +import { + getProductAnalyticsRateLimitKey, + isTrustedAnalyticsRequest, + normalizeGeoHeader, + normalizeProductEventBatch, + ProductAnalyticsRateLimiter, +} from "@/lib/analytics/request"; + +const allowedOrigins = ["https://cap.so", "tauri://localhost"]; +const event = { + eventId: "event-1", + eventName: "page_view", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + sessionId: "session-1", + platform: "web", +}; +const now = Date.parse("2026-07-12T12:00:01.000Z"); + +describe("isTrustedAnalyticsRequest", () => { + it.each([ + [ + "same-origin browser", + { origin: "https://cap.so", secFetchSite: "same-origin" }, + ], + [ + "same-site browser", + { origin: "https://cap.so", secFetchSite: "same-site" }, + ], + ["Tauri", { origin: "tauri://localhost" }], + ["server client", {}], + ])("accepts %s", (_label, headers) => { + expect(isTrustedAnalyticsRequest(headers, allowedOrigins)).toBe(true); + }); + + it("rejects cross-site browser requests", () => { + expect( + isTrustedAnalyticsRequest( + { origin: "https://attacker.example", secFetchSite: "cross-site" }, + allowedOrigins, + ), + ).toBe(false); + }); + + it("rejects oversized declared bodies", () => { + expect( + isTrustedAnalyticsRequest( + { contentLength: String(PRODUCT_ANALYTICS_LIMITS.requestBytes + 1) }, + allowedOrigins, + ), + ).toBe(false); + }); + + it.each(["invalid", "-1", "1.5"])( + "rejects malformed content length %s", + (contentLength) => { + expect(isTrustedAnalyticsRequest({ contentLength }, allowedOrigins)).toBe( + false, + ); + }, + ); +}); + +describe("normalizeProductEventBatch", () => { + it("accepts a bounded valid batch", () => { + expect(normalizeProductEventBatch([event], now)).toEqual([event]); + }); + + it("rejects an empty batch", () => { + expect(normalizeProductEventBatch([], now)).toBeNull(); + }); + + it("rejects a batch above the cap", () => { + expect( + normalizeProductEventBatch( + Array.from( + { length: PRODUCT_ANALYTICS_LIMITS.batchSize + 1 }, + () => event, + ), + now, + ), + ).toBeNull(); + }); + + it("rejects the whole batch when one event is invalid", () => { + expect( + normalizeProductEventBatch( + [event, { ...event, eventName: "$autocapture" }], + now, + ), + ).toBeNull(); + }); + + it("rejects an undeclared oversized body", () => { + expect( + normalizeProductEventBatch( + [ + { + ...event, + properties: { + value: "x".repeat(PRODUCT_ANALYTICS_LIMITS.requestBytes), + }, + }, + ], + now, + ), + ).toBeNull(); + }); + + it.each([ + "user_signed_up", + "checkout_started", + "guest_checkout_started", + "purchase_completed", + ] as const)("rejects client-authored %s", (eventName) => { + expect( + normalizeProductEventBatch([{ ...event, eventName }], now), + ).toBeNull(); + }); +}); + +describe("ProductAnalyticsRateLimiter", () => { + it("enforces per-key and process-wide fallback limits", () => { + const limiter = new ProductAnalyticsRateLimiter({ + perKeyLimit: 2, + globalLimit: 4, + windowMs: 1_000, + }); + expect(limiter.isRateLimited("a", 0)).toBe(false); + expect(limiter.isRateLimited("a", 0)).toBe(false); + expect(limiter.isRateLimited("a", 0)).toBe(true); + expect(limiter.isRateLimited("b", 0)).toBe(false); + expect(limiter.isRateLimited("c", 0)).toBe(true); + expect(limiter.isRateLimited("a", 1_000)).toBe(false); + }); + + it("uses a bounded request identity", () => { + expect( + getProductAnalyticsRateLimitKey({ + xForwardedFor: "203.0.113.10, 10.0.0.1", + }), + ).toBe("203.0.113.10"); + expect(getProductAnalyticsRateLimitKey({})).toBe("unknown"); + }); +}); + +describe("normalizeGeoHeader", () => { + it("decodes and bounds a city header", () => { + expect(normalizeGeoHeader("Nicosia%20Centre", true)).toBe("Nicosia Centre"); + }); + + it("rejects malformed encoded data", () => { + expect(normalizeGeoHeader("%E0%A4%A", true)).toBeUndefined(); + }); + + it("removes unknown values", () => { + expect(normalizeGeoHeader("unknown")).toBeUndefined(); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-scheduler.test.ts b/apps/web/__tests__/unit/product-analytics-scheduler.test.ts new file mode 100644 index 00000000000..3ed5183580f --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-scheduler.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + after: vi.fn(() => { + throw new Error("after unavailable"); + }), +})); + +vi.mock("next/server", () => ({ after: mocks.after })); +vi.mock("@cap/env", () => ({ + buildEnv: { + NEXT_PUBLIC_POSTHOG_KEY: "", + NEXT_PUBLIC_POSTHOG_HOST: "", + }, + serverEnv: () => ({ + PRODUCT_ANALYTICS_TINYBIRD_HOST: undefined, + PRODUCT_ANALYTICS_TINYBIRD_TOKEN: undefined, + }), +})); +vi.mock("posthog-node", () => ({ PostHog: vi.fn() })); + +describe("analytics scheduling", () => { + afterEach(() => vi.restoreAllMocks()); + + it("cannot make a business route fail when after is unavailable", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { scheduleLegacyPostHogEvent, scheduleServerProductEvent } = + await import("@/lib/analytics/server"); + + expect(() => + scheduleServerProductEvent({ + eventId: "checkout:cs_1", + eventName: "checkout_started", + anonymousId: "anonymous-1", + platform: "web", + }), + ).not.toThrow(); + expect(() => + scheduleLegacyPostHogEvent({ + distinctId: "anonymous-1", + eventName: "checkout_started", + }), + ).not.toThrow(); + await Promise.resolve(); + expect(mocks.after).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-server.test.ts b/apps/web/__tests__/unit/product-analytics-server.test.ts new file mode 100644 index 00000000000..f1dda108db9 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-server.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { createServerProductEventRows } from "@/lib/analytics/server-event"; + +describe("server product analytics", () => { + it("builds a deterministic trusted server event", () => { + const [row] = createServerProductEventRows({ + eventId: "stripe:evt_123:purchase_completed", + eventName: "purchase_completed", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + platform: "web", + userId: "user-1", + organizationId: "org-1", + properties: { + quantity: 3, + email: "private@example.com", + nested: { private: true }, + }, + }); + + expect(row).toMatchObject({ + event_id: "stripe:evt_123:purchase_completed", + event_name: "purchase_completed", + source: "server", + platform: "web", + anonymous_id: "anonymous-1", + user_id: "user-1", + organization_id: "org-1", + properties: '{"quantity":3}', + }); + }); + + it("uses an authenticated fallback identity", () => { + const [row] = createServerProductEventRows({ + eventId: "signup:user-1", + eventName: "user_signed_up", + platform: "server", + userId: "user-1", + }); + expect(row?.anonymous_id).toBe("user:user-1"); + }); + + it("drops an event without any stable identity", () => { + expect( + createServerProductEventRows({ + eventId: "event-1", + eventName: "page_view", + platform: "server", + }), + ).toEqual([]); + }); +}); diff --git a/apps/web/__tests__/unit/product-analytics-transport.test.ts b/apps/web/__tests__/unit/product-analytics-transport.test.ts new file mode 100644 index 00000000000..2fa585cbe32 --- /dev/null +++ b/apps/web/__tests__/unit/product-analytics-transport.test.ts @@ -0,0 +1,134 @@ +import { + createProductEventRows, + type ProductAnalyticsError, + sendProductAnalyticsRows, +} from "@cap/analytics"; +import { hasAnalyticsSessionCookie } from "@cap/web-backend"; +import { describe, expect, it, vi } from "vitest"; + +const rows = createProductEventRows( + [ + { + eventId: "event-1", + eventName: "page_view", + occurredAt: "2026-07-12T12:00:00.000Z", + anonymousId: "anonymous-1", + platform: "web", + }, + ], + { + receivedAt: "2026-07-12T12:00:01.000Z", + source: "client", + }, +); + +describe("Tinybird product event transport", () => { + it("skips session resolution for anonymous requests", () => { + expect(hasAnalyticsSessionCookie()).toBe(false); + expect(hasAnalyticsSessionCookie("theme=dark; visitor=123")).toBe(false); + expect( + hasAnalyticsSessionCookie( + "theme=dark; next-auth.session-token=token; visitor=123", + ), + ).toBe(true); + expect(hasAnalyticsSessionCookie("next-auth.session-token.0=chunk")).toBe( + true, + ); + }); + + it("posts NDJSON with append-only credentials", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response(null, { status: 202 })); + + await sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }); + + const [url, request] = fetchImpl.mock.calls[0] ?? []; + expect(String(url)).toBe( + "https://api.tinybird.co/v0/events?name=product_events_v1&format=ndjson", + ); + expect(request).toMatchObject({ + method: "POST", + headers: { + Authorization: "Bearer append-token", + "Content-Type": "application/x-ndjson", + }, + body: JSON.stringify(rows[0]), + }); + }); + + it("retries a transient response once", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response("busy", { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 202 })); + + await sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("does not retry a permanent response", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(new Response("invalid", { status: 400 })); + + await expect( + sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }), + ).rejects.toMatchObject({ + _tag: "ProductAnalyticsError", + retryable: false, + status: 400, + } satisfies Partial); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("supports a single-attempt collector path", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValue(new Error("offline")); + await expect( + sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + maxAttempts: 1, + fetchImpl, + }), + ).rejects.toMatchObject({ retryable: true }); + expect(fetchImpl).toHaveBeenCalledOnce(); + }); + + it("fails after two network attempts", async () => { + const fetchImpl = vi + .fn() + .mockRejectedValue(new Error("offline")); + + await expect( + sendProductAnalyticsRows({ + host: "https://api.tinybird.co", + token: "append-token", + rows, + fetchImpl, + }), + ).rejects.toMatchObject({ + _tag: "ProductAnalyticsError", + retryable: true, + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/__tests__/unit/subscription-analytics-webhook.test.ts b/apps/web/__tests__/unit/subscription-analytics-webhook.test.ts new file mode 100644 index 00000000000..c0e530ffede --- /dev/null +++ b/apps/web/__tests__/unit/subscription-analytics-webhook.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + product: vi.fn(), + posthog: vi.fn(), + constructEvent: vi.fn(), + retrieveCustomer: vi.fn(), + retrieveSubscription: vi.fn(), +})); + +const dbChain = { + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + update: vi.fn(), + set: vi.fn(), +}; + +vi.mock("@/lib/analytics/server", () => ({ + scheduleLegacyPostHogEvent: mocks.posthog, + scheduleServerProductEvent: mocks.product, +})); +vi.mock("@/lib/developer-credits", () => ({ addCreditsToAccount: vi.fn() })); +vi.mock("@cap/database", () => ({ db: () => dbChain })); +vi.mock("@cap/database/helpers", () => ({ nanoId: () => "new-user" })); +vi.mock("@cap/database/schema", () => ({ + developerCreditTransactions: {}, + users: { + id: "id", + email: "email", + }, +})); +vi.mock("@cap/env", () => ({ + serverEnv: () => ({ STRIPE_WEBHOOK_SECRET: "whsec_test" }), +})); +vi.mock("@cap/utils", () => ({ + stripe: () => ({ + webhooks: { constructEvent: mocks.constructEvent }, + customers: { + retrieve: mocks.retrieveCustomer, + update: vi.fn(), + }, + subscriptions: { + retrieve: mocks.retrieveSubscription, + list: vi.fn(), + }, + }), +})); +vi.mock("@cap/web-domain", () => ({ + Organisation: { OrganisationId: { make: (value: string) => value } }, + User: { UserId: { make: (value: string) => value } }, +})); +vi.mock("drizzle-orm", () => ({ + and: (...args: unknown[]) => args, + eq: (left: unknown, right: unknown) => ({ left, right }), +})); + +const dbUser = { + id: "user-1", + email: "user@example.com", + activeOrganizationId: "org-1", + stripeSubscriptionId: null, + name: "User", +}; + +const customer = { + id: "cus_1", + deleted: false, + email: "user@example.com", + metadata: { userId: "user-1" }, +}; + +const subscription = { + id: "sub_1", + status: "active", + items: { + data: [ + { + quantity: 3, + price: { + id: "price_team", + unit_amount: 900, + recurring: { interval: "month", interval_count: 1 }, + }, + }, + ], + }, +}; + +function session(overrides: Record = {}) { + return { + id: "cs_1", + customer: "cus_1", + subscription: "sub_1", + payment_status: "paid", + amount_total: 2700, + amount_subtotal: 3000, + currency: "usd", + total_details: { amount_discount: 300 }, + metadata: { + platform: "web", + analyticsAnonymousId: "anonymous-1", + analyticsIsFirstPurchase: "true", + }, + ...overrides, + }; +} + +function request() { + return new Request("https://cap.so/api/webhooks/stripe", { + method: "POST", + headers: { "Stripe-Signature": "signature" }, + body: "{}", + }); +} + +function event(type: string, checkoutSession: ReturnType) { + return { + id: `evt_${type}`, + created: 1_752_537_600, + type, + data: { object: checkoutSession }, + }; +} + +describe("Stripe subscription analytics", () => { + let POST: typeof import("@/app/api/webhooks/stripe/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + dbChain.select.mockReturnValue(dbChain); + dbChain.from.mockReturnValue(dbChain); + dbChain.where.mockReturnValue(dbChain); + dbChain.limit.mockResolvedValue([dbUser]); + dbChain.update.mockReturnValue(dbChain); + dbChain.set.mockReturnValue(dbChain); + mocks.retrieveCustomer.mockResolvedValue(customer); + mocks.retrieveSubscription.mockResolvedValue(subscription); + POST = (await import("@/app/api/webhooks/stripe/route")).POST; + }); + + it("emits a paid purchase with revenue dimensions and deterministic IDs", async () => { + mocks.constructEvent.mockReturnValue( + event("checkout.session.completed", session()), + ); + expect((await POST(request())).status).toBe(200); + + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: "stripe:evt_checkout.session.completed:purchase_completed", + eventName: "purchase_completed", + occurredAt: "2025-07-15T00:00:00.000Z", + anonymousId: "anonymous-1", + userId: "user-1", + organizationId: "org-1", + properties: expect.objectContaining({ + payment_status: "paid", + amount_total_minor: 2700, + currency: "usd", + unit_amount_minor: 900, + billing_interval: "month", + quantity: 3, + }), + }), + ); + expect(mocks.posthog).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + $insert_id: + "stripe:evt_checkout.session.completed:purchase_completed", + }), + }), + ); + }); + + it("keeps first-purchase attribution stable on duplicate delivery", async () => { + dbChain.limit.mockResolvedValue([ + { ...dbUser, stripeSubscriptionId: "sub_1" }, + ]); + mocks.constructEvent.mockReturnValue( + event("checkout.session.completed", session()), + ); + + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + is_first_purchase: true, + }), + }), + ); + }); + + it("does not count an unpaid checkout as a purchase", async () => { + mocks.constructEvent.mockReturnValue( + event( + "checkout.session.completed", + session({ payment_status: "unpaid" }), + ), + ); + expect((await POST(request())).status).toBe(200); + expect(mocks.product).not.toHaveBeenCalled(); + expect(mocks.posthog).not.toHaveBeenCalled(); + }); + + it("emits when an asynchronous subscription payment settles", async () => { + mocks.constructEvent.mockReturnValue( + event("checkout.session.async_payment_succeeded", session()), + ); + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: + "stripe:evt_checkout.session.async_payment_succeeded:purchase_completed", + userId: "user-1", + }), + ); + }); + + it("counts a no-payment trial while exposing its zero revenue", async () => { + mocks.retrieveSubscription.mockResolvedValue({ + ...subscription, + status: "trialing", + }); + mocks.constructEvent.mockReturnValue( + event( + "checkout.session.completed", + session({ + payment_status: "no_payment_required", + amount_total: 0, + }), + ), + ); + expect((await POST(request())).status).toBe(200); + expect(mocks.product).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + payment_status: "no_payment_required", + amount_total_minor: 0, + subscription_status: "trialing", + }), + }), + ); + }); +}); diff --git a/apps/web/actions/analytics/track-user-signed-up.ts b/apps/web/actions/analytics/track-user-signed-up.ts index 2ffb33c6f0a..ce0af3d7b3c 100644 --- a/apps/web/actions/analytics/track-user-signed-up.ts +++ b/apps/web/actions/analytics/track-user-signed-up.ts @@ -1,9 +1,16 @@ "use server"; +import { PRODUCT_ANALYTICS_ANONYMOUS_ID_COOKIE } from "@cap/analytics"; import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users } from "@cap/database/schema"; import { sql } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { + captureServerProductEvent, + scheduleAfterResponse, +} from "@/lib/analytics/server"; +import { normalizeServerIdentifier } from "@/lib/analytics/server-event"; const SIGNUP_TRACKING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; @@ -15,6 +22,7 @@ type UserPreferences = { pauseReactions: boolean; }; trackedEvents?: { + product_user_signed_up?: boolean; user_signed_up?: boolean; }; } | null; @@ -53,10 +61,9 @@ export async function checkAndMarkUserSignedUpTracked(): Promise<{ try { const prefs = currentUser.preferences as UserPreferences; const alreadyTracked = Boolean(prefs?.trackedEvents?.user_signed_up); - - if (alreadyTracked) { - return { shouldTrack: false }; - } + const productAlreadyTracked = Boolean( + prefs?.trackedEvents?.product_user_signed_up, + ); const createdAtTime = getCreatedAtTime(currentUser.created_at); @@ -67,6 +74,41 @@ export async function checkAndMarkUserSignedUpTracked(): Promise<{ return { shouldTrack: false }; } + if (!productAlreadyTracked) { + const analyticsAnonymousId = normalizeServerIdentifier( + (await cookies()).get(PRODUCT_ANALYTICS_ANONYMOUS_ID_COOKIE)?.value, + ); + scheduleAfterResponse(async () => { + try { + const captured = await captureServerProductEvent({ + eventId: `signup:${currentUser.id}`, + eventName: "user_signed_up", + occurredAt: new Date(createdAtTime).toISOString(), + anonymousId: analyticsAnonymousId, + platform: "web", + userId: currentUser.id, + organizationId: currentUser.activeOrganizationId, + }); + if (!captured) return; + + await db() + .update(users) + .set({ + preferences: sql`JSON_SET(COALESCE(${users.preferences}, JSON_OBJECT()), '$.trackedEvents.product_user_signed_up', true)`, + }) + .where( + sql`(${users.id} = ${currentUser.id}) AND JSON_CONTAINS(COALESCE(${users.preferences}, JSON_OBJECT()), CAST(true AS JSON), '$.trackedEvents.product_user_signed_up') = 0`, + ); + } catch (error) { + console.error("Failed to capture user_signed_up", error); + } + }); + } + + if (alreadyTracked) { + return { shouldTrack: false }; + } + const result = await db() .update(users) .set({ diff --git a/apps/web/actions/organization/send-invites.ts b/apps/web/actions/organization/send-invites.ts index ad3642d4e3b..b46f8e5193b 100644 --- a/apps/web/actions/organization/send-invites.ts +++ b/apps/web/actions/organization/send-invites.ts @@ -15,6 +15,7 @@ import { serverEnv } from "@cap/env"; import type { Organisation } from "@cap/web-domain"; import { and, eq, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { scheduleServerProductEvent } from "@/lib/analytics/server"; import { provisionOrganizationInvitee } from "@/lib/organization-provisioning"; import { type AssignableOrganizationRole, @@ -211,6 +212,31 @@ export async function sendOrganizationInvites( } } + const failedInviteIds = new Set(failedInvites.map((invite) => invite.id)); + const successfulInvites = inviteRecords.filter( + (invite) => !failedInviteIds.has(invite.id), + ); + const firstSuccessfulInvite = successfulInvites[0]; + if (firstSuccessfulInvite) { + scheduleServerProductEvent({ + eventId: `organization_invites:${firstSuccessfulInvite.id}`, + eventName: "organization_invite_sent", + platform: "web", + userId: user.id, + organizationId, + properties: { + invite_count: successfulInvites.length, + admin_count: successfulInvites.filter( + (invite) => invite.role === "admin", + ).length, + member_count: successfulInvites.filter( + (invite) => invite.role === "member", + ).length, + delivery: "email", + }, + }); + } + revalidatePath("/dashboard/settings/organization"); return { success: true, failedEmails }; diff --git a/apps/web/actions/organization/update-seat-quantity.ts b/apps/web/actions/organization/update-seat-quantity.ts index 942bbb0bf08..89f365161a1 100644 --- a/apps/web/actions/organization/update-seat-quantity.ts +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -11,6 +11,7 @@ import { stripe } from "@cap/utils"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { scheduleServerProductEvent } from "@/lib/analytics/server"; import { calculateProSeats } from "@/utils/organization"; async function getOwnerSubscription( @@ -205,6 +206,27 @@ export async function updateSeatQuantity( } revalidatePath("/dashboard/settings/organization"); + const latestInvoice = + typeof updatedSubscription.latest_invoice === "string" + ? updatedSubscription.latest_invoice + : updatedSubscription.latest_invoice?.id; + scheduleServerProductEvent({ + eventId: `seat_quantity:${subscription.id}:${latestInvoice ?? updatedSubscription.current_period_start}:${newQuantity}`, + eventName: "seat_quantity_changed", + platform: "web", + userId: user.id, + organizationId, + properties: { + previous_quantity: currentQuantity, + new_quantity: newQuantity, + quantity_delta: newQuantity - currentQuantity, + direction: isSeatIncrease ? "increase" : "decrease", + price_id: subscriptionItem.price.id, + unit_amount_minor: subscriptionItem.price.unit_amount, + currency: subscriptionItem.price.currency, + billing_interval: subscriptionItem.price.recurring?.interval, + }, + }); return { success: true, newQuantity }; } diff --git a/apps/web/app/Layout/PosthogIdentify.tsx b/apps/web/app/Layout/PosthogIdentify.tsx index 851143c9a4d..ef074fa5f06 100644 --- a/apps/web/app/Layout/PosthogIdentify.tsx +++ b/apps/web/app/Layout/PosthogIdentify.tsx @@ -5,7 +5,7 @@ import { checkAndMarkUserSignedUpTracked } from "@/actions/analytics/track-user- import { identifyUser, initAnonymousUser, - trackEvent, + trackExternalEvent, } from "../utils/analytics"; import { useCurrentUser } from "./AuthContext"; @@ -30,9 +30,8 @@ function Inner() { (async () => { const { shouldTrack } = await checkAndMarkUserSignedUpTracked(); if (shouldTrack) { - trackEvent("user_signed_up"); + trackExternalEvent("user_signed_up"); } - trackEvent("user_signed_in"); })(); } }, [user]); diff --git a/apps/web/app/Layout/PosthogPageView.tsx b/apps/web/app/Layout/PosthogPageView.tsx index 41996db03bb..0838ced327a 100644 --- a/apps/web/app/Layout/PosthogPageView.tsx +++ b/apps/web/app/Layout/PosthogPageView.tsx @@ -1,28 +1,23 @@ -// app/PostHogPageView.tsx "use client"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname } from "next/navigation"; import { usePostHog } from "posthog-js/react"; -import { Suspense, useEffect, useMemo } from "react"; +import { useEffect } from "react"; +import { shouldCaptureProductPageView } from "../utils/product-analytics"; let lastTrackedUrl: string | null = null; function PostHogPageView(): null { const pathname = usePathname(); - const searchParams = useSearchParams(); const posthog = usePostHog(); - const search = useMemo(() => searchParams?.toString() ?? "", [searchParams]); useEffect(() => { - if (!pathname || !posthog) { + if (!pathname || !posthog || !shouldCaptureProductPageView(pathname)) { return; } try { - let url = window.location.origin + pathname; - if (search) { - url = `${url}?${search}`; - } + const url = window.location.origin + pathname; if (lastTrackedUrl === url) { return; @@ -33,18 +28,9 @@ function PostHogPageView(): null { } catch (error) { console.error("Error capturing pageview:", error); } - }, [pathname, search, posthog]); + }, [pathname, posthog]); return null; } -// Wrap this in Suspense to avoid the `useSearchParams` usage above -// from de-opting the whole app into client-side rendering -// See: https://nextjs.org/docs/messages/deopted-into-client-rendering -export default function SuspendedPostHogPageView() { - return ( - - - - ); -} +export default PostHogPageView; diff --git a/apps/web/app/Layout/ProductAnalyticsPageView.tsx b/apps/web/app/Layout/ProductAnalyticsPageView.tsx new file mode 100644 index 00000000000..5bccfd96b9d --- /dev/null +++ b/apps/web/app/Layout/ProductAnalyticsPageView.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; +import { + captureProductPageView, + shouldCaptureProductPageView, +} from "../utils/product-analytics"; + +let lastCapturedPathname: string | undefined; + +export function ProductAnalyticsPageView() { + const pathname = usePathname(); + + useEffect(() => { + if ( + !pathname || + pathname === lastCapturedPathname || + !shouldCaptureProductPageView(pathname) + ) { + return; + } + + lastCapturedPathname = pathname; + captureProductPageView(); + }, [pathname]); + + return null; +} diff --git a/apps/web/app/Layout/providers.tsx b/apps/web/app/Layout/providers.tsx index 79ff4a0675e..8076fe1bae8 100644 --- a/apps/web/app/Layout/providers.tsx +++ b/apps/web/app/Layout/providers.tsx @@ -44,17 +44,15 @@ export function PostHogProvider({ if (!host) return undefined; const base: CapPostHogConfig = { api_host: host, + autocapture: false, capture_pageview: false, - capture_pageleave: true, + capture_pageleave: false, + disable_session_recording: true, bootstrap: initialBootstrap.current?.distinctID ? initialBootstrap.current : undefined, }; - if (process.env.NEXT_PUBLIC_POSTHOG_DISABLE_SESSION_RECORDING === "true") { - base.disable_session_recording = true; - } - return base; }, [host]); diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 694119771b9..b6f4e798242 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -8,7 +8,7 @@ import { organizations, users, } from "@cap/database/schema"; -import { buildEnv, serverEnv } from "@cap/env"; +import { serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; import { OrganizationBrandingPatchBody } from "@cap/web-api-contract"; import { ImageUploads } from "@cap/web-backend"; @@ -17,9 +17,12 @@ import { zValidator } from "@hono/zod-validator"; import { and, eq, isNull } from "drizzle-orm"; import { Effect, Option } from "effect"; import { type Context, Hono } from "hono"; -import { PostHog } from "posthog-node"; import type Stripe from "stripe"; import { z } from "zod"; +import { + scheduleLegacyPostHogEvent, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; import { runPromise } from "@/lib/server"; import { withAuth, withOptionalAuth } from "../../utils"; import { @@ -756,31 +759,37 @@ app.post( success_url: `${serverEnv().WEB_URL}/dashboard/caps?upgrade=true`, cancel_url: `${serverEnv().WEB_URL}/pricing`, allow_promotion_codes: true, - metadata: { platform: "desktop", dubCustomerId: user.id }, + metadata: { + platform: "desktop", + dubCustomerId: user.id, + analyticsIsFirstPurchase: user.stripeSubscriptionId ? "false" : "true", + }, }); if (checkoutSession.url) { console.log("[POST] Checkout session created successfully"); + scheduleServerProductEvent({ + eventId: `checkout:${checkoutSession.id}`, + eventName: "checkout_started", + platform: "desktop", + userId: user.id, + organizationId: user.activeOrganizationId, + properties: { + price_id: priceId, + quantity: 1, + }, + }); - try { - const ph = new PostHog(buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", { - host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "", - }); - - ph.capture({ - distinctId: user.id, - event: "checkout_started", - properties: { - price_id: priceId, - quantity: 1, - platform: "desktop", - }, - }); - - await ph.shutdown(); - } catch (e) { - console.error("Failed to capture checkout_started in PostHog", e); - } + scheduleLegacyPostHogEvent({ + distinctId: user.id, + eventName: "checkout_started", + properties: { + $insert_id: `checkout:${checkoutSession.id}`, + price_id: priceId, + quantity: 1, + platform: "desktop", + }, + }); return c.json({ url: checkoutSession.url }); } diff --git a/apps/web/app/api/events/route.ts b/apps/web/app/api/events/route.ts new file mode 100644 index 00000000000..394a40fbae5 --- /dev/null +++ b/apps/web/app/api/events/route.ts @@ -0,0 +1,152 @@ +import { + createProductEventRows, + PRODUCT_ANALYTICS_LIMITS, +} from "@cap/analytics"; +import { + ProductAnalytics, + resolveProductAnalyticsActor, +} from "@cap/web-backend"; +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + HttpApiSchema, + HttpServerRequest, +} from "@effect/platform"; +import { Effect, Layer, Schema } from "effect"; +import { + getProductAnalyticsRateLimitKey, + isTrustedAnalyticsRequest, + normalizeGeoHeader, + normalizeProductEventBatch, + ProductAnalyticsRateLimiter, +} from "@/lib/analytics/request"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +import { apiToHandler } from "@/lib/server"; +import { allowedOrigins } from "@/utils/cors"; + +class RateLimited extends Schema.TaggedError()( + "RateLimited", + {}, + HttpApiSchema.annotations({ status: 429 }), +) {} + +class Api extends HttpApi.make("ProductAnalyticsApi").add( + HttpApiGroup.make("events").add( + HttpApiEndpoint.post("capture", "/api/events") + .setPayload( + Schema.Struct({ + events: Schema.Array(Schema.Unknown).pipe( + Schema.minItems(1), + Schema.maxItems(PRODUCT_ANALYTICS_LIMITS.batchSize), + ), + }), + ) + .addSuccess(Schema.Struct({ accepted: Schema.Number })) + .addError(HttpApiError.BadRequest) + .addError(HttpApiError.ServiceUnavailable) + .addError(RateLimited), + ), +) {} + +const RequestHeaders = Schema.Struct({ + "content-length": Schema.optional(Schema.String), + "sec-fetch-site": Schema.optional(Schema.String), + origin: Schema.optional(Schema.String), + "x-vercel-ip-country": Schema.optional(Schema.String), + "x-vercel-ip-country-region": Schema.optional(Schema.String), + "x-vercel-ip-city": Schema.optional(Schema.String), + "x-forwarded-for": Schema.optional(Schema.String), + "x-real-ip": Schema.optional(Schema.String), +}); + +const fallbackRateLimiter = new ProductAnalyticsRateLimiter(); + +const ApiLive = HttpApiBuilder.api(Api).pipe( + Layer.provide( + HttpApiBuilder.group(Api, "events", (handlers) => + Effect.gen(function* () { + const analytics = yield* ProductAnalytics; + + return handlers.handle("capture", ({ payload }) => + Effect.gen(function* () { + const headers = yield* HttpServerRequest.schemaHeaders( + RequestHeaders, + ).pipe(Effect.mapError(() => new HttpApiError.BadRequest())); + if ( + !isTrustedAnalyticsRequest( + { + contentLength: headers["content-length"], + origin: headers.origin, + secFetchSite: headers["sec-fetch-site"], + }, + allowedOrigins, + ) + ) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + if ( + fallbackRateLimiter.isRateLimited( + getProductAnalyticsRateLimitKey({ + xForwardedFor: headers["x-forwarded-for"], + xRealIp: headers["x-real-ip"], + }), + ) + ) { + return yield* Effect.fail(new RateLimited()); + } + + if ( + yield* Effect.promise(() => + isRateLimited(RATE_LIMIT_IDS.PRODUCT_ANALYTICS_EVENTS), + ) + ) { + return yield* Effect.fail(new RateLimited()); + } + + const events = normalizeProductEventBatch(payload.events); + if (!events) { + return yield* Effect.fail(new HttpApiError.BadRequest()); + } + + const actor = yield* resolveProductAnalyticsActor; + const rows = createProductEventRows(events, { + receivedAt: new Date().toISOString(), + source: "client", + userId: actor?.userId, + organizationId: actor?.organizationId, + country: normalizeGeoHeader(headers["x-vercel-ip-country"]), + region: normalizeGeoHeader(headers["x-vercel-ip-country-region"]), + city: normalizeGeoHeader(headers["x-vercel-ip-city"], true), + }); + + yield* analytics + .append(rows) + .pipe( + Effect.catchTag("ProductAnalyticsError", (error) => + Effect.logWarning( + "Product analytics ingestion failed", + error, + ).pipe( + Effect.andThen( + Effect.fail(new HttpApiError.ServiceUnavailable()), + ), + ), + ), + ); + + return { accepted: rows.length }; + }), + ); + }), + ), + ), +); + +const handler = apiToHandler(ApiLive); + +export const POST = handler; +export const OPTIONS = handler; diff --git a/apps/web/app/api/invite/accept/route.ts b/apps/web/app/api/invite/accept/route.ts index 4f99e2022a8..1352bd25390 100644 --- a/apps/web/app/api/invite/accept/route.ts +++ b/apps/web/app/api/invite/accept/route.ts @@ -9,6 +9,10 @@ import { } from "@cap/database/schema"; import { and, eq } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; +import { + readAnalyticsAnonymousId, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; import { normalizeAssignableOrganizationRole } from "@/lib/permissions/roles"; import { calculateProSeats } from "@/utils/organization"; @@ -33,6 +37,10 @@ export async function POST(request: NextRequest) { } try { + let joinedMemberId: string | undefined; + let joinedOrganizationId: string | undefined; + let joinedRole: string | undefined; + let assignedProSeat = false; await db().transaction(async (tx) => { const [invite] = await tx .select() @@ -72,6 +80,9 @@ export async function POST(request: NextRequest) { role, }); memberId = newId; + joinedMemberId = newId; + joinedOrganizationId = invite.organizationId; + joinedRole = role; } const [org] = await tx @@ -111,6 +122,7 @@ export async function POST(request: NextRequest) { }); if (proSeatsRemaining > 0) { + assignedProSeat = true; await tx .update(organizationMembers) .set({ hasProSeat: true }) @@ -148,6 +160,21 @@ export async function POST(request: NextRequest) { .where(eq(organizationInvites.id, inviteId)); }); + if (joinedMemberId && joinedOrganizationId) { + scheduleServerProductEvent({ + eventId: `organization_member:${joinedMemberId}:joined`, + eventName: "organization_member_joined", + anonymousId: readAnalyticsAnonymousId(request), + platform: "web", + userId: user.id, + organizationId: joinedOrganizationId, + properties: { + role: joinedRole, + assigned_pro_seat: assignedProSeat, + }, + }); + } + return NextResponse.json({ success: true }); } catch (error) { if (error instanceof Error) { diff --git a/apps/web/app/api/settings/billing/guest-checkout/route.ts b/apps/web/app/api/settings/billing/guest-checkout/route.ts index a8e5dc63f30..775225e834a 100644 --- a/apps/web/app/api/settings/billing/guest-checkout/route.ts +++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts @@ -1,11 +1,18 @@ -import { buildEnv, serverEnv } from "@cap/env"; +import { randomUUID } from "node:crypto"; +import { serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; -import { PostHog } from "posthog-node"; +import { + readAnalyticsAnonymousId, + scheduleLegacyPostHogEvent, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; export async function POST(request: NextRequest) { console.log("Starting guest checkout process"); const { priceId, quantity } = await request.json(); + const analyticsAnonymousId = readAnalyticsAnonymousId(request); + const checkoutAnonymousId = analyticsAnonymousId ?? `guest:${randomUUID()}`; console.log("Received guest checkout request:", { priceId, quantity }); @@ -25,32 +32,35 @@ export async function POST(request: NextRequest) { metadata: { platform: "web", guestCheckout: "true", + analyticsIsFirstPurchase: "true", + analyticsAnonymousId: checkoutAnonymousId, }, }); if (checkoutSession.url) { console.log("Successfully created guest checkout session"); + scheduleServerProductEvent({ + eventId: `checkout:${checkoutSession.id}`, + eventName: "guest_checkout_started", + anonymousId: checkoutAnonymousId, + platform: "web", + properties: { + price_id: priceId, + quantity: quantity || 1, + }, + }); - try { - const ph = new PostHog(buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", { - host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "", - }); - - ph.capture({ - distinctId: `guest-${checkoutSession.id}`, - event: "guest_checkout_started", - properties: { - price_id: priceId, - quantity: quantity || 1, - platform: "web", - session_id: checkoutSession.id, - }, - }); - - await ph.shutdown(); - } catch (e) { - console.error("Failed to capture guest_checkout_started in PostHog", e); - } + scheduleLegacyPostHogEvent({ + distinctId: checkoutAnonymousId, + eventName: "guest_checkout_started", + properties: { + $insert_id: `checkout:${checkoutSession.id}`, + price_id: priceId, + quantity: quantity || 1, + platform: "web", + session_id: checkoutSession.id, + }, + }); return Response.json({ url: checkoutSession.url }, { status: 200 }); } diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index ebb9b816bad..c702471c621 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -1,17 +1,22 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { users } from "@cap/database/schema"; -import { buildEnv, serverEnv } from "@cap/env"; +import { serverEnv } from "@cap/env"; import { stripe, userIsPro } from "@cap/utils"; import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; -import { PostHog } from "posthog-node"; import type Stripe from "stripe"; +import { + readAnalyticsAnonymousId, + scheduleLegacyPostHogEvent, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); let customerId = user?.stripeCustomerId; const { priceId, quantity, isOnBoarding } = await request.json(); + const analyticsAnonymousId = readAnalyticsAnonymousId(request); if (!priceId) { console.error("Price ID not found"); @@ -78,29 +83,36 @@ export async function POST(request: NextRequest) { platform: "web", dubCustomerId: user.id, isOnBoarding: isOnBoarding ? "true" : "false", + analyticsIsFirstPurchase: user.stripeSubscriptionId ? "false" : "true", + ...(analyticsAnonymousId ? { analyticsAnonymousId } : {}), }, }); if (checkoutSession.url) { - try { - const ph = new PostHog(buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", { - host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "", - }); - - ph.capture({ - distinctId: user.id, - event: "checkout_started", - properties: { - price_id: priceId, - quantity: quantity, - platform: "web", - }, - }); + scheduleServerProductEvent({ + eventId: `checkout:${checkoutSession.id}`, + eventName: "checkout_started", + anonymousId: analyticsAnonymousId, + platform: "web", + userId: user.id, + organizationId: user.activeOrganizationId, + properties: { + price_id: priceId, + quantity: quantity ?? 1, + is_onboarding: Boolean(isOnBoarding), + }, + }); - await ph.shutdown(); - } catch (e) { - console.error("Failed to capture checkout_started in PostHog", e); - } + scheduleLegacyPostHogEvent({ + distinctId: user.id, + eventName: "checkout_started", + properties: { + $insert_id: `checkout:${checkoutSession.id}`, + price_id: priceId, + quantity, + platform: "web", + }, + }); return Response.json({ url: checkoutSession.url }, { status: 200 }); } diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index e0f3dff4f06..ce7ee372ada 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,13 +1,16 @@ import { db } from "@cap/database"; import { nanoId } from "@cap/database/helpers"; import { developerCreditTransactions, users } from "@cap/database/schema"; -import { buildEnv, serverEnv } from "@cap/env"; +import { serverEnv } from "@cap/env"; import { stripe } from "@cap/utils"; import { Organisation, User } from "@cap/web-domain"; import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; -import { PostHog } from "posthog-node"; import type Stripe from "stripe"; +import { + scheduleLegacyPostHogEvent, + scheduleServerProductEvent, +} from "@/lib/analytics/server"; import { addCreditsToAccount } from "@/lib/developer-credits"; const relevantEvents = new Set([ @@ -17,6 +20,98 @@ const relevantEvents = new Set([ "customer.subscription.deleted", ]); +type PurchaseAnalyticsUser = Pick< + typeof users.$inferSelect, + "id" | "activeOrganizationId" +>; + +function isSettledSubscriptionPurchase( + session: Stripe.Checkout.Session, + subscription: Stripe.Subscription, +) { + return ( + (session.payment_status === "paid" || + session.payment_status === "no_payment_required") && + (subscription.status === "active" || subscription.status === "trialing") + ); +} + +function scheduleSubscriptionPurchaseEvents({ + eventId, + occurredAt, + session, + subscription, + inviteQuota, + user, + isFirstPurchase, +}: { + eventId: string; + occurredAt: string; + session: Stripe.Checkout.Session; + subscription: Stripe.Subscription; + inviteQuota: number; + user?: PurchaseAnalyticsUser; + isFirstPurchase: boolean; +}) { + const isGuestCheckout = session.metadata?.guestCheckout === "true"; + const platform = + session.metadata?.platform === "desktop" + ? "desktop" + : session.metadata?.platform === "web" + ? "web" + : "server"; + const anonymousId = session.metadata?.analyticsAnonymousId; + const distinctId = user?.id ?? anonymousId ?? `stripe:${session.id}`; + const price = subscription.items.data[0]?.price; + const revenueProperties = { + payment_status: session.payment_status, + subscription_status: subscription.status, + amount_total_minor: session.amount_total, + amount_subtotal_minor: session.amount_subtotal, + discount_amount_minor: session.total_details?.amount_discount, + currency: session.currency, + unit_amount_minor: price?.unit_amount, + billing_interval: price?.recurring?.interval, + billing_interval_count: price?.recurring?.interval_count, + }; + + scheduleServerProductEvent({ + eventId: `stripe:${eventId}:purchase_completed`, + eventName: "purchase_completed", + occurredAt, + anonymousId, + platform, + userId: user?.id, + organizationId: user?.activeOrganizationId, + properties: { + ...revenueProperties, + invite_quota: inviteQuota, + price_id: price?.id, + quantity: inviteQuota, + is_onboarding: session.metadata?.isOnBoarding === "true", + is_first_purchase: isFirstPurchase, + is_guest_checkout: isGuestCheckout, + }, + }); + + scheduleLegacyPostHogEvent({ + distinctId, + eventName: "purchase_completed", + properties: { + $insert_id: `stripe:${eventId}:purchase_completed`, + subscription_id: subscription.id, + ...revenueProperties, + invite_quota: inviteQuota, + price_id: price?.id, + quantity: inviteQuota, + is_onboarding: session.metadata?.isOnBoarding === "true", + platform: platform === "server" ? "unknown" : platform, + is_first_purchase: isFirstPurchase, + is_guest_checkout: isGuestCheckout, + }, + }); +} + async function grantDeveloperCredits( session: Stripe.Checkout.Session, ): Promise { @@ -329,39 +424,17 @@ export const POST = async (req: Request) => { console.log("Successfully updated user in database"); - try { - const serverPostHog = new PostHog( - buildEnv.NEXT_PUBLIC_POSTHOG_KEY || "", - { host: buildEnv.NEXT_PUBLIC_POSTHOG_HOST || "" }, - ); - - const isFirstPurchase = !dbUser.stripeSubscriptionId; - const isGuestCheckout = session.metadata?.guestCheckout === "true"; - serverPostHog.capture({ - distinctId: dbUser.id, - event: "purchase_completed", - properties: { - subscription_id: subscription.id, - subscription_status: subscription.status, - invite_quota: inviteQuota, - price_id: subscription.items.data[0]?.price.id, - quantity: inviteQuota, - is_onboarding: session.metadata?.isOnBoarding === "true", - platform: - session.metadata?.platform === "desktop" - ? "desktop" - : session.metadata?.platform === "web" - ? "web" - : "unknown", - is_first_purchase: isFirstPurchase, - is_guest_checkout: isGuestCheckout, - }, + if (isSettledSubscriptionPurchase(session, subscription)) { + scheduleSubscriptionPurchaseEvents({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + session, + subscription, + inviteQuota, + user: dbUser, + isFirstPurchase: + session.metadata?.analyticsIsFirstPurchase === "true", }); - - await serverPostHog.shutdown(); - console.log("Successfully tracked purchase event in PostHog"); - } catch (error) { - console.error("Error tracking purchase in PostHog:", error); } } @@ -374,6 +447,45 @@ export const POST = async (req: Request) => { if (session.metadata?.type === "developer_credits") { return await grantDeveloperCredits(session); } + + if (typeof session.subscription === "string") { + const subscription = await stripe().subscriptions.retrieve( + session.subscription, + ); + if (isSettledSubscriptionPurchase(session, subscription)) { + const inviteQuota = subscription.items.data.reduce( + (total, item) => total + (item.quantity || 1), + 0, + ); + let dbUser: typeof users.$inferSelect | null = null; + if (typeof session.customer === "string") { + const customer = await stripe().customers.retrieve( + session.customer, + ); + if (!customer.deleted) { + const userId = customer.metadata.userId + ? User.UserId.make(customer.metadata.userId) + : undefined; + dbUser = await findUserWithRetry( + customer.email ?? "", + userId, + 1, + ); + } + } + + scheduleSubscriptionPurchaseEvents({ + eventId: event.id, + occurredAt: new Date(event.created * 1000).toISOString(), + session, + subscription, + inviteQuota, + ...(dbUser ? { user: dbUser } : {}), + isFirstPurchase: + session.metadata?.analyticsIsFirstPurchase === "true", + }); + } + } } if (event.type === "customer.subscription.updated") { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f8c3bbcff49..0a446ced264 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; import Script from "next/script"; import type { PropsWithChildren } from "react"; +import { ProductAnalyticsPageView } from "./Layout/ProductAnalyticsPageView"; const defaultFont = localFont({ src: [ @@ -84,6 +85,7 @@ export default function RootLayout({ children }: PropsWithChildren) {