From a3cbf47023f6f66969ea53f0f596fa21540db5e3 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 15 Oct 2025 22:34:42 +0000 Subject: [PATCH 1/6] docs(posthog): add comprehensive PostHog event plan and instrumentation guide This commit introduces a detailed PostHog instrumentation blueprint for the OpenChat monorepo, covering web, server, and extension components. It outlines baseline setup, event taxonomy with triggers and properties, common properties, dashboards and insights strategies, implementation notes by file, and next steps for full analytics coverage. Key additions include event naming conventions, super-property registrations, enhanced event properties for auth, chat, settings, sync, and extension features, as well as recommended dashboard KPIs and monitoring plans to track acquisition, onboarding, chat health, personalization, and extension engagement. This documentation will guide engineering alignment and implementation to enable actionable analytics through PostHog. Co-authored-by: terragon-labs[bot] --- posthog.md | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 posthog.md diff --git a/posthog.md b/posthog.md new file mode 100644 index 00000000..f80bb755 --- /dev/null +++ b/posthog.md @@ -0,0 +1,149 @@ +# 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** + - Once a session resolves (`chat-room.tsx`, `AppSidebar`), call `identifyClient(userId)` and `posthog.group("workspace", workspaceId)` for collaboration metrics. For guests, register `auth_state: "guest"`. + - Register shared super-properties: `auth_state`, `has_openrouter_key`, `workspace_id`, `ui_theme` (dark/light), `brand_theme`. + +--- + +## 2. Core Event Taxonomy + +| Event | Trigger (file / hook) | Key properties | Why it matters | +| --- | --- | --- | --- | +| `marketing.visit_landing` | `hero-section.tsx` on mount | `referrer_url`, `referrer_domain`, `utm_source/medium/campaign`, `entry_path`, `session_is_guest` | Quantify acquisition sources and landing page conversion. | +| `marketing.cta_clicked` | Primary CTAs (`hero-section`, header, footer) | `cta_id` (`hero_try_openchat`, `hero_request_demo`, `header_dashboard`), `cta_copy`, `section`, `screen_width_bucket` | Measure CTA performance and feed experiments. | +| `marketing.menu_toggled` | Mobile nav toggle (`header.tsx`) | `state` (`open`/`closed`), `has_account` | Understand mobile navigation friction. | +| `auth.sign_in_started` | Submit in `sign-in-form.tsx` | `remember_me`, `email_domain` | Funnel start for sign-in. | +| `auth.sign_in` *(already exists)* | On success (`sign-in-form.tsx`) | Add: `method: "password"`, `remember_me`, `email_domain` | Track completions; align property naming. | +| `auth.sign_in_failed` | `sign-in-form.tsx` catch/toast error | `error_type`, `http_status` | Quantify failure reasons. | +| `auth.sign_up_started` | Submit in `sign-up-form.tsx` | `plan_hint` (if future tiers), `email_domain` | Funnel start for registrations. | +| `auth.sign_up` *(already exists)* | On success (`sign-up-form.tsx`) | Add: `method`, `email_domain` | Activation metric. | +| `auth.sign_up_failed` | Error branch (`sign-up-form.tsx`) | `error_type`, `http_status` | Detect validation friction. | +| `auth.sign_out` | After `handleSignOut` (`account-settings-modal.tsx`) | `session_length_minutes`, `chat_count` | Retention signal and clean cohorts. | +| `dashboard.entered` | `dashboard/layout.tsx` after chats load | `chat_total`, `has_api_key`, `auth_state`, `entry_path` | Baseline for active users. | +| `sidebar.chat_selected` | Link click in `ChatList` (`app-sidebar.tsx`) | `chat_id`, `position_index`, `source: "sidebar"`, `previous_chat_id` | Understand navigation patterns. | +| `sidebar.chat_delete_confirmation` | Delete button click (`ChatList`) | `chat_id`, `has_messages`, `is_guest` | Gauge destructive action frequency. | +| `chat.created` *(existing front/back)* | Sidebar button (`app-sidebar.tsx`) & server router | Add properties: `source` (`sidebar_button`, `auto`), `title_length`, `storage_backend` (`postgres`, `memory_fallback`) | Tie creation intent with backend success. | +| `chat.deleted` | Success path in `client.chats.delete` promise | `chat_id`, `message_count`, `storage_backend` | Measure churn of conversations. | +| `chat_message_submitted` *(existing)* | `chat-room.tsx` send handler | Add: `attachment_count`, `has_api_key`, `model_id`, `characters`, `input_latency_ms` (keydown→send). | Core usage + leading indicator for spend. | +| `chat.stream_started` | Before `streamText` call (`chat-handler.ts`) | `chat_id`, `model_id`, `openrouter_base`, `user_character_count` | Count completions vs attempts. | +| `chat_message_stream` *(existing server)* | Already captured in handler | Extend props: `chunk_count`, `error_code`, `openrouter_status`, `rate_limit_bucket`, `trail_latency_ms`. | Deep health KPI for completions. | +| `chat.stream_stopped` | `onStop` in `ChatComposer` | `chat_id`, `model_id`, `elapsed_ms`, `reason` (`user_stop`) | Identify interruption patterns. | +| `chat.attachment_added` | `handleFileSelection` success (`chat-composer.tsx`) | `file_mime`, `file_size_bytes`, `attachment_count` | Demand for multimodal features. | +| `chat.attachment_rejected` | Attachment > 5 MB path | `file_name`, `file_size_bytes`, `limit_bytes` | Justify raising limits or copy tweaks. | +| `chat.scroll_to_bottom_clicked` | Button in `chat-messages-panel.tsx` | `message_count`, `unpinned_distance_px` | Diagnose long thread UX. | +| `openrouter.key_prompt_shown` | `OpenRouterLinkModal` open | `reason` (`missing`, `error`), `api_key_present` | Track onboarding friction. | +| `openrouter.key_saved` | Success in `handleSaveApiKey` (modal + account settings) | `source` (`modal`, `settings`), `masked_tail`, `scope` | Activation milestone. | +| `openrouter.key_removed` | `handleRemoveApiKey` | `source`, `had_models_cached` | Detect churn risk. | +| `openrouter.models_fetch_started` | Before fetch POST `/api/openrouter/models` | `provider_host` | Baseline latency metrics. | +| `openrouter.models_fetch_failed` | Catch in `fetchModels` (`chat-room.tsx`) | `status`, `error_message`, `api_key_present` | Health signal for vendor issues. | +| `openrouter.model_selected` | `onChange` in `ModelSelector` | `model_id`, `pricing_prompt`, `pricing_completion`, `context_length` | Allows per-model retention split. | +| `openrouter.requirement_missing` | `handleMissingRequirement` | `requirement` (`apiKey`, `model`), `chat_id` | Detect gating blockers. | +| `settings.viewed` | `SettingsPageClient` mount | `is_guest`, `has_api_key`, `theme` | Settings engagement. | +| `settings.account_modal_opened` | `setOpen(true)` in settings/account | `is_guest` | Confirm guests hit paywall. | +| `settings.guest_cta_clicked` | Guest button → `/auth/sign-in` | `cta_copy`, `location` | Evaluate guest-to-auth conversion. | +| `theme.toggle` | `ThemeToggle` click | `from_theme`, `to_theme`, `auth_state` | Measure dark mode preference. | +| `brand_theme.selected` | `ThemeSelector` setTheme | `brand_theme_id`, `previous_theme` | Input for appearance roadmap. | +| `account.modal_opened` | Header/account button | `session_state` | Quick access usage. | +| `account.user_id_copied` | `handleCopyUserId` | `account_type` | Niche but indicates power users/devs. | +| `sync.connection_state` | `connect()` success/error (`apps/web/src/lib/sync.ts`) | `state` (`connected`, `retry`, `failed`), `retry_count`, `tab_id` | Track live sync reliability. | +| `sync.topic_subscription` | `subscribe()` | `topic`, `handler_count`, `tab_id` | Understand load on hub. | +| `workspace.fallback_storage_used` | `catch` branches in server router when DB fails | `chat_id`, `operation` (`create`, `list`, `send`, `streamUpsert`), `fallback_size` | Alert when falling back to in-memory stores. | +| `rpc.error` | ORPC client error wrapper | `procedure`, `http_status`, `error_code`, `auth_state` | Spot failing backend routes. | +| `rate_limit.hit` | `createChatHandler` rate limited branch | `limit`, `window_ms`, `client_ip_hash` | Monitor traffic spikes. | +| `extension.popup_opened` | `apps/extension/entrypoints/popup/main.tsx` render | `browser`, `extension_version` | Baseline for extension usage. | +| `extension.counter_incremented` | Button click in popup | `count` | Placeholder until real features ship. | + +> **Naming convention**: use `scope.action` (lowercase, snake words). Keep existing `auth.sign_in`/`chat_message_submitted` but 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"` or `"member"`. +- `workspace_id`: `session.user.id` (still set for guests). +- `plan_tier`: `"free"` for guests, future paid tiers later. +- `has_openrouter_key`: boolean; update whenever the key is saved/removed. +- `model_id`: last selected model (register after `openrouter.model_selected`). +- `ui_theme`: `"light"`/`"dark"`. +- `brand_theme`: `BrandThemeProvider` current theme id. +- `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 to respect privacy. +- `openrouter_latency_ms`, `openrouter_status`, `stream_status` where applicable. + +--- + +## 4. Dashboards & Insights + +1. **Activation Funnel** + - Steps: `marketing.visit_landing` → `marketing.cta_clicked` (CTA = `hero_try_openchat`) → `auth.sign_up_started` → `auth.sign_up` → `dashboard.entered` → `openrouter.key_saved`. + - Slice by `referrer_domain`, `utm_source`, `auth_state`. +2. **Chat Health Overview** + - Time-series of `chat_message_submitted`, `chat_message_stream` (`status` split), average `durationMs`, average `characters`. + - Breakdown by `model_id`, `has_openrouter_key`, `deployment`. +3. **Sync Reliability Board** + - Monitor `sync.connection_state` (`state = failed`), `workspace.fallback_storage_used`, `rpc.error`. + - Set alerts when fallback usage > 5% of total events. +4. **Personalization Adoption** + - Pie charts for `ui_theme`, `brand_theme.selected`. + - Funnel `settings.viewed` → `theme.toggle`/`brand_theme.selected`. +5. **Attachment Usage** + - Count of `chat.attachment_added` vs `chat.attachment_rejected`. + - Weighted by file type to justify storage roadmap. +6. **Model Performance Report** + - Compare `chat_message_stream` success rate, median `durationMs`, and tokens (approx via `characters`) by `model_id`. + - Flag models with >5% error rate (`status = error`). +7. **Guest vs Authenticated Cohort Retention** + - Use `auth_state` property to build retention curves and segment `chat.created`, `openrouter.key_saved`. + +--- + +## 5. Implementation Notes by File + +- `apps/web/src/components/hero-section.tsx`: fire landing + CTA events using `captureClientEvent`. +- `apps/web/src/components/auth/sign-*.tsx`: wrap submission and error flows. +- `apps/web/src/components/app-sidebar.tsx`: emit selection/deletion events; include fallback detection by reading the local `fallbackChats` vs `electric`. +- `apps/web/src/components/chat-room.tsx`: enrich existing events with new properties, add attachments + requirement events. +- `apps/web/src/components/chat-composer.tsx`: instrument attachments and stop button. +- `apps/web/src/components/chat-messages-panel.tsx`: scroll-to-bottom. +- `apps/web/src/components/openrouter-link-modal.tsx` & `account-settings-modal.tsx`: API key lifecycle events. +- `apps/web/src/components/settings-page-client.tsx`: `settings.viewed`, guest CTA, theme picks. +- `apps/web/src/components/theme-toggle.tsx` & `settings/theme-selector.tsx`: theme events. +- `apps/web/src/lib/sync.ts`: connection/subscription telemetry. +- `apps/server/src/routers/index.ts`: in catch blocks where fallback memory is used, capture `workspace.fallback_storage_used`. +- `apps/web/src/app/api/chat/chat-handler.ts`: expand streaming events, rate-limit hits, API failures. +- `apps/web/src/utils/orpc.ts`: wrap `client` calls with error instrumentation for `rpc.error`. +- `apps/extension/entrypoints/popup/*.tsx`: minimal signals for extension adoption (optional now). + +--- + +## 6. Next Steps + +1. Align engineering on naming conventions (`scope.action`) and property casing (snake_case). +2. Implement super-property registration + `$pageview` enhancement (referrer/domain) first so every session has provenance. +3. Iterate on event additions by surface (auth → chat → settings), deploying behind a feature flag if needed. +4. Validate in PostHog using the "Live events" feed; ensure events marry to the correct distinct ids (guest vs authenticated). +5. Build dashboards in the order above, then share with stakeholders for feedback. + +With this instrumentation in place, you’ll have the coverage needed to monitor acquisition sources, onboarding friction, chat reliability, personalization adoption, and extension engagement—all within PostHog. + From c30bc857eb601d975266ef85a340fda51845eb06 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 15 Oct 2025 22:40:47 +0000 Subject: [PATCH 2/6] feat(analytics): refine and streamline PostHog event taxonomy and instrumentation - Reduced and optimized core event sets to focus on high-leverage insights, minimizing low-signal noise. - Standardized event naming conventions to lowercase snake_case with scope.action structure. - Updated super-properties registration for better session identification including guest handling. - Enhanced event properties for chat creation, streaming, attachments, and OpenRouter API key lifecycle. - Added new telemetry events for sync connection state, fallback storage usage, and chat rate limiting. - Revised dashboard and funnel definitions to reflect streamlined event model and metrics. - Improved implementation notes for consistency and expanded coverage across web, server, and extension components. This update enables effective monitoring of acquisition, chat usage, reliability, and onboarding activation without excess event volume. Co-authored-by: terragon-labs[bot] --- posthog.md | 151 ++++++++++++++++++++--------------------------------- 1 file changed, 56 insertions(+), 95 deletions(-) diff --git a/posthog.md b/posthog.md index f80bb755..baa9e360 100644 --- a/posthog.md +++ b/posthog.md @@ -13,60 +13,33 @@ This document outlines a PostHog instrumentation blueprint for the OpenChat mono - 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** - - Once a session resolves (`chat-room.tsx`, `AppSidebar`), call `identifyClient(userId)` and `posthog.group("workspace", workspaceId)` for collaboration metrics. For guests, register `auth_state: "guest"`. - - Register shared super-properties: `auth_state`, `has_openrouter_key`, `workspace_id`, `ui_theme` (dark/light), `brand_theme`. + - 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 +## 2. Core Event Taxonomy (Lean Set) -| Event | Trigger (file / hook) | Key properties | Why it matters | +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/medium/campaign`, `entry_path`, `session_is_guest` | Quantify acquisition sources and landing page conversion. | -| `marketing.cta_clicked` | Primary CTAs (`hero-section`, header, footer) | `cta_id` (`hero_try_openchat`, `hero_request_demo`, `header_dashboard`), `cta_copy`, `section`, `screen_width_bucket` | Measure CTA performance and feed experiments. | -| `marketing.menu_toggled` | Mobile nav toggle (`header.tsx`) | `state` (`open`/`closed`), `has_account` | Understand mobile navigation friction. | -| `auth.sign_in_started` | Submit in `sign-in-form.tsx` | `remember_me`, `email_domain` | Funnel start for sign-in. | -| `auth.sign_in` *(already exists)* | On success (`sign-in-form.tsx`) | Add: `method: "password"`, `remember_me`, `email_domain` | Track completions; align property naming. | -| `auth.sign_in_failed` | `sign-in-form.tsx` catch/toast error | `error_type`, `http_status` | Quantify failure reasons. | -| `auth.sign_up_started` | Submit in `sign-up-form.tsx` | `plan_hint` (if future tiers), `email_domain` | Funnel start for registrations. | -| `auth.sign_up` *(already exists)* | On success (`sign-up-form.tsx`) | Add: `method`, `email_domain` | Activation metric. | -| `auth.sign_up_failed` | Error branch (`sign-up-form.tsx`) | `error_type`, `http_status` | Detect validation friction. | -| `auth.sign_out` | After `handleSignOut` (`account-settings-modal.tsx`) | `session_length_minutes`, `chat_count` | Retention signal and clean cohorts. | -| `dashboard.entered` | `dashboard/layout.tsx` after chats load | `chat_total`, `has_api_key`, `auth_state`, `entry_path` | Baseline for active users. | -| `sidebar.chat_selected` | Link click in `ChatList` (`app-sidebar.tsx`) | `chat_id`, `position_index`, `source: "sidebar"`, `previous_chat_id` | Understand navigation patterns. | -| `sidebar.chat_delete_confirmation` | Delete button click (`ChatList`) | `chat_id`, `has_messages`, `is_guest` | Gauge destructive action frequency. | -| `chat.created` *(existing front/back)* | Sidebar button (`app-sidebar.tsx`) & server router | Add properties: `source` (`sidebar_button`, `auto`), `title_length`, `storage_backend` (`postgres`, `memory_fallback`) | Tie creation intent with backend success. | -| `chat.deleted` | Success path in `client.chats.delete` promise | `chat_id`, `message_count`, `storage_backend` | Measure churn of conversations. | -| `chat_message_submitted` *(existing)* | `chat-room.tsx` send handler | Add: `attachment_count`, `has_api_key`, `model_id`, `characters`, `input_latency_ms` (keydown→send). | Core usage + leading indicator for spend. | -| `chat.stream_started` | Before `streamText` call (`chat-handler.ts`) | `chat_id`, `model_id`, `openrouter_base`, `user_character_count` | Count completions vs attempts. | -| `chat_message_stream` *(existing server)* | Already captured in handler | Extend props: `chunk_count`, `error_code`, `openrouter_status`, `rate_limit_bucket`, `trail_latency_ms`. | Deep health KPI for completions. | -| `chat.stream_stopped` | `onStop` in `ChatComposer` | `chat_id`, `model_id`, `elapsed_ms`, `reason` (`user_stop`) | Identify interruption patterns. | -| `chat.attachment_added` | `handleFileSelection` success (`chat-composer.tsx`) | `file_mime`, `file_size_bytes`, `attachment_count` | Demand for multimodal features. | -| `chat.attachment_rejected` | Attachment > 5 MB path | `file_name`, `file_size_bytes`, `limit_bytes` | Justify raising limits or copy tweaks. | -| `chat.scroll_to_bottom_clicked` | Button in `chat-messages-panel.tsx` | `message_count`, `unpinned_distance_px` | Diagnose long thread UX. | -| `openrouter.key_prompt_shown` | `OpenRouterLinkModal` open | `reason` (`missing`, `error`), `api_key_present` | Track onboarding friction. | -| `openrouter.key_saved` | Success in `handleSaveApiKey` (modal + account settings) | `source` (`modal`, `settings`), `masked_tail`, `scope` | Activation milestone. | -| `openrouter.key_removed` | `handleRemoveApiKey` | `source`, `had_models_cached` | Detect churn risk. | -| `openrouter.models_fetch_started` | Before fetch POST `/api/openrouter/models` | `provider_host` | Baseline latency metrics. | -| `openrouter.models_fetch_failed` | Catch in `fetchModels` (`chat-room.tsx`) | `status`, `error_message`, `api_key_present` | Health signal for vendor issues. | -| `openrouter.model_selected` | `onChange` in `ModelSelector` | `model_id`, `pricing_prompt`, `pricing_completion`, `context_length` | Allows per-model retention split. | -| `openrouter.requirement_missing` | `handleMissingRequirement` | `requirement` (`apiKey`, `model`), `chat_id` | Detect gating blockers. | -| `settings.viewed` | `SettingsPageClient` mount | `is_guest`, `has_api_key`, `theme` | Settings engagement. | -| `settings.account_modal_opened` | `setOpen(true)` in settings/account | `is_guest` | Confirm guests hit paywall. | -| `settings.guest_cta_clicked` | Guest button → `/auth/sign-in` | `cta_copy`, `location` | Evaluate guest-to-auth conversion. | -| `theme.toggle` | `ThemeToggle` click | `from_theme`, `to_theme`, `auth_state` | Measure dark mode preference. | -| `brand_theme.selected` | `ThemeSelector` setTheme | `brand_theme_id`, `previous_theme` | Input for appearance roadmap. | -| `account.modal_opened` | Header/account button | `session_state` | Quick access usage. | -| `account.user_id_copied` | `handleCopyUserId` | `account_type` | Niche but indicates power users/devs. | -| `sync.connection_state` | `connect()` success/error (`apps/web/src/lib/sync.ts`) | `state` (`connected`, `retry`, `failed`), `retry_count`, `tab_id` | Track live sync reliability. | -| `sync.topic_subscription` | `subscribe()` | `topic`, `handler_count`, `tab_id` | Understand load on hub. | -| `workspace.fallback_storage_used` | `catch` branches in server router when DB fails | `chat_id`, `operation` (`create`, `list`, `send`, `streamUpsert`), `fallback_size` | Alert when falling back to in-memory stores. | -| `rpc.error` | ORPC client error wrapper | `procedure`, `http_status`, `error_code`, `auth_state` | Spot failing backend routes. | -| `rate_limit.hit` | `createChatHandler` rate limited branch | `limit`, `window_ms`, `client_ip_hash` | Monitor traffic spikes. | -| `extension.popup_opened` | `apps/extension/entrypoints/popup/main.tsx` render | `browser`, `extension_version` | Baseline for extension usage. | -| `extension.counter_incremented` | Button click in popup | `count` | Placeholder until real features ship. | - -> **Naming convention**: use `scope.action` (lowercase, snake words). Keep existing `auth.sign_in`/`chat_message_submitted` but align new events to the same style. +| `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. --- @@ -74,13 +47,12 @@ This document outlines a PostHog instrumentation blueprint for the OpenChat mono Set these super-properties via `posthog.register` (client) and `client.capture({ properties })` (server): -- `auth_state`: `"guest"` or `"member"`. -- `workspace_id`: `session.user.id` (still set for guests). -- `plan_tier`: `"free"` for guests, future paid tiers later. -- `has_openrouter_key`: boolean; update whenever the key is saved/removed. -- `model_id`: last selected model (register after `openrouter.model_selected`). +- `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`: `BrandThemeProvider` current theme id. +- `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. @@ -88,62 +60,51 @@ Set these super-properties via `posthog.register` (client) and `client.capture({ For server-side events, add: - `origin`: host making the request (from `validateRequestOrigin`). -- `ip_hash`: SHA-256 of `pickClientIp(request)` truncated to respect privacy. -- `openrouter_latency_ms`, `openrouter_status`, `stream_status` where applicable. +- `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. **Activation Funnel** - - Steps: `marketing.visit_landing` → `marketing.cta_clicked` (CTA = `hero_try_openchat`) → `auth.sign_up_started` → `auth.sign_up` → `dashboard.entered` → `openrouter.key_saved`. - - Slice by `referrer_domain`, `utm_source`, `auth_state`. +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`, `chat_message_stream` (`status` split), average `durationMs`, average `characters`. + - 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. **Sync Reliability Board** - - Monitor `sync.connection_state` (`state = failed`), `workspace.fallback_storage_used`, `rpc.error`. - - Set alerts when fallback usage > 5% of total events. -4. **Personalization Adoption** - - Pie charts for `ui_theme`, `brand_theme.selected`. - - Funnel `settings.viewed` → `theme.toggle`/`brand_theme.selected`. -5. **Attachment Usage** - - Count of `chat.attachment_added` vs `chat.attachment_rejected`. - - Weighted by file type to justify storage roadmap. -6. **Model Performance Report** - - Compare `chat_message_stream` success rate, median `durationMs`, and tokens (approx via `characters`) by `model_id`. - - Flag models with >5% error rate (`status = error`). -7. **Guest vs Authenticated Cohort Retention** - - Use `auth_state` property to build retention curves and segment `chat.created`, `openrouter.key_saved`. +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 landing + CTA events using `captureClientEvent`. -- `apps/web/src/components/auth/sign-*.tsx`: wrap submission and error flows. -- `apps/web/src/components/app-sidebar.tsx`: emit selection/deletion events; include fallback detection by reading the local `fallbackChats` vs `electric`. -- `apps/web/src/components/chat-room.tsx`: enrich existing events with new properties, add attachments + requirement events. -- `apps/web/src/components/chat-composer.tsx`: instrument attachments and stop button. -- `apps/web/src/components/chat-messages-panel.tsx`: scroll-to-bottom. -- `apps/web/src/components/openrouter-link-modal.tsx` & `account-settings-modal.tsx`: API key lifecycle events. -- `apps/web/src/components/settings-page-client.tsx`: `settings.viewed`, guest CTA, theme picks. -- `apps/web/src/components/theme-toggle.tsx` & `settings/theme-selector.tsx`: theme events. -- `apps/web/src/lib/sync.ts`: connection/subscription telemetry. -- `apps/server/src/routers/index.ts`: in catch blocks where fallback memory is used, capture `workspace.fallback_storage_used`. -- `apps/web/src/app/api/chat/chat-handler.ts`: expand streaming events, rate-limit hits, API failures. -- `apps/web/src/utils/orpc.ts`: wrap `client` calls with error instrumentation for `rpc.error`. -- `apps/extension/entrypoints/popup/*.tsx`: minimal signals for extension adoption (optional now). +- `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. Implement super-property registration + `$pageview` enhancement (referrer/domain) first so every session has provenance. -3. Iterate on event additions by surface (auth → chat → settings), deploying behind a feature flag if needed. -4. Validate in PostHog using the "Live events" feed; ensure events marry to the correct distinct ids (guest vs authenticated). -5. Build dashboards in the order above, then share with stakeholders for feedback. - -With this instrumentation in place, you’ll have the coverage needed to monitor acquisition sources, onboarding friction, chat reliability, personalization adoption, and extension engagement—all within PostHog. +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. From c79243bb1cd32597f14e97e3ef4e9adfddf1757f Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 15 Oct 2025 23:01:36 +0000 Subject: [PATCH 3/6] feat(analytics): enhance PostHog integration and event tracking - Add base super properties and sanitize event properties for client and server PostHog usage - Track fallback storage usage in server router for chats and messages - Add detailed client event tracking in web chat handler and components - Introduce PosthogBootstrap component for consistent client identification and property registration - Enhance event tracking with user workspace IDs, auth states, theme info, and OpenRouter API key status - Track marketing CTA clicks and landing visits with detailed metadata - Improve rate limit and error event captures for chat and OpenRouter APIs - Add client property registration on model selection and OpenRouter key management - Add sync connection state events to PostHog This update improves analytics granularity and consistency across client and server, enabling better user behavior insights and troubleshooting. Co-authored-by: terragon-labs[bot] --- apps/server/src/lib/posthog.ts | 48 +++++++-- apps/server/src/routers/index.ts | 95 ++++++++++++----- apps/web/src/app/api/chat/chat-handler.ts | 63 ++++++++--- .../src/components/account-settings-modal.tsx | 13 +++ apps/web/src/components/app-sidebar.tsx | 52 +++++++-- apps/web/src/components/chat-composer.tsx | 21 ++++ apps/web/src/components/chat-room.tsx | 100 ++++++++++++++++-- apps/web/src/components/hero-section.tsx | 70 +++++++++++- apps/web/src/components/model-selector.tsx | 7 ++ .../src/components/openrouter-link-modal.tsx | 29 ++++- apps/web/src/components/posthog-bootstrap.tsx | 82 ++++++++++++++ apps/web/src/components/providers.tsx | 20 +++- apps/web/src/lib/posthog-server.ts | 46 +++++++- apps/web/src/lib/posthog.ts | 50 ++++++++- apps/web/src/lib/sync.ts | 16 +++ 15 files changed, 635 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/components/posthog-bootstrap.tsx 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/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 {} }; } From 3722fbd6b3c09aeefba8709fce6a680d0654f746 Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 15 Oct 2025 22:13:07 -0400 Subject: [PATCH 4/6] fix: sanitize user id before bootstrapping client --- apps/web/src/app/dashboard/layout.tsx | 35 ++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx index ba6d6a4f..2e9909a7 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(); @@ -29,7 +62,7 @@ export default async function DashboardLayout({ children }: { children: ReactNod
Date: Wed, 15 Oct 2025 22:17:32 -0400 Subject: [PATCH 5/6] fix: derive OpenRouter key with PBKDF2 --- apps/server/src/lib/openrouter.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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) { From 9725836cc097d7a7e2d68789f0d20c9ab448fa5a Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 16 Oct 2025 17:53:17 -0400 Subject: [PATCH 6/6] chore(web): tidy dashboard layout indentation --- apps/web/src/app/dashboard/layout.tsx | 36 +++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx index 2e9909a7..f3de438b 100644 --- a/apps/web/src/app/dashboard/layout.tsx +++ b/apps/web/src/app/dashboard/layout.tsx @@ -60,24 +60,24 @@ export default async function DashboardLayout({ children }: { children: ReactNod
-
- -
- - - - -
-
- {children} -
-
+
+ +
+ + + + +
+
+ {children} +
+
); }