diff --git a/apps/server/src/lib/openrouter.ts b/apps/server/src/lib/openrouter.ts index 8bbd6ae3..3914868e 100644 --- a/apps/server/src/lib/openrouter.ts +++ b/apps/server/src/lib/openrouter.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from "node:crypto"; import { eq } from "drizzle-orm"; import { db } from "../db"; @@ -67,7 +67,10 @@ function getEncryptionKey() { if (!secret || secret.length < 16) { throw new Error("Missing OPENROUTER_API_KEY_SECRET env for encrypting OpenRouter API keys"); } - return createHash("sha256").update(secret).digest(); + const salt = "openrouter:key-derivation-salt"; + const iterations = 100_000; + const keyLength = 32; // AES-256-GCM expects 32-byte key + return pbkdf2Sync(secret, salt, iterations, keyLength, "sha256"); } function base64UrlEncode(buffer: Buffer) { diff --git a/apps/server/src/lib/posthog.ts b/apps/server/src/lib/posthog.ts index 878a1b5c..2fa363d2 100644 --- a/apps/server/src/lib/posthog.ts +++ b/apps/server/src/lib/posthog.ts @@ -3,6 +3,32 @@ import { withTracing } from "@posthog/ai"; let client: PostHog | null = null; +const APP_VERSION = + process.env.SERVER_APP_VERSION ?? + process.env.APP_VERSION ?? + process.env.NEXT_PUBLIC_APP_VERSION ?? + process.env.VERCEL_GIT_COMMIT_SHA ?? + "dev"; + +const DEPLOYMENT = + process.env.SERVER_DEPLOYMENT ?? + process.env.DEPLOYMENT ?? + process.env.POSTHOG_DEPLOYMENT ?? + process.env.VERCEL_ENV ?? + (process.env.NODE_ENV === "production" ? "prod" : "local"); + +const ENVIRONMENT = process.env.POSTHOG_ENVIRONMENT ?? process.env.NODE_ENV ?? "development"; +const DEPLOYMENT_REGION = + process.env.POSTHOG_DEPLOYMENT_REGION ?? process.env.VERCEL_REGION ?? "local"; + +const BASE_SUPER_PROPERTIES = Object.freeze({ + app: "openchat-server", + app_version: APP_VERSION, + deployment: DEPLOYMENT, + environment: ENVIRONMENT, + deployment_region: DEPLOYMENT_REGION, +}); + function buildClient() { const apiKey = process.env.POSTHOG_API_KEY; if (!apiKey) return null; @@ -13,6 +39,7 @@ function buildClient() { flushAt: 1, flushInterval: 5_000, }); + client.register(BASE_SUPER_PROPERTIES); return client; } @@ -27,13 +54,20 @@ export function capturePosthogEvent( ) { const instance = buildClient(); if (!instance || !distinctId) return; - instance.capture({ - distinctId, - event, - properties, - }).catch((error: unknown) => { - console.error("[posthog] capture failed", error); - }); + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined) continue; + sanitized[key] = value; + } + instance + .capture({ + distinctId, + event, + properties: sanitized, + }) + .catch((error: unknown) => { + console.error("[posthog] capture failed", error); + }); } export function withPosthogTracing any>( diff --git a/apps/server/src/routers/index.ts b/apps/server/src/routers/index.ts index 8e9fa7ff..47686874 100644 --- a/apps/server/src/routers/index.ts +++ b/apps/server/src/routers/index.ts @@ -113,45 +113,55 @@ export const appRouter = { .optional(), ) .handler(async ({ context, input }) => { + const userId = context.session!.user.id; const id = input?.id ?? cuid(); const now = new Date(); const title = input?.title ?? "New Chat"; + let storageBackend: "postgres" | "memory_fallback" = "postgres"; try { await db.insert(chat).values({ id, - userId: context.session!.user.id, + userId, title, createdAt: now, updatedAt: now, lastMessageAt: now, }); - // emit sidebar add publish( - `chats:index:${context.session!.user.id}`, + `chats:index:${userId}`, "chats.index.add", { chatId: id, title, updatedAt: now, lastMessageAt: now }, ); } catch { - addFallbackChat(context.session!.user.id, { + storageBackend = "memory_fallback"; + addFallbackChat(userId, { id, - userId: context.session!.user.id, + userId, title, createdAt: now, updatedAt: now, lastMessageAt: now, }); publish( - `chats:index:${context.session!.user.id}`, + `chats:index:${userId}`, "chats.index.add", { chatId: id, title, updatedAt: now, lastMessageAt: now }, ); + capturePosthogEvent("workspace.fallback_storage_used", userId, { + operation: "create", + chat_id: id, + fallback_size: (memChatsByUser.get(userId) ?? []).length, + workspace_id: userId, + }); } - capturePosthogEvent("chat_created", context.session!.user.id, { - chatId: id, - title, - recordedAt: now.toISOString(), + capturePosthogEvent("chat.created", userId, { + chat_id: id, + title_length: title.length, + storage_backend: storageBackend, + source: "server_router", + workspace_id: userId, }); - return { id }; + return { id, storageBackend }; }), // List chats for the current user (sorted by last activity) list: protectedProcedure.handler(async ({ context }) => { @@ -163,8 +173,15 @@ export const appRouter = { .orderBy(desc(chat.lastMessageAt), desc(chat.updatedAt)); return rows; } catch { - pruneUserChats(context.session!.user.id); - const list = memChatsByUser.get(context.session!.user.id) ?? []; + const userId = context.session!.user.id; + pruneUserChats(userId); + const list = memChatsByUser.get(userId) ?? []; + capturePosthogEvent("workspace.fallback_storage_used", userId, { + operation: "list", + chat_id: null, + fallback_size: list.length, + workspace_id: userId, + }); return list.map(({ id, title, lastMessageAt, updatedAt }) => ({ id, title, lastMessageAt, updatedAt })); } }), @@ -231,13 +248,21 @@ export const appRouter = { } else { memMsgsByChat.delete(input.chatId); } - const permissibleChats = pruneChatList(memChatsByUser.get(context.session!.user.id) ?? []); + const userId = context.session!.user.id; + const permissibleChats = pruneChatList(memChatsByUser.get(userId) ?? []); if (permissibleChats.length > 0) { - memChatsByUser.set(context.session!.user.id, permissibleChats); + memChatsByUser.set(userId, permissibleChats); } const hasAccess = permissibleChats.some((c) => c.id === input.chatId); if (!hasAccess) return []; - return (memMsgsByChat.get(input.chatId) ?? prunedMessages) + const fallbackMessages = memMsgsByChat.get(input.chatId) ?? prunedMessages; + capturePosthogEvent("workspace.fallback_storage_used", userId, { + operation: "list", + chat_id: input.chatId, + fallback_size: fallbackMessages.length, + workspace_id: userId, + }); + return fallbackMessages .map(({ id, role, content, createdAt }) => ({ id, role, content, createdAt })); } }), @@ -261,6 +286,7 @@ export const appRouter = { }), ) .handler(async ({ context, input }) => { + const userId = context.session!.user.id; const userCreatedAt = input.userMessage.createdAt ? new Date(input.userMessage.createdAt) : new Date(); const assistantProvided = input.assistantMessage != null; const assistantCreatedAt = assistantProvided @@ -275,7 +301,7 @@ export const appRouter = { const owned = await db .select({ id: chat.id }) .from(chat) - .where(and(eq(chat.id, input.chatId), eq(chat.userId, context.session!.user.id))); + .where(and(eq(chat.id, input.chatId), eq(chat.userId, userId))); if (owned.length === 0) return { ok: false as const }; await db @@ -333,14 +359,14 @@ export const appRouter = { .set({ updatedAt: lastActivity, lastMessageAt: lastActivity }) .where(eq(chat.id, input.chatId)); publish( - `chats:index:${context.session!.user.id}`, + `chats:index:${userId}`, "chats.index.update", { chatId: input.chatId, updatedAt: lastActivity, lastMessageAt: lastActivity }, ); return { ok: true as const, userMessageId: userMsgId, assistantMessageId: assistantMsgId }; } catch { - pruneUserChats(context.session!.user.id); - const userChats = memChatsByUser.get(context.session!.user.id) ?? []; + pruneUserChats(userId); + const userChats = memChatsByUser.get(userId) ?? []; if (!userChats.some((c) => c.id === input.chatId)) return { ok: false as const }; addFallbackMessage(input.chatId, { id: userMsgId, @@ -377,12 +403,19 @@ export const appRouter = { record.updatedAt = latest; record.lastMessageAt = latest; } - memChatsByUser.set(context.session!.user.id, pruneChatList(owned)); + memChatsByUser.set(userId, pruneChatList(owned)); publish( - `chats:index:${context.session!.user.id}`, + `chats:index:${userId}`, "chats.index.update", { chatId: input.chatId, updatedAt: assistantCreatedAt ?? userCreatedAt, lastMessageAt: assistantCreatedAt ?? userCreatedAt }, ); + const fallbackMessages = memMsgsByChat.get(input.chatId) ?? []; + capturePosthogEvent("workspace.fallback_storage_used", userId, { + operation: "send", + chat_id: input.chatId, + fallback_size: fallbackMessages.length, + workspace_id: userId, + }); return { ok: true as const, userMessageId: userMsgId, assistantMessageId: assistantMsgId }; } }), @@ -398,6 +431,7 @@ export const appRouter = { }), ) .handler(async ({ context, input }) => { + const userId = context.session!.user.id; const createdAt = input.createdAt ? new Date(input.createdAt) : new Date(); const now = new Date(); const content = input.content ?? ''; @@ -408,7 +442,7 @@ export const appRouter = { const owned = await db .select({ id: chat.id }) .from(chat) - .where(and(eq(chat.id, input.chatId), eq(chat.userId, context.session!.user.id))); + .where(and(eq(chat.id, input.chatId), eq(chat.userId, userId))); if (owned.length === 0) return { ok: false as const }; let inserted = false; @@ -456,7 +490,7 @@ export const appRouter = { } publish( - `chats:index:${context.session!.user.id}`, + `chats:index:${userId}`, 'chats.index.update', sidebarPayload, ); @@ -477,8 +511,8 @@ export const appRouter = { return { ok: true as const }; } catch { - pruneUserChats(context.session!.user.id); - const userChats = memChatsByUser.get(context.session!.user.id) ?? []; + pruneUserChats(userId); + const userChats = memChatsByUser.get(userId) ?? []; if (!userChats.some((c) => c.id === input.chatId)) return { ok: false as const }; const existingMessages = memMsgsByChat.get(input.chatId) ?? []; @@ -514,7 +548,7 @@ export const appRouter = { record.lastMessageAt = createdAt; } } - memChatsByUser.set(context.session!.user.id, pruneChatList(userChats)); + memChatsByUser.set(userId, pruneChatList(userChats)); publish( `chat:${input.chatId}`, @@ -529,6 +563,13 @@ export const appRouter = { updatedAt: now, }, ); + const fallbackMessages = memMsgsByChat.get(input.chatId) ?? []; + capturePosthogEvent("workspace.fallback_storage_used", userId, { + operation: "streamUpsert", + chat_id: input.chatId, + fallback_size: fallbackMessages.length, + workspace_id: userId, + }); return { ok: true as const }; } }), diff --git a/apps/web/src/app/api/chat/chat-handler.ts b/apps/web/src/app/api/chat/chat-handler.ts index 396efaa4..cedc9efa 100644 --- a/apps/web/src/app/api/chat/chat-handler.ts +++ b/apps/web/src/app/api/chat/chat-handler.ts @@ -1,5 +1,6 @@ import { streamText, convertToCoreMessages, type UIMessage } from "ai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { createHash } from "crypto"; import { captureServerEvent } from "@/lib/posthog-server"; @@ -89,6 +90,14 @@ function pickClientIp(request: Request): string { } } +function hashClientIp(ip: string): string { + try { + return createHash("sha256").update(ip).digest("hex").slice(0, 16); + } catch { + return "unknown"; + } +} + function buildCorsHeaders(request: Request, allowedOrigin?: string | null) { const headers = new Headers(); if (allowedOrigin) { @@ -175,10 +184,24 @@ export function createChatHandler(options: ChatHandlerOptions = {}) { return new Response("Invalid request origin", { status: 403 }); } const allowOrigin = originResult.origin ?? corsOrigin ?? null; - + const distinctIdHeader = request.headers.get("x-user-id")?.trim() || null; + const clientIp = pickClientIp(request); + const ipHash = hashClientIp(clientIp); + const requestOriginValue = originResult.origin ?? request.headers.get("origin") ?? allowOrigin ?? null; + const rateLimitBucketLabel = `${rateLimit.limit}/${bucketWindowMs}`; if (isRateLimited(request)) { const headers = buildCorsHeaders(request, allowOrigin); headers.set("Retry-After", Math.ceil(bucketWindowMs / 1000).toString()); + headers.set("X-RateLimit-Limit", rateLimit.limit.toString()); + headers.set("X-RateLimit-Window", bucketWindowMs.toString()); + captureServerEvent("chat.rate_limited", distinctIdHeader, { + chat_id: null, + limit: rateLimit.limit, + window_ms: bucketWindowMs, + client_ip_hash_trunc: ipHash, + origin: requestOriginValue, + rate_limit_bucket: rateLimitBucketLabel, + }); return new Response("Too Many Requests", { status: 429, headers }); } @@ -233,7 +256,7 @@ export function createChatHandler(options: ChatHandlerOptions = {}) { return new Response(responseMessage, { status, headers }); } - const distinctId = request.headers.get("x-user-id")?.trim() || null; + const distinctId = distinctIdHeader; const chatId = typeof payload?.chatId === "string" && payload.chatId.trim().length > 0 ? payload.chatId.trim() : null; if (!chatId) { const headers = buildCorsHeaders(request, allowOrigin); @@ -312,6 +335,8 @@ export function createChatHandler(options: ChatHandlerOptions = {}) { let pendingResolve: (() => void) | null = null; let finalized = false; let persistenceError: Error | null = null; + const startedAt = Date.now(); + let streamStatus: "completed" | "aborted" | "error" = "completed"; const persistAssistant = async (status: "streaming" | "completed", force = false) => { if (!force && status === "streaming" && assistantText.length === lastPersistedLength) { @@ -389,8 +414,6 @@ export function createChatHandler(options: ChatHandlerOptions = {}) { }; try { - const startedAt = Date.now(); - let streamStatus: "completed" | "aborted" | "error" = "completed"; const model = config.provider.chat(config.modelId); const result = await streamTextImpl({ model, @@ -415,14 +438,21 @@ export function createChatHandler(options: ChatHandlerOptions = {}) { await finalize(); }, }); + const duration = Date.now() - startedAt; + const openrouterStatus = streamStatus === "completed" ? "ok" : streamStatus; captureServerEvent("chat_message_stream", distinctId, { - chatId, - modelId: config.modelId, - userMessageId, - assistantMessageId, + chat_id: chatId, + model_id: config.modelId, + user_message_id: userMessageId, + assistant_message_id: assistantMessageId, characters: assistantText.length, - durationMs: Date.now() - startedAt, + duration_ms: duration, status: streamStatus, + openrouter_status: openrouterStatus, + openrouter_latency_ms: duration, + origin: requestOriginValue, + ip_hash: ipHash, + rate_limit_bucket: rateLimitBucketLabel, }); const aiResponse = result.toUIMessageStreamResponse({ @@ -449,13 +479,20 @@ export function createChatHandler(options: ChatHandlerOptions = {}) { } catch (error) { console.error("/api/chat", error); await finalize(); + const duration = Date.now() - startedAt; captureServerEvent("chat_message_stream", distinctId, { - chatId, - modelId: config.modelId, - userMessageId, - assistantMessageId, + chat_id: chatId, + model_id: config.modelId, + user_message_id: userMessageId, + assistant_message_id: assistantMessageId, characters: assistantText.length, + duration_ms: duration, status: "error", + openrouter_status: "error", + openrouter_latency_ms: duration, + origin: requestOriginValue, + ip_hash: ipHash, + rate_limit_bucket: rateLimitBucketLabel, }); const headers = buildCorsHeaders(request, allowOrigin); return new Response("Upstream error", { status: 502, headers }); diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx index ba6d6a4f..f3de438b 100644 --- a/apps/web/src/app/dashboard/layout.tsx +++ b/apps/web/src/app/dashboard/layout.tsx @@ -9,6 +9,39 @@ import Script from "next/script"; import type { ChatSummary } from "@/types/server-router"; export const dynamic = "force-dynamic"; +const charMap: Record = { + "<": "\\u003C", + ">": "\\u003E", + "/": "\\u002F", + "\\": "\\\\", + "\u0008": "\\b", + "\u000c": "\\f", + "\u000a": "\\n", + "\u000d": "\\r", + "\u0009": "\\t", + "\u0000": "\\0", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +function escapeRegexChar(char: string): string { + const code = char.charCodeAt(0); + if (char === "\\" || char === "]" || char === "^" || char === "-") { + return `\\${char}`; + } + if (char === "/") return "\\/"; + if (code < 0x20 || char === "\u2028" || char === "\u2029") { + return `\\u${code.toString(16).padStart(4, "0")}`; + } + return char; +} + +const UNSAFE_PATTERN = new RegExp(`[${Object.keys(charMap).map(escapeRegexChar).join("")}]`, "g"); + +function escapeUnsafeChars(str: string): string { + return str.replace(UNSAFE_PATTERN, (char) => charMap[char] ?? char); +} + export default async function DashboardLayout({ children }: { children: ReactNode }) { const { userId } = await getUserContext(); @@ -27,24 +60,24 @@ export default async function DashboardLayout({ children }: { children: ReactNod -
- -
- - - - -
-
- {children} -
-
+
+ +
+ + + + +
+
+ {children} +
+
); } diff --git a/apps/web/src/components/account-settings-modal.tsx b/apps/web/src/components/account-settings-modal.tsx index d967ba83..1c6c3f3d 100644 --- a/apps/web/src/components/account-settings-modal.tsx +++ b/apps/web/src/components/account-settings-modal.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { loadOpenRouterKey, removeOpenRouterKey, saveOpenRouterKey } from "@/lib/openrouter-key-storage"; +import { captureClientEvent, registerClientProperties } from "@/lib/posthog"; const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; @@ -145,6 +146,12 @@ export function AccountSettingsModal({ open, onClose }: { open: boolean; onClose setStoredKeyTail(trimmed.slice(-4)); setApiKeyInput(""); toast.success("OpenRouter key saved"); + captureClientEvent("openrouter.key_saved", { + source: "settings", + masked_tail: trimmed.slice(-4), + scope: "workspace", + }); + registerClientProperties({ has_openrouter_key: true }); } catch (error) { console.error("save-openrouter-key", error); setApiKeyError("Failed to save OpenRouter key."); @@ -156,6 +163,7 @@ export function AccountSettingsModal({ open, onClose }: { open: boolean; onClose async function handleRemoveApiKey() { if (removingKey) return; + const wasLinked = hasStoredKey; setRemovingKey(true); try { removeOpenRouterKey(); @@ -164,6 +172,11 @@ export function AccountSettingsModal({ open, onClose }: { open: boolean; onClose setApiKeyInput(""); setApiKeyError(null); toast.success("OpenRouter key removed"); + captureClientEvent("openrouter.key_removed", { + source: "settings", + had_models_cached: wasLinked, + }); + registerClientProperties({ has_openrouter_key: false }); } catch (error) { console.error("remove-openrouter-key", error); toast.error("Failed to remove OpenRouter key"); diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx index b6b4710b..0a20c604 100644 --- a/apps/web/src/components/app-sidebar.tsx +++ b/apps/web/src/components/app-sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComponentProps } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; @@ -19,8 +19,10 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { useWorkspaceChats, type WorkspaceChatRow } from "@/lib/electric/workspace-db"; import { client } from "@/utils/orpc"; import { connect, subscribe, type Envelope } from "@/lib/sync"; -import { captureClientEvent } from "@/lib/posthog"; +import { captureClientEvent, identifyClient, registerClientProperties } from "@/lib/posthog"; import { AccountSettingsModal } from "@/components/account-settings-modal"; +import { loadOpenRouterKey } from "@/lib/openrouter-key-storage"; +import { useBrandTheme } from "@/components/brand-theme-provider"; export type ChatListItem = { id: string; @@ -105,6 +107,7 @@ export default function AppSidebar({ initialChats = [], currentUserId, ...sideba const router = useRouter(); const pathname = usePathname(); const { data: session } = authClient.useSession(); + const { theme: brandTheme } = useBrandTheme(); const [accountOpen, setAccountOpen] = useState(false); const [deletingChatId, setDeletingChatId] = useState(null); const [isCreating, setIsCreating] = useState(false); @@ -115,7 +118,15 @@ export default function AppSidebar({ initialChats = [], currentUserId, ...sideba if (!currentUserId) return; (window as any).__DEV_USER_ID__ = currentUserId; (window as any).__OC_USER_ID__ = currentUserId; - }, [currentUserId]); + identifyClient(currentUserId, { + workspaceId: currentUserId, + properties: { auth_state: session?.user ? "member" : "guest" }, + }); + registerClientProperties({ + auth_state: session?.user ? "member" : "guest", + workspace_id: currentUserId, + }); + }, [currentUserId, session?.user]); const normalizedInitial = useMemo(() => initialChats.map(normalizeChat), [initialChats]); const [fallbackChats, setFallbackChats] = useState(() => dedupeChats(normalizedInitial)); @@ -175,17 +186,22 @@ export default function AppSidebar({ initialChats = [], currentUserId, ...sideba setIsCreating(true); try { const now = new Date(); - const { id } = await client.chats.create({ title: "New Chat" }); + const { id, storageBackend = "postgres" } = await client.chats.create({ title: "New Chat" }); const optimisticChat: ChatListItem = { id, title: "New Chat", updatedAt: now, lastMessageAt: now }; setOptimisticChats((prev) => upsertChat(prev, optimisticChat)); setFallbackChats((prev) => upsertChat(prev, optimisticChat)); - captureClientEvent("chat_created", { chatId: id, createdAt: now.toISOString() }); + captureClientEvent("chat.created", { + chat_id: id, + source: "sidebar_button", + storage_backend: storageBackend, + title_length: optimisticChat.title?.length ?? 0, + }); await router.push(`/dashboard/chat/${id}`); } catch (error) { console.error("create chat", error); } finally { setIsCreating(false); - } + } }, [currentUserId, isCreating, router]); const handleDelete = useCallback( @@ -254,6 +270,30 @@ export default function AppSidebar({ initialChats = [], currentUserId, ...sideba return parts.slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join(""); }, [session?.user?.email, session?.user?.name]); + const dashboardTrackedRef = useRef(false); + useEffect(() => { + if (dashboardTrackedRef.current) return; + if (isLoading) return; + dashboardTrackedRef.current = true; + void (async () => { + let hasKey = false; + try { + const key = await loadOpenRouterKey(); + hasKey = Boolean(key); + registerClientProperties({ has_openrouter_key: hasKey }); + } catch { + hasKey = false; + } + const entryPath = typeof window !== "undefined" ? window.location.pathname || "/dashboard" : "/dashboard"; + captureClientEvent("dashboard.entered", { + chat_total: baseChats.length, + has_api_key: hasKey, + entry_path: entryPath, + brand_theme: brandTheme, + }); + })(); + }, [baseChats.length, brandTheme, isLoading]); + return ( diff --git a/apps/web/src/components/chat-composer.tsx b/apps/web/src/components/chat-composer.tsx index 7077bdf0..48f615fb 100644 --- a/apps/web/src/components/chat-composer.tsx +++ b/apps/web/src/components/chat-composer.tsx @@ -6,6 +6,7 @@ import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; import { ModelSelector, type ModelSelectorOption } from "@/components/model-selector"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { captureClientEvent } from "@/lib/posthog"; type UseAutoResizeTextareaProps = { minHeight: number; maxHeight?: number }; function useAutoResizeTextarea({ minHeight, maxHeight }: UseAutoResizeTextareaProps) { @@ -107,6 +108,7 @@ export type ChatComposerProps = { isStreaming?: boolean; onStop?: () => void; onMissingRequirement?: (reason: "apiKey" | "model") => void; + chatId?: string; }; export default function ChatComposer({ @@ -121,6 +123,7 @@ export default function ChatComposer({ isStreaming = false, onStop, onMissingRequirement, + chatId, }: ChatComposerProps) { const [value, setValue] = useState(''); const [attachments, setAttachments] = useState([]); @@ -192,6 +195,13 @@ export default function ChatComposer({ for (const file of Array.from(files)) { if (file.size > MAX_ATTACHMENT_SIZE_BYTES) { rejectedName = file.name; + captureClientEvent("chat.attachment_event", { + chat_id: chatId, + result: "rejected", + file_mime: file.type || "application/octet-stream", + file_size_bytes: file.size, + limit_bytes: MAX_ATTACHMENT_SIZE_BYTES, + }); continue; } nextFiles.push(file); @@ -200,6 +210,7 @@ export default function ChatComposer({ setErrorMessage(`Attachment ${rejectedName} exceeds the 5MB limit.`); } if (nextFiles.length === 0) return; + const added: File[] = []; setAttachments((prev) => { const seen = new Set(prev.map((file) => `${file.name}:${file.size}`)); const combined = [...prev]; @@ -208,9 +219,19 @@ export default function ChatComposer({ if (seen.has(key)) continue; seen.add(key); combined.push(file); + added.push(file); } return combined; }); + for (const file of added) { + captureClientEvent("chat.attachment_event", { + chat_id: chatId, + result: "accepted", + file_mime: file.type || "application/octet-stream", + file_size_bytes: file.size, + limit_bytes: MAX_ATTACHMENT_SIZE_BYTES, + }); + } }; return ( diff --git a/apps/web/src/components/chat-room.tsx b/apps/web/src/components/chat-room.tsx index 6321821b..ade5af11 100644 --- a/apps/web/src/components/chat-room.tsx +++ b/apps/web/src/components/chat-room.tsx @@ -12,7 +12,7 @@ import ChatMessagesFeed from "@/components/chat-messages-feed"; import { loadOpenRouterKey, removeOpenRouterKey, saveOpenRouterKey } from "@/lib/openrouter-key-storage"; import { OpenRouterLinkModal } from "@/components/openrouter-link-modal"; import { normalizeMessage, toUiMessage } from "@/lib/chat-message-utils"; -import { captureClientEvent, identifyClient } from "@/lib/posthog"; +import { captureClientEvent, identifyClient, registerClientProperties } from "@/lib/posthog"; type ChatRoomProps = { chatId: string; @@ -34,10 +34,16 @@ export default function ChatRoom({ chatId, initialMessages }: ChatRoomProps) { useEffect(() => { const identifier = session?.user?.id || memoDevUser; - if (identifier) { - identifyClient(identifier); - } - }, [memoDevUser, session?.user?.id]); + if (!identifier) return; + identifyClient(identifier, { + workspaceId: workspaceId ?? identifier, + properties: { auth_state: session?.user ? "member" : "guest" }, + }); + registerClientProperties({ + auth_state: session?.user ? "member" : "guest", + workspace_id: workspaceId ?? identifier, + }); + }, [memoDevUser, session?.user, session?.user?.id, workspaceId]); const router = useRouter(); const pathname = usePathname(); @@ -53,6 +59,10 @@ export default function ChatRoom({ chatId, initialMessages }: ChatRoomProps) { const [selectedModel, setSelectedModel] = useState(null); const missingKeyToastRef = useRef(null); + useEffect(() => { + registerClientProperties({ has_openrouter_key: Boolean(apiKey) }); + }, [apiKey]); + useEffect(() => { const params = new URLSearchParams(searchParamsString); if (params.has("openrouter")) { @@ -82,7 +92,27 @@ export default function ChatRoom({ chatId, initialMessages }: ChatRoomProps) { removeOpenRouterKey(); setApiKey(null); } - throw new Error(typeof data?.message === "string" && data.message.length > 0 ? data.message : "Failed to fetch OpenRouter models."); + const errorMessage = + typeof data?.message === "string" && data.message.length > 0 + ? data.message + : "Failed to fetch OpenRouter models."; + let providerHost = "openrouter.ai"; + try { + providerHost = new URL(response.url ?? "https://openrouter.ai/api/v1").host; + } catch { + providerHost = "openrouter.ai"; + } + captureClientEvent("openrouter.models_fetch_failed", { + status: response.status, + error_message: errorMessage, + provider_host: providerHost, + has_api_key: Boolean(key), + }); + throw Object.assign(new Error(errorMessage), { + __posthogTracked: true, + status: response.status, + providerUrl: response.url, + }); } setModelOptions(data.models); const fallback = data.models[0]?.value ?? null; @@ -92,6 +122,25 @@ export default function ChatRoom({ chatId, initialMessages }: ChatRoomProps) { }); } catch (error) { console.error("Failed to load OpenRouter models", error); + if (!(error as any)?.__posthogTracked) { + const status = typeof (error as any)?.status === "number" ? (error as any).status : 0; + let providerHost = "openrouter.ai"; + const providerUrl = (error as any)?.providerUrl; + if (typeof providerUrl === "string" && providerUrl.length > 0) { + try { + providerHost = new URL(providerUrl).host; + } catch { + providerHost = "openrouter.ai"; + } + } + captureClientEvent("openrouter.models_fetch_failed", { + status, + error_message: + error instanceof Error && error.message ? error.message : "Failed to load OpenRouter models.", + provider_host: providerHost, + has_api_key: Boolean(key), + }); + } setModelOptions([]); setSelectedModel(null); setModelsError(error instanceof Error && error.message ? error.message : "Failed to load OpenRouter models."); @@ -137,6 +186,12 @@ export default function ChatRoom({ chatId, initialMessages }: ChatRoomProps) { try { await saveOpenRouterKey(key); setApiKey(key); + registerClientProperties({ has_openrouter_key: true }); + captureClientEvent("openrouter.key_saved", { + source: "modal", + masked_tail: key.slice(-4), + scope: "workspace", + }); await fetchModels(key); } catch (error) { console.error("Failed to save OpenRouter API key", error); @@ -281,11 +336,38 @@ export default function ChatRoom({ chatId, initialMessages }: ChatRoomProps) { }, ); captureClientEvent("chat_message_submitted", { - chatId, - modelId, + chat_id: chatId, + model_id: modelId, characters: content.length, + attachment_count: attachments.length, + has_api_key: Boolean(requestApiKey), }); } catch (error) { + const status = + error instanceof Response + ? error.status + : typeof (error as any)?.status === "number" + ? (error as any).status + : typeof (error as any)?.cause?.status === "number" + ? (error as any).cause.status + : null; + if (status === 429) { + let limitHeader: number | undefined; + let windowHeader: number | undefined; + if (error instanceof Response) { + const limit = error.headers.get("x-ratelimit-limit") || error.headers.get("X-RateLimit-Limit"); + const windowMs = error.headers.get("x-ratelimit-window") || error.headers.get("X-RateLimit-Window"); + const parsedLimit = limit ? Number(limit) : Number.NaN; + const parsedWindow = windowMs ? Number(windowMs) : Number.NaN; + limitHeader = Number.isFinite(parsedLimit) ? parsedLimit : undefined; + windowHeader = Number.isFinite(parsedWindow) ? parsedWindow : undefined; + } + captureClientEvent("chat.rate_limited", { + chat_id: chatId, + limit: limitHeader, + window_ms: windowHeader, + }); + } console.error("Failed to send message", error); } }; @@ -308,6 +390,7 @@ export default function ChatRoom({ chatId, initialMessages }: ChatRoomProps) { setModelsError(null); if (apiKey) void fetchModels(apiKey); }} + hasApiKey={Boolean(apiKey)} /> stop()} onMissingRequirement={handleMissingRequirement} diff --git a/apps/web/src/components/hero-section.tsx b/apps/web/src/components/hero-section.tsx index 627a96a1..1547caa8 100644 --- a/apps/web/src/components/hero-section.tsx +++ b/apps/web/src/components/hero-section.tsx @@ -1,4 +1,6 @@ -import React from 'react' +"use client"; + +import React, { useCallback, useEffect, useRef } from 'react' import Link from 'next/link' import { ArrowRight, ChevronRight } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -6,6 +8,8 @@ import { TextEffect } from '@/components/ui/text-effect' import { AnimatedGroup } from '@/components/ui/animated-group' import { HeroHeader } from './header' import type { Variants } from 'motion/react' +import { authClient } from '@openchat/auth/client' +import { captureClientEvent } from '@/lib/posthog' const transitionVariants = { item: { @@ -27,7 +31,63 @@ const transitionVariants = { }, } satisfies { item: Variants } +function screenWidthBucket(width: number) { + if (width < 640) return 'xs' + if (width < 768) return 'sm' + if (width < 1024) return 'md' + if (width < 1280) return 'lg' + return 'xl' +} + export default function HeroSection() { + const { data: session } = authClient.useSession() + const visitTrackedRef = useRef(false) + + const handleCtaClick = useCallback((ctaId: string, ctaCopy: string, section: string) => { + return () => { + const width = typeof window !== 'undefined' ? window.innerWidth : 0 + captureClientEvent('marketing.cta_clicked', { + cta_id: ctaId, + cta_copy: ctaCopy, + section, + screen_width_bucket: screenWidthBucket(width), + }) + } + }, []) + + useEffect(() => { + if (visitTrackedRef.current) return + if (typeof session === 'undefined') return + visitTrackedRef.current = true + const referrerUrl = document.referrer && document.referrer.length > 0 ? document.referrer : 'direct' + let referrerDomain = 'direct' + if (referrerUrl !== 'direct') { + try { + referrerDomain = new URL(referrerUrl).hostname + } catch { + referrerDomain = 'direct' + } + } + let utmSource: string | null = null + try { + const params = new URLSearchParams(window.location.search) + const source = params.get('utm_source') + if (source && source.length > 0) { + utmSource = source + } + } catch { + utmSource = null + } + const entryPath = window.location.pathname || '/' + captureClientEvent('marketing.visit_landing', { + referrer_url: referrerUrl, + referrer_domain: referrerDomain, + utm_source: utmSource ?? undefined, + entry_path: entryPath, + session_is_guest: !session?.user, + }) + }, [session]) + return ( <> @@ -137,7 +197,9 @@ export default function HeroSection() { asChild size="lg" className="rounded-xl px-5 text-base"> - + Try OpenChat @@ -148,7 +210,9 @@ export default function HeroSection() { size="lg" variant="ghost" className="h-10.5 rounded-xl px-5"> - + Request a demo diff --git a/apps/web/src/components/model-selector.tsx b/apps/web/src/components/model-selector.tsx index 7800234e..fc64db6a 100644 --- a/apps/web/src/components/model-selector.tsx +++ b/apps/web/src/components/model-selector.tsx @@ -14,6 +14,7 @@ import { CommandList, } from "@/components/ui/command" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { registerClientProperties } from "@/lib/posthog" export type ModelSelectorOption = { value: string @@ -90,6 +91,11 @@ export function ModelSelector({ options, value, onChange, disabled, loading }: M return options.find((option) => option.value === selectedValue) ?? null }, [options, selectedValue]) + React.useEffect(() => { + if (!selectedValue) return + registerClientProperties({ model_id: selectedValue }) + }, [selectedValue]) + const triggerLabel = React.useMemo(() => { if (selectedOption) return selectedOption.label if (loading) return "Loading models..." @@ -145,6 +151,7 @@ export function ModelSelector({ options, value, onChange, disabled, loading }: M setInternalValue(currentValue) } onChange?.(currentValue) + registerClientProperties({ model_id: currentValue }) setOpen(false) }} className={cn( diff --git a/apps/web/src/components/openrouter-link-modal.tsx b/apps/web/src/components/openrouter-link-modal.tsx index 53d3115d..92165f7f 100644 --- a/apps/web/src/components/openrouter-link-modal.tsx +++ b/apps/web/src/components/openrouter-link-modal.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ExternalLink, LoaderIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; +import { captureClientEvent } from "@/lib/posthog"; type OpenRouterLinkModalProps = { open: boolean; @@ -14,14 +15,34 @@ type OpenRouterLinkModalProps = { errorMessage?: string | null; onSubmit: (apiKey: string) => void | Promise; onTroubleshoot?: () => void; + hasApiKey?: boolean; }; -export function OpenRouterLinkModal({ open, saving, errorMessage, onSubmit, onTroubleshoot }: OpenRouterLinkModalProps) { +export function OpenRouterLinkModal({ + open, + saving, + errorMessage, + onSubmit, + onTroubleshoot, + hasApiKey, +}: OpenRouterLinkModalProps) { const [apiKey, setApiKey] = useState(""); + const trackedRef = useRef(false); useEffect(() => { - if (!open) setApiKey(""); - }, [open]); + if (!open) { + setApiKey(""); + trackedRef.current = false; + return; + } + if (trackedRef.current) return; + trackedRef.current = true; + const reason = errorMessage ? "error" : "missing"; + captureClientEvent("openrouter.key_prompt_shown", { + reason, + has_api_key: Boolean(hasApiKey), + }); + }, [open, errorMessage, hasApiKey]); if (!open) return null; diff --git a/apps/web/src/components/posthog-bootstrap.tsx b/apps/web/src/components/posthog-bootstrap.tsx new file mode 100644 index 00000000..99a9c3e7 --- /dev/null +++ b/apps/web/src/components/posthog-bootstrap.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { useTheme } from "next-themes"; +import { authClient } from "@openchat/auth/client"; + +import { useBrandTheme } from "@/components/brand-theme-provider"; +import { loadOpenRouterKey } from "@/lib/openrouter-key-storage"; +import { identifyClient, registerClientProperties } from "@/lib/posthog"; +import { ensureGuestIdClient, resolveClientUserId } from "@/lib/guest.client"; + +export function PosthogBootstrap() { + const { theme, resolvedTheme } = useTheme(); + const { theme: brandTheme } = useBrandTheme(); + const { data: session } = authClient.useSession(); + const identifyRef = useRef(null); + + useEffect(() => { + ensureGuestIdClient(); + }, []); + + const resolvedWorkspaceId = useMemo(() => { + if (session?.user?.id) return session.user.id; + try { + return resolveClientUserId(); + } catch { + return null; + } + }, [session?.user?.id]); + + useEffect(() => { + if (!resolvedWorkspaceId) return; + if (identifyRef.current === resolvedWorkspaceId && session?.user) return; + identifyRef.current = resolvedWorkspaceId; + identifyClient(resolvedWorkspaceId, { + workspaceId: resolvedWorkspaceId, + properties: { + auth_state: session?.user ? "member" : "guest", + }, + }); + }, [resolvedWorkspaceId, session?.user]); + + useEffect(() => { + if (!resolvedWorkspaceId) return; + registerClientProperties({ + auth_state: session?.user ? "member" : "guest", + workspace_id: resolvedWorkspaceId, + }); + }, [resolvedWorkspaceId, session?.user]); + + useEffect(() => { + const preferred = + theme === "system" + ? resolvedTheme ?? "system" + : theme ?? resolvedTheme ?? "system"; + registerClientProperties({ ui_theme: preferred }); + }, [theme, resolvedTheme]); + + useEffect(() => { + if (!brandTheme) return; + registerClientProperties({ brand_theme: brandTheme }); + }, [brandTheme]); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const key = await loadOpenRouterKey(); + if (cancelled) return; + registerClientProperties({ has_openrouter_key: Boolean(key) }); + } catch { + if (cancelled) return; + registerClientProperties({ has_openrouter_key: false }); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return null; +} diff --git a/apps/web/src/components/providers.tsx b/apps/web/src/components/providers.tsx index e6f403aa..0bd9a70f 100644 --- a/apps/web/src/components/providers.tsx +++ b/apps/web/src/components/providers.tsx @@ -8,6 +8,7 @@ import { ThemeProvider } from "./theme-provider"; import { BrandThemeProvider } from "./brand-theme-provider"; import { Toaster } from "sonner"; import { initPosthog } from "@/lib/posthog"; +import { PosthogBootstrap } from "@/components/posthog-bootstrap"; export default function Providers({ children }: { children: React.ReactNode }) { const [posthogClient, setPosthogClient] = useState>(null); @@ -16,7 +17,23 @@ export default function Providers({ children }: { children: React.ReactNode }) { const client = initPosthog(); if (client) { setPosthogClient(client); - client.capture("$pageview"); + const referrerUrl = document.referrer && document.referrer.length > 0 ? document.referrer : "direct"; + let referrerDomain = "direct"; + if (referrerUrl !== "direct") { + try { + referrerDomain = new URL(referrerUrl).hostname; + } catch { + referrerDomain = "direct"; + } + } + const entryPath = window.location.pathname || "/"; + const entryQuery = window.location.search || ""; + client.capture("$pageview", { + referrer_url: referrerUrl, + referrer_domain: referrerDomain, + entry_path: entryPath, + entry_query: entryQuery, + }); } }, []); @@ -29,6 +46,7 @@ export default function Providers({ children }: { children: React.ReactNode }) { > + {children} diff --git a/apps/web/src/lib/posthog-server.ts b/apps/web/src/lib/posthog-server.ts index b3dceba5..fcabfff8 100644 --- a/apps/web/src/lib/posthog-server.ts +++ b/apps/web/src/lib/posthog-server.ts @@ -3,6 +3,30 @@ import { withTracing } from "@posthog/ai"; let serverClient: PostHog | null = null; +const APP_VERSION = + process.env.APP_VERSION ?? + process.env.NEXT_PUBLIC_APP_VERSION ?? + process.env.VERCEL_GIT_COMMIT_SHA ?? + "dev"; + +const DEPLOYMENT = + process.env.DEPLOYMENT ?? + process.env.POSTHOG_DEPLOYMENT ?? + process.env.VERCEL_ENV ?? + (process.env.NODE_ENV === "production" ? "prod" : "local"); + +const ENVIRONMENT = process.env.POSTHOG_ENVIRONMENT ?? process.env.NODE_ENV ?? "development"; +const DEPLOYMENT_REGION = + process.env.POSTHOG_DEPLOYMENT_REGION ?? process.env.VERCEL_REGION ?? "local"; + +const BASE_SUPER_PROPERTIES = Object.freeze({ + app: "openchat-server", + app_version: APP_VERSION, + deployment: DEPLOYMENT, + environment: ENVIRONMENT, + deployment_region: DEPLOYMENT_REGION, +}); + function ensureServerClient() { const apiKey = process.env.POSTHOG_API_KEY; if (!apiKey) return null; @@ -13,15 +37,29 @@ function ensureServerClient() { flushAt: 1, flushInterval: 5_000, }); + serverClient.register(BASE_SUPER_PROPERTIES); return serverClient; } -export function captureServerEvent(event: string, distinctId: string | null | undefined, properties?: Record) { +export function captureServerEvent( + event: string, + distinctId: string | null | undefined, + properties?: Record, +) { const client = ensureServerClient(); if (!client || !distinctId) return; - client.capture({ event, distinctId, properties }).catch((error) => { - console.error("[posthog] capture failed", error); - }); + const sanitized: Record = {}; + if (properties) { + for (const [key, value] of Object.entries(properties)) { + if (value === undefined) continue; + sanitized[key] = value; + } + } + client + .capture({ event, distinctId, properties: sanitized }) + .catch((error) => { + console.error("[posthog] capture failed", error); + }); } export function withServerTracing any>( diff --git a/apps/web/src/lib/posthog.ts b/apps/web/src/lib/posthog.ts index 18e673b8..3a81cc86 100644 --- a/apps/web/src/lib/posthog.ts +++ b/apps/web/src/lib/posthog.ts @@ -1,7 +1,23 @@ import posthog from "posthog-js"; +type IdentifyOptions = { + workspaceId?: string | null | undefined; + properties?: Record; +}; + const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY; const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"; +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "dev"; +const DEPLOYMENT = + process.env.NEXT_PUBLIC_DEPLOYMENT ?? (process.env.NODE_ENV === "production" ? "prod" : "local"); +const ELECTRIC_ENABLED = Boolean(process.env.NEXT_PUBLIC_ELECTRIC_URL); + +const BASE_SUPER_PROPERTIES = Object.freeze({ + app: "openchat-web", + app_version: APP_VERSION, + deployment: DEPLOYMENT, + electric_enabled: ELECTRIC_ENABLED, +}); let initialized = false; @@ -20,7 +36,7 @@ export function initPosthog() { blockSelector: "[data-ph-no-capture]", } as any, loaded: (client) => { - client.register({ app: "openchat-web" }); + client.register(BASE_SUPER_PROPERTIES); }, }); initialized = true; @@ -31,13 +47,39 @@ export function initPosthog() { export function captureClientEvent(event: string, properties?: Record) { const client = initPosthog(); if (!client) return; - client.capture(event, properties); + if (!properties || Object.keys(properties).length === 0) { + client.capture(event); + return; + } + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined) continue; + sanitized[key] = value; + } + client.capture(event, sanitized); +} + +export function registerClientProperties(properties: Record) { + const client = initPosthog(); + if (!client) return; + const sanitized: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (value === undefined) continue; + sanitized[key] = value; + } + if (Object.keys(sanitized).length === 0) return; + client.register(sanitized); } -export function identifyClient(distinctId: string | null | undefined) { +export function identifyClient(distinctId: string | null | undefined, options?: IdentifyOptions) { const client = initPosthog(); if (!client || !distinctId) return; - client.identify(distinctId); + client.identify(distinctId, options?.properties); + const workspaceId = options?.workspaceId; + if (workspaceId) { + client.group("workspace", workspaceId); + registerClientProperties({ workspace_id: workspaceId }); + } } export function resetClient() { diff --git a/apps/web/src/lib/sync.ts b/apps/web/src/lib/sync.ts index 08b7ebff..5d0cad62 100644 --- a/apps/web/src/lib/sync.ts +++ b/apps/web/src/lib/sync.ts @@ -1,4 +1,5 @@ import { ensureGuestIdClient, resolveClientUserId } from "@/lib/guest.client"; +import { captureClientEvent } from "@/lib/posthog"; // Minimal single-socket sync client for /sync // Envelope: { id, ts, topic, type, data } @@ -47,6 +48,11 @@ async function openSocket() { connected = true; connecting = false; retry = 0; + captureClientEvent("sync.connection_state", { + state: "connected", + retry_count: retry, + tab_id: tabId, + }); // resubscribe current topics for (const [topic] of handlers) { ws.send(JSON.stringify({ op: "sub", topic })); @@ -72,9 +78,19 @@ async function openSocket() { connecting = false; // exponential backoff up to ~5s retry = Math.min(retry + 1, 5); + captureClientEvent("sync.connection_state", { + state: "retry", + retry_count: retry, + tab_id: tabId, + }); setTimeout(() => { if (!connected) void openSocket(); }, retry * 500); }; ws.onerror = () => { + captureClientEvent("sync.connection_state", { + state: "failed", + retry_count: retry, + tab_id: tabId, + }); try { ws.close(); } catch {} }; } diff --git a/posthog.md b/posthog.md new file mode 100644 index 00000000..baa9e360 --- /dev/null +++ b/posthog.md @@ -0,0 +1,110 @@ +# PostHog Event Plan + +This document outlines a PostHog instrumentation blueprint for the OpenChat monorepo (web, server, extension). It focuses on events that turn the app's critical flows into actionable insights and includes property suggestions, owners, and reporting ideas. + +--- + +## 1. Baseline Setup & Guardrails + +- **Client bootstrap (`apps/web/src/lib/posthog.ts`)** + - Continue initialising PostHog lazily. Add `client.register({ app: "openchat-web", app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? "dev" })`. + - Keep manual `$pageview`, but include `referrer_url: document.referrer || "direct"`, `referrer_domain`, `entry_path`, `entry_query`. This satisfies the "where user comes from" requirement even when PostHog’s default referrer can’t access cross-origin titles. +- **Server capture (`apps/web/src/lib/posthog-server.ts`, `apps/server/src/lib/posthog.ts`)** + - Register `environment`, `deployment_region`, `app: "openchat-server"`. + - Always call `captureServerEvent`/`capturePosthogEvent` inside `try/finally` blocks that already have a distinct user id; skip when unauthenticated. +- **Identity** + - Even without a full auth system, continue generating a stable guest/workspace id (`ensureGuestId*`). Call `identifyClient(distinctId)` once per session and optionally `posthog.group("workspace", workspaceId)` to keep chat cohorts together. + - Register lightweight super-properties: `auth_state: "guest"`, `has_openrouter_key`, `workspace_id`, `ui_theme` (dark/light), `brand_theme`. + +--- + +## 2. Core Event Taxonomy (Lean Set) + +These 14 events cover the highest-leverage insights without burning volume on low-signal noise. + +| Event | Trigger (file / hook) | Key properties | Value | +| --- | --- | --- | --- | +| `marketing.visit_landing` | `hero-section.tsx` on mount | `referrer_url`, `referrer_domain`, `utm_source`, `entry_path`, `session_is_guest` | Measures acquisition mix and landing conversion without relying on auth. | +| `marketing.cta_clicked` | Primary CTAs (`hero-section`, header CTA) | `cta_id` (`hero_try_openchat`, `hero_request_demo`), `cta_copy`, `section`, `screen_width_bucket` | Shows which CTAs turn visitors into users. | +| `dashboard.entered` | `dashboard/layout.tsx` after chat list load | `chat_total`, `has_api_key`, `entry_path`, `brand_theme` | Baseline active sessions and personalization adoption. | +| `chat.created` *(front + server)* | Sidebar “New Chat” + router success | `chat_id`, `source` (`sidebar_button`), `storage_backend` (`postgres`, `memory_fallback`), `title_length` | Tracks creation intent and fallback usage. | +| `chat_message_submitted` *(existing)* | `chat-room.tsx` send handler | `chat_id`, `model_id`, `characters`, `attachment_count`, `has_api_key` | Core usage + cost proxy by model and content length. | +| `chat_message_stream` *(existing server)* | `/api/chat` handler completion | `chat_id`, `model_id`, `status` (`completed`, `error`, `aborted`), `duration_ms`, `characters`, `openrouter_status`, `rate_limit_bucket` | Single source of truth for streaming health. | +| `chat.rate_limited` | 429 branch in `createChatHandler` | `chat_id`, `limit`, `window_ms`, `client_ip_hash_trunc` | Detect traffic spikes or abuse without extra server logs. | +| `chat.attachment_event` | `handleFileSelection` success/failure | `chat_id`, `result` (`accepted`, `rejected`), `file_mime`, `file_size_bytes`, `limit_bytes` | One event that captures attachment demand and guardrail friction. | +| `openrouter.key_prompt_shown` | `OpenRouterLinkModal` open | `reason` (`missing`, `error`), `has_api_key` | Measures API-key onboarding friction. | +| `openrouter.key_saved` | Successful save (modal or settings) | `source` (`modal`, `settings`), `masked_tail`, `scope` | Activation milestone toward usable chats. | +| `openrouter.key_removed` | `handleRemoveApiKey` | `source`, `had_models_cached` | Detects churn risk for paid usage. | +| `openrouter.models_fetch_failed` | Catch in `fetchModels` | `status`, `error_message`, `provider_host`, `has_api_key` | Alerts when model catalogue fails—critical reliability signal. | +| `sync.connection_state` | `apps/web/src/lib/sync.ts` (`onopen`, `onclose`, retry) | `state` (`connected`, `retry`, `failed`), `retry_count`, `tab_id` | Observability for live sync without extra logs. | +| `workspace.fallback_storage_used` | Server router fallback branches | `operation` (`create`, `list`, `send`, `streamUpsert`), `chat_id`, `fallback_size` | Immediate warning when DB connectivity regresses. | + +> **Naming convention**: use `scope.action` (lowercase, snake words). Keep existing `chat_message_submitted` naming and align new events to the same style. + +--- + +## 3. Common Properties + +Set these super-properties via `posthog.register` (client) and `client.capture({ properties })` (server): + +- `auth_state`: `"guest"` today; toggle to `"member"` once auth ships. +- `workspace_id`: the stable id from guest/session helpers. +- `has_openrouter_key`: boolean updated on key save/remove. +- `model_id`: last selected model (register inside `ModelSelector`’s `onChange`). +- `ui_theme`: `"light"`/`"dark"`. +- `brand_theme`: brand accent from `BrandThemeProvider`. +- `app_version`: surface via env (web & server). +- `deployment`: `"local"`, `"staging"`, `"prod"` using env flags. +- `electric_enabled`: from `process.env.NEXT_PUBLIC_ELECTRIC_URL` truthiness. + +For server-side events, add: + +- `origin`: host making the request (from `validateRequestOrigin`). +- `ip_hash`: SHA-256 of `pickClientIp(request)` truncated (first 8 bytes) to respect privacy. +- `openrouter_latency_ms`, `openrouter_status` when streaming. + +--- + +## 4. Dashboards & Insights + +1. **Acquisition to Activation Funnel** + - Steps: `marketing.visit_landing` → `marketing.cta_clicked` (CTA = `hero_try_openchat`) → `dashboard.entered` → `openrouter.key_saved`. + - Slice by `referrer_domain`, `utm_source`, `session_is_guest`. +2. **Chat Health Overview** + - Time-series of `chat_message_submitted` vs `chat_message_stream` (`status` split), average `duration_ms`, average `characters`. + - Breakdown by `model_id`, `has_openrouter_key`, `deployment`. +3. **Reliability Board** + - Track `chat.rate_limited`, `openrouter.models_fetch_failed`, `sync.connection_state` (`state = failed`), and `workspace.fallback_storage_used`. + - Alert when fallback usage > 5% or rate limits spike. +4. **Attachment Demand** + - Segment `chat.attachment_event` by `result`, `file_mime`, and `file_size_bytes` to justify storage roadmap. +5. **Model Performance Report** + - Compare `chat_message_stream` success rate, median `duration_ms`, and characters by `model_id`. + - Flag models with >5% `status = error` to trigger provider follow-up. + +--- + +## 5. Implementation Notes by File + +- `apps/web/src/components/hero-section.tsx`: fire `marketing.visit_landing` on mount and `marketing.cta_clicked` on CTA buttons. +- `apps/web/src/components/app-sidebar.tsx`: emit `chat.created` once create resolves; include `storage_backend` from response/fallback context. +- `apps/web/src/components/chat-room.tsx`: enrich `chat_message_submitted`, capture `chat.rate_limited` feedback (server response), and pass stats for `chat_message_stream`. +- `apps/web/src/components/chat-composer.tsx`: emit `chat.attachment_event` with `result` set appropriately. +- `apps/web/src/components/openrouter-link-modal.tsx` & `account-settings-modal.tsx`: handle `openrouter.key_prompt_shown`, `openrouter.key_saved`, `openrouter.key_removed`. +- `apps/web/src/components/model-selector.tsx`: update the `model_id` super-property on change. +- `apps/web/src/lib/sync.ts`: emit `sync.connection_state` inside `onopen`, `onclose`, and retry logic. +- `apps/server/src/routers/index.ts`: in catch blocks where memory fallbacks run, fire `workspace.fallback_storage_used`. +- `apps/web/src/app/api/chat/chat-handler.ts`: ensure `chat_message_stream` properties, emit `chat.rate_limited`, and capture latency/error metadata. +- `apps/web/src/lib/posthog.ts`: register shared super-properties during bootstrap. + +--- + +## 6. Next Steps + +1. Align engineering on naming conventions (`scope.action`) and property casing (snake_case). +2. Ship the `$pageview` enrichment + super-property registration so sessions always include referrer and workspace metadata. +3. Instrument the events in two passes: marketing surface (landing + CTA) followed by chat surface (creation, streaming, attachments, OpenRouter flows). +4. Validate in PostHog’s Live Events feed; confirm rate limiting and fallback events appear only when expected. +5. Build the dashboards above and set alert thresholds for reliability metrics. + +With this instrumentation in place, you’ll have lean but actionable coverage across acquisition, chat usage, reliability, and OpenRouter activation without burning through unnecessary event volume.