diff --git a/apps/server/src/lib/openrouter.ts b/apps/server/src/lib/openrouter.ts index 9f363724..a4aedf3f 100644 --- a/apps/server/src/lib/openrouter.ts +++ b/apps/server/src/lib/openrouter.ts @@ -11,6 +11,10 @@ const accountTable = account as any; const OPENROUTER_PROVIDER_ID = "openrouter"; const OPENROUTER_API_BASE = (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1").replace(/\/$/, ""); const DEFAULT_SCOPE = process.env.OPENROUTER_SCOPE || "openid offline_access models.read"; +const ENCRYPTION_VERSION = "v2"; +const ENCRYPTION_SALT = "openrouter:key-derivation-salt"; +const ENCRYPTION_ITERATIONS = 100_000; +const ENCRYPTION_KEY_LENGTH = 32; // AES-256-GCM expects 32-byte key export type OpenRouterModelSummary = { id: string; @@ -62,16 +66,20 @@ export function getDefaultScope() { return DEFAULT_SCOPE; } -function getEncryptionKey() { +function assertEncryptionSecret(): string { const secret = process.env.OPENROUTER_API_KEY_SECRET; if (!secret || secret.length < 16) { throw new Error("Missing OPENROUTER_API_KEY_SECRET env for encrypting OpenRouter API keys"); } - // Use PBKDF2 for key derivation - const salt = "openrouter:key-derivation-salt"; // should be a constant that remains stable across encryption/decryption - const iterations = 100000; // recommended minimum - const keylen = 32; // 256 bits for aes-256-gcm - return pbkdf2Sync(secret, salt, iterations, keylen, "sha256"); + return secret; +} + +function deriveEncryptionKey(secret: string) { + return pbkdf2Sync(secret, ENCRYPTION_SALT, ENCRYPTION_ITERATIONS, ENCRYPTION_KEY_LENGTH, "sha256"); +} + +function deriveLegacyKey(secret: string) { + return createHash("sha256").update(secret).digest(); } function base64UrlEncode(buffer: Buffer) { @@ -86,27 +94,43 @@ function base64UrlDecode(value: string) { } export function encryptApiKey(raw: string) { - const key = getEncryptionKey(); + const secret = assertEncryptionSecret(); + const key = deriveEncryptionKey(secret); const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); const ciphertext = Buffer.concat([cipher.update(raw, "utf8"), cipher.final()]); const authTag = cipher.getAuthTag(); - return `${base64UrlEncode(iv)}.${base64UrlEncode(authTag)}.${base64UrlEncode(ciphertext)}`; + return `${ENCRYPTION_VERSION}:${base64UrlEncode(iv)}.${base64UrlEncode(authTag)}.${base64UrlEncode(ciphertext)}`; } export function decryptApiKey(payload: string) { - const [ivEncoded, tagEncoded, dataEncoded] = payload.split("."); + const secret = assertEncryptionSecret(); + const [maybeVersion, rest] = payload.includes(":") ? payload.split(":", 2) : [null, null]; + const encryptedPayload = rest ?? payload; + const isVersioned = maybeVersion === ENCRYPTION_VERSION; + const [ivEncoded, tagEncoded, dataEncoded] = encryptedPayload.split("."); if (!ivEncoded || !tagEncoded || !dataEncoded) { throw new Error("Invalid OpenRouter API key payload"); } - const key = getEncryptionKey(); - const iv = base64UrlDecode(ivEncoded); - const authTag = base64UrlDecode(tagEncoded); - const ciphertext = base64UrlDecode(dataEncoded); - const decipher = createDecipheriv("aes-256-gcm", key, iv); - decipher.setAuthTag(authTag); - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); - return decrypted.toString("utf8"); + const attemptDecrypt = (key: Buffer) => { + const iv = base64UrlDecode(ivEncoded); + const authTag = base64UrlDecode(tagEncoded); + const ciphertext = base64UrlDecode(dataEncoded); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return decrypted.toString("utf8"); + }; + + try { + const key = deriveEncryptionKey(secret); + return attemptDecrypt(key); + } catch (error) { + // If the payload was not encrypted with the new scheme (no version prefix) fall back to pre-PBKDF2. + if (isVersioned) throw error; + const legacyKey = deriveLegacyKey(secret); + return attemptDecrypt(legacyKey); + } } export async function storeOpenRouterApiKey({ 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 5a3ae83e..d58aa844 100644 --- a/apps/web/src/app/dashboard/layout.tsx +++ b/apps/web/src/app/dashboard/layout.tsx @@ -9,23 +9,37 @@ import Script from "next/script"; import type { ChatSummary } from "@/types/server-router"; export const dynamic = "force-dynamic"; -const charMap = { - '<': '\\u003C', - '>': '\\u003E', - '/': '\\u002F', - '\\': '\\\\', - '\b': '\\b', - '\f': '\\f', - '\n': '\\n', - '\r': '\\r', - '\t': '\\t', - '\0': '\\0', - '\u2028': '\\u2028', - '\u2029': '\\u2029' +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(/[<>\b\f\n\r\t\0\u2028\u2029/\\]/g, x => charMap[x] || x); + return str.replace(UNSAFE_PATTERN, (char) => charMap[char] ?? char); } export default async function DashboardLayout({ children }: { children: ReactNode }) { @@ -47,9 +61,9 @@ export default async function DashboardLayout({ children }: { children: ReactNod
- +
(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 {} }; }