From 2dd79ae0ec4b9f6dbd6eab9af72281efd5ddcb1f Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 24 Apr 2026 14:06:08 -0300 Subject: [PATCH 01/37] fix(prompts): add explicit verb-first titles to all studio guide prompts (#3145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(prompts): derive display title from prompt name when title is absent Prompts registered via the old server.prompt() API don't carry a title field, causing the UI fallback (displayToolName) to display the raw namespaced slug — e.g. "H0jwredec58c… Self Writing Prompts" instead of "Writing Prompts". aggregatePrompts() now sets title to a human-readable Title Case string derived from the original (pre-namespace) prompt name when the upstream prompt has no title. Co-Authored-By: Claude Sonnet 4.6 * fix(ci): fix TS2532 in titleFromName and stabilize flaky jwt expiry test Use charAt(0) instead of [0] to avoid noUncheckedIndexedAccess error. Increase JWT expiry test from 1s/1.5s wait to 2s/3s to avoid false failures on loaded CI runners. Co-Authored-By: Claude Sonnet 4.6 * fix(prompts): add explicit verb-first titles to all guide prompts Switch from server.prompt() to server.registerPrompt() so the title field is included in the MCP response. Each guide prompt now has a clear verb-first title (e.g. "Create Agents", "Update Connections") rather than the garbled fallback derived from the kebab-case name. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- apps/mesh/src/tools/guides/agents.ts | 3 +++ apps/mesh/src/tools/guides/ai-providers.ts | 1 + apps/mesh/src/tools/guides/automations.ts | 2 ++ apps/mesh/src/tools/guides/connections.ts | 3 +++ apps/mesh/src/tools/guides/index.ts | 1 + apps/mesh/src/tools/guides/store.ts | 2 ++ apps/mesh/src/tools/guides/virtual-tools.ts | 2 ++ apps/mesh/src/tools/index.ts | 20 ++++++++++++-------- 8 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/mesh/src/tools/guides/agents.ts b/apps/mesh/src/tools/guides/agents.ts index 1887c90847..a5380b9b87 100644 --- a/apps/mesh/src/tools/guides/agents.ts +++ b/apps/mesh/src/tools/guides/agents.ts @@ -3,6 +3,7 @@ import type { GuidePrompt, GuideResource } from "./index"; export const prompts: GuidePrompt[] = [ { name: "agents-create", + title: "Create Agents", description: "Build a new agent for a specific role or workflow.", text: `# Create agent @@ -27,6 +28,7 @@ Checks: }, { name: "agents-update", + title: "Update Agents", description: "Modify an existing agent's behavior, connections, or instructions.", text: `# Update agent @@ -52,6 +54,7 @@ Checks: }, { name: "writing-prompts", + title: "Improve Instructions", description: "Improve instructions for an agent or automation.", text: `# Writing instructions diff --git a/apps/mesh/src/tools/guides/ai-providers.ts b/apps/mesh/src/tools/guides/ai-providers.ts index 3f82948695..032eb72ca0 100644 --- a/apps/mesh/src/tools/guides/ai-providers.ts +++ b/apps/mesh/src/tools/guides/ai-providers.ts @@ -3,6 +3,7 @@ import type { GuidePrompt, GuideResource } from "./index"; export const prompts: GuidePrompt[] = [ { name: "ai-providers-setup", + title: "Set Up AI Provider", description: "Set up an AI provider so the workspace can use its models.", text: `# Set up AI provider diff --git a/apps/mesh/src/tools/guides/automations.ts b/apps/mesh/src/tools/guides/automations.ts index 0184a78e39..c6b1b5f233 100644 --- a/apps/mesh/src/tools/guides/automations.ts +++ b/apps/mesh/src/tools/guides/automations.ts @@ -3,6 +3,7 @@ import type { GuidePrompt, GuideResource } from "./index"; export const prompts: GuidePrompt[] = [ { name: "automations-create", + title: "Create Automations", description: "Set up a background workflow that runs on a schedule, event, or webhook.", text: `# Create automation @@ -28,6 +29,7 @@ Checks: }, { name: "automations-update", + title: "Update Automations", description: "Change an automation's triggers, agent, or configuration.", text: `# Update automation diff --git a/apps/mesh/src/tools/guides/connections.ts b/apps/mesh/src/tools/guides/connections.ts index c3685ab5c9..113887e24f 100644 --- a/apps/mesh/src/tools/guides/connections.ts +++ b/apps/mesh/src/tools/guides/connections.ts @@ -3,6 +3,7 @@ import type { GuidePrompt, GuideResource } from "./index"; export const prompts: GuidePrompt[] = [ { name: "connections-create", + title: "Create Connections", description: "Add a new MCP server connection to the workspace.", text: `# Create connection @@ -27,6 +28,7 @@ Checks: }, { name: "connections-update", + title: "Update Connections", description: "Change an existing connection's settings or credentials.", text: `# Update connection @@ -50,6 +52,7 @@ Checks: }, { name: "connections-troubleshoot", + title: "Troubleshoot Connections", description: "Fix a broken or unhealthy connection.", text: `# Troubleshoot connection diff --git a/apps/mesh/src/tools/guides/index.ts b/apps/mesh/src/tools/guides/index.ts index b4adebf9cc..38c9f038dc 100644 --- a/apps/mesh/src/tools/guides/index.ts +++ b/apps/mesh/src/tools/guides/index.ts @@ -8,6 +8,7 @@ import * as virtualTools from "./virtual-tools"; export interface GuidePrompt { name: string; + title: string; description: string; text: string; } diff --git a/apps/mesh/src/tools/guides/store.ts b/apps/mesh/src/tools/guides/store.ts index 9ace74be59..7794b240cc 100644 --- a/apps/mesh/src/tools/guides/store.ts +++ b/apps/mesh/src/tools/guides/store.ts @@ -3,6 +3,7 @@ import type { GuidePrompt, GuideResource } from "./index"; export const prompts: GuidePrompt[] = [ { name: "store-search", + title: "Search Store", description: "Find MCP servers in the Deco Store or a registry.", text: `# Search store @@ -30,6 +31,7 @@ Checks: }, { name: "store-install", + title: "Install from Store", description: "Install an MCP server from a store or registry.", text: `# Install MCP server from store diff --git a/apps/mesh/src/tools/guides/virtual-tools.ts b/apps/mesh/src/tools/guides/virtual-tools.ts index 5743067377..0bd7ab662e 100644 --- a/apps/mesh/src/tools/guides/virtual-tools.ts +++ b/apps/mesh/src/tools/guides/virtual-tools.ts @@ -3,6 +3,7 @@ import type { GuidePrompt, GuideResource } from "./index"; export const prompts: GuidePrompt[] = [ { name: "virtual-tools-create", + title: "Create Virtual Tools", description: "Add a sandboxed JavaScript tool to an agent.", text: `# Create virtual tool @@ -28,6 +29,7 @@ Checks: }, { name: "virtual-tools-update", + title: "Update Virtual Tools", description: "Modify a virtual tool's code or schema safely.", text: `# Update virtual tool diff --git a/apps/mesh/src/tools/index.ts b/apps/mesh/src/tools/index.ts index d39478c116..97348e10f6 100644 --- a/apps/mesh/src/tools/index.ts +++ b/apps/mesh/src/tools/index.ts @@ -276,14 +276,18 @@ export const managementMCP = async (ctx: MeshContext) => { // Register action prompts const prompts = getPrompts(); for (const prompt of prompts) { - server.prompt(prompt.name, prompt.description, () => ({ - messages: [ - { - role: "user" as const, - content: { type: "text" as const, text: prompt.text }, - }, - ], - })); + server.registerPrompt( + prompt.name, + { title: prompt.title, description: prompt.description }, + () => ({ + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: prompt.text }, + }, + ], + }), + ); } // Register one prompt per brand context (e.g. /brand-acme-corp) From 0639eb51d1e0bdd0ea18c7aa38b059d1261994a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 17:06:21 +0000 Subject: [PATCH 02/37] [release]: bump to 2.274.1 --- apps/mesh/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 10c9e4fcc5..f5a89c7c7a 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -1,6 +1,6 @@ { "name": "decocms", - "version": "2.274.0", + "version": "2.274.1", "description": "Deco CMS — Self-hostable MCP Gateway for managing AI connections and tools", "author": "Deco team", "repository": { From 0c2f9a7943576e34a6bda4633a9f2486b24d18bd Mon Sep 17 00:00:00 2001 From: guitavano Date: Fri, 24 Apr 2026 14:13:28 -0300 Subject: [PATCH 03/37] fix(deco-sites): avoid duplicate profile insert on service account creation (#3176) Supabase has a DB trigger that auto-creates a profiles row when a new auth user is created. The explicit INSERT was hitting a unique constraint violation (profiles_user_id_key) on the first call, causing a 409/500. Now we check if the profile already exists before attempting to insert. Co-authored-by: Claude Opus 4.6 --- apps/mesh/src/api/routes/deco-sites.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/mesh/src/api/routes/deco-sites.ts b/apps/mesh/src/api/routes/deco-sites.ts index af7b9d8931..83acf7e9e8 100644 --- a/apps/mesh/src/api/routes/deco-sites.ts +++ b/apps/mesh/src/api/routes/deco-sites.ts @@ -236,12 +236,19 @@ async function getOrCreateTeamServiceAccount( email, ); - // 2. Create profile - await supabasePost<{ id: number }>(supabaseUrl, serviceKey, "profiles", { - user_id: authUserId, - email, - name: `Mesh Service Account (team ${teamId})`, - }); + // 2. Create profile (skip if a DB trigger already created one) + const autoProfile = await supabaseGet<{ user_id: string }>( + supabaseUrl, + serviceKey, + `profiles?user_id=eq.${encodeURIComponent(authUserId)}&select=user_id&limit=1`, + ); + if (!autoProfile[0]) { + await supabasePost<{ id: number }>(supabaseUrl, serviceKey, "profiles", { + user_id: authUserId, + email, + name: `Mesh Service Account (team ${teamId})`, + }); + } // 3. Create team membership (admin: true) const member = await supabasePost<{ id: number }>( From 09a732b1859706115d9279b5563c25c116b87f72 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 17:13:39 +0000 Subject: [PATCH 04/37] [release]: bump to 2.274.2 --- apps/mesh/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/package.json b/apps/mesh/package.json index f5a89c7c7a..bd8c8e7183 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -1,6 +1,6 @@ { "name": "decocms", - "version": "2.274.1", + "version": "2.274.2", "description": "Deco CMS — Self-hostable MCP Gateway for managing AI connections and tools", "author": "Deco team", "repository": { From 0a61c37de899560a4dc6c2430b6a806aae1ca092 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Fri, 24 Apr 2026 16:22:14 -0300 Subject: [PATCH 05/37] feat(analytics): integrate PostHog for server-side and client-side tracking (#3162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(analytics): integrate PostHog for server-side and client-side event tracking Adds PostHog Node.js SDK (server) and posthog-js (client) with a no-op fallback when POSTHOG_KEY is unset, so self-hosted deployments are unaffected. Instruments key lifecycle events: org creation/join, user auth, connection/API key/automation CRUD, thread creation, topup URL, and AI streaming sessions. Co-Authored-By: Claude Sonnet 4.6 * feat(analytics): expand PostHog event coverage and fix gaps Structured event taxonomy for chat, tools, credits, and settings. Chat hierarchy (renamed for consistency): - chat_started, chat_opened, chat_message_sent/started/completed/failed/ stopped/aborted — per-thread and per-completion granularity - chat_archived, chat_unarchived, chat_deleted — thread lifecycle - chat_picker_opened/closed/item_selected — @/slash picker with abandonment detection (outcome + duration) - chat_model_changed, chat_credential_changed - chat_voice_started (with outcome: started | unsupported | permission_denied) Tool calls: - tool_called fires for both MCP passthrough and built-in tools with tool_source discriminator, annotations (readOnly/destructive/ idempotent/openWorld), latency, and error status Credits & revenue: - credits_topup_clicked (intent), credits_topup_requested (server), credits_topped_up_detected (heuristic via balance delta), credits_exhausted_shown, credits_empty_state_shown/dismissed Organization/team: - organization_created now also fires from Better Auth default-org auto-creation hook (was only domain-setup); closes undercounting gap - organization_member_role_updated, organization_member_removed - ai_provider_key_created, ai_provider_key_deleted - chat_message_aborted for server-side abort visibility Navigation & UI: - nav_item_clicked, settings_nav_clicked, agent_toolbar_toggled - sidebar_agent_pin_clicked, agent_browser_opened, agent_create_new_clicked, agent_import_clicked, agent_template_clicked - mcp_app_opened (real MCP app renderer), vm_preview_loaded Privacy & session replay: - Session recording enabled at PostHog project level (10% sample, 10s min duration) - ph-no-capture class applied to AI provider API keys and connection secrets so they are fully blocked from replays - Frontend exception capture enabled ($exception events) Team analytics: - \$groupidentify fires on organization creation - All server events include groups: { organization: org_id } for team-level filtering and breakdowns Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track home page events — tiles, tools popover, connections dialog, recruit modals Wires structured PostHog events for the home-page surface identified in the page-by-page audit: - Home agent tiles (template/existing/recent), Create agent, See all - Chat mode toggles from tools popover + pill-dismiss (plan/gen-image/web-search) - Image/search model selection from tools popover - Prompt insertion from tools popover - Connect-tools banner + dialog-opened (with source across all callers) - Connection add flows (use_existing / clone / connect_new) + OAuth boundaries - Recruit-modal confirmed/failed (site-diagnostics / ai-image / ai-research) - Deco.cx site import started/succeeded/failed * feat(analytics): track agent instructions/connect page events Adds structured PostHog events for the agent detail page (instructions / connections / layout) and the Connect share modal: - agent_subtab_changed — instructions/connections/layout switches - agent_instructions_template_inserted, agent_instructions_improve_clicked - agent_updated — on successful form save, lists dirty field roots and instructions length when dirty - agent_test_clicked, agent_delete_requested, agent_deleted - agent_connect_modal_opened + agent_connect_action (copy_url / install_cursor / install_claude_code / typegen_copy_command / typegen_copy_env) - agent_typegen_key_generated / _failed - agent_connection_removed, agent_connection_settings_opened, agent_connection_instance_switched, agent_connection_new_instance_requested - connection_oauth_succeeded / _failed on agent reauthenticate flow - main_panel_tab_clicked — top Instructions/Connections/Automations/ Layout/pinned-view tabs (with tab_kind + was_active) * feat(analytics): track tasks panel + chat message actions Tasks panel (left column): - tasks_panel_member_filter_changed — all/mine toggle - tasks_panel_filter_changed — all/manual/automation toggle - tasks_panel_new_clicked — pencil icon to create a new task - tasks_panel_task_clicked — row select (dedupes no-op re-clicks) - tasks_panel_task_archived — frontend intent (server-side chat_archived still fires through COLLECTION_THREADS_UPDATE) Chat message actions: - chat_message_copied — assistant message copy-to-clipboard, includes message_id + char count Chat input + model selector events on this surface were already wired in the home-page pass; nothing new to add there. * feat(analytics): track settings pages — general, connections, agents, automations, store, brand, AI providers, monitor, members/roles, SSO, profile Wires PostHog events for every settings screen: General: - organization_settings_updated (dirty fields) - organization_domain_claimed / _cleared - organization_auto_join_toggled Connections list: - connections_page_tab_changed, connections_custom_dialog_opened, connection_custom_created, connection_add_clicked (source=connections_page), connections_community_warning_confirmed, connection_oauth_succeeded/_failed (flow=connections_page_connect), connections_bulk_delete / _status_toggled / _add_to_agent Agents list: - agents_list_template_clicked, agent_create_clicked (source=agents_list/agents_list_empty, method), agent_deleted (source=agents_list) Automations: - automations_list_row_clicked, automations_empty_state_browse_agents_clicked - automation_improve_clicked, automation_updated, automation_test_clicked, automation_trigger_added (cron / event), automation_new_clicked Store: - store_private_registry_added / _removed - store_registry_toggled Brand Context: - brand_created, brand_extract_started / _succeeded - brand_updated, brand_archived / _restored, brand_set_as_default AI Providers: - ai_provider_connect_clicked (method) - ai_provider_oauth_succeeded / _failed - ai_provider_cli_activated / _activate_failed - ai_provider_provision_succeeded / _failed Monitor: - monitoring_tab_changed, monitoring_time_range_changed, monitoring_live_toggled Members: - member_invited, member_removed, member_role_updated, invitation_role_updated - role_created, role_updated, role_deleted, role_members_updated SSO: - sso_configured / _config_updated / _config_removed - sso_enforcement_toggled Profile & Preferences: - profile_updated - preferences_theme_changed, preferences_notifications_toggled / _permission_denied, preferences_sounds_toggled / _previewed, preferences_tool_approval_changed, preferences_experimental_vibecode_toggled * feat(analytics): patch recruit modal + oauth timeout + extract-failed gaps - agent_recruit_confirmed / _failed now also fire from lean-canvas-recruit-modal.tsx and studio-pack-recruit-modal.tsx - ai_provider_oauth_failed fires on the 2-minute OAuth timeout path (was previously silent) - brand_extract_failed fires on BRAND_CONTEXT_EXTRACT error - agent_deleted from virtual-mcp/index.tsx now passes source: "agent_detail" for consistency with agents_list * refactor(analytics): drop credits_topup_requested + session-based agent/automation_updated Removals: - credits_topup_requested: removed from AI_PROVIDER_TOPUP_URL tool handler. It was a near-duplicate of the frontend credits_topup_clicked in the standard UI flow, and neither is an authoritative payment event. Keep credits_topup_clicked as the intent signal. Session-based tracking for agent_updated and automation_updated: - Auto-saves still persist every ~1s (product behavior unchanged). - PostHog now emits one event per edit SESSION, not per save. - A session ends after 30s of quiet OR an explicit flush (sub-tab change / test / improve / delete). - New props on both events: save_count — how many auto-saves occurred during the session edit_duration_ms — Date.now() delta from first save in session 'fields' is now the union of all dirty fields during the session. - Cuts event volume ~10-15x for a typical instructions edit. * docs(analytics): PostHog events catalog, review, and dashboards proposal Temporary reference docs for the PostHog instrumentation review. Three files at repo root so they're easy to share and easy to delete later: - posthog-events-catalog.md — every tracked event with exact trigger + props + misleading- interpretation guards - posthog-events-review.md — T1/T2/T3 triage, trigger-correctness pass, fixed/open gaps - posthog-events-dashboards.md — 14 dashboard proposals + 17 correlation questions + "Do-NOT labels" guardrails These are NOT the Astro docs site — delete them once the dashboards are built and the catalog lives in a better home. * feat(analytics): track signed_out event from both sign-out call sites Fires before authClient.signOut() so the event still carries the user's distinct_id; PostHog reset() then clears identity for the next session. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track chat_tools_popover_opened on Tools button click Discovery signal — the inner items already track their own actions (chat_mode_changed, chat_prompt_inserted, chat_image_model_selected, chat_search_model_selected) but opening the popover itself was untracked, so we couldn't measure the open→action funnel. Fires only on the open transition, not on close. Carries chat_mode for segmenting by current mode. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): add app_name to connection_created/deleted events Lets you break down connection adoption and churn by provider (Linear, Slack, HubSpot, etc.) directly in PostHog without joining against the connections table. Nullable — STDIO/HTTP connections without a registry app will report null. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track agent_connection_attached at 5 attach points Authoritative agent-scoped attach signal — fires whenever a connection becomes attached to an agent regardless of whether the connection was brand-new, cloned, or reused. Closes the gap where the existing connection_created (server) only fired for new rows. Modes: existing | clone | new | custom. Carries agent_id, connection_id, app_name (nullable). Threaded via a new agentId prop on AddConnectionDialog (add mode only — browse mode keeps it optional). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(analytics): split ai_provider_oauth_timeout from oauth_failed; suppress race Two bugs at the same site: 1. Race: if the popup posts back and exchangeOAuth (the async token swap) takes longer than the remaining 2-min timeout window, the timeout would fire ai_provider_oauth_failed{error:"timeout"} alongside the eventual ai_provider_oauth_succeeded. User saw an error toast and a failed event even though the connection worked. 2. Semantics: a 2-min "user never came back from popup" timeout is user abandonment, not an OAuth-protocol failure. Mixing both into oauth_failed inflates the failure rate and obscures real exchange failures. Fix: - Local exchangeStarted flag in the effect — set when the popup posts back, checked by the timeout. Once exchange begins, its own onError handler is the authoritative failure signal. - New event ai_provider_oauth_timeout for the popup-abandonment case. - ai_provider_oauth_failed now only fires for actual exchange failures. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): report React error boundary catches to PostHog PostHog's capture_exceptions: true only sees what bubbles to window.onerror / unhandledrejection. React error boundaries catch render- and commit-phase errors BEFORE they reach the window, so anything that hits a boundary (the "removeChild" class, render crashes, etc.) was previously invisible to PostHog. - Add captureException wrapper to posthog-client (try/catch so an analytics failure never blocks the fallback UI). - Wire both ErrorBoundary and ChunkErrorBoundary componentDidCatch to call it with route + componentStack + boundary tag. The boundary prop ("default" / "chunk_root") lets you split React-boundary catches from autocapture in PostHog dashboards. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(analytics): remove planning docs from branch Moved to local Downloads folder; these were working notes (events catalog, dashboards proposal, review) that don't belong in the shipped PR. The event changes themselves are in the preceding commits; nothing in the code or dashboards references these files. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track member_invite_failed on invite mutation error The success path fired member_invited; the error path only showed a toast, so invite failures were invisible in PostHog. Now captures count, role, and error message on failure. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track failure counterparts for silent onError paths Several mutations fired success events but swallowed errors with a toast-only onError, making failures invisible in PostHog. Added matching _failed events mirroring the success event's props + an error field. Covers 8 gaps: - member_remove_failed - member_role_update_failed - invitation_role_update_failed - role_create_failed / role_update_failed / role_members_update_failed - role_delete_failed - organization_settings_update_failed - organization_domain_claim_failed - organization_domain_clear_failed - organization_auto_join_toggle_failed (Already-good paths like deco_site_import_failed, ai_provider_*_failed, brand_extract_failed, agent_recruit_failed are unchanged.) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track user_signup_failed + user_signin_failed The auth form's emailPasswordMutation had no tracking at all — neither success nor failure. The server-side user_signed_up fires only AFTER a DB row is created, so pre-insert failures (network, validation, email-already-exists, weak password) were completely invisible in PostHog. Since the same mutation handles both signup and signin, the onError branches on isSignUp to fire the right event: - user_signup_failed - user_signin_failed Success path intentionally left untracked: the authoritative signal is the server-side user_signed_up (signup) or presence of the session cookie on subsequent requests (signin). No client-side duplicate. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(analytics): track password-reset and email-OTP auth flows Fills the tracking gap on the remaining 3 auth mutations in unified-auth-form.tsx. New events: - password_reset_requested + password_reset_request_failed - email_otp_sent + email_otp_send_failed - email_otp_verify_failed Success for sendOtp / password-reset is tracked because those are intermediate states (user stays on the form waiting for email). Success for verifyOtp is NOT tracked — it redirects on success, matching the signin pattern where the session cookie is authoritative. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(analytics): remove unused setOrganizationGroup export Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- apps/mesh/package.json | 2 + apps/mesh/src/api/routes/auth.ts | 47 ++++++++ .../routes/decopilot/built-in-tools/index.ts | 84 ++++++++++++- apps/mesh/src/api/routes/decopilot/helpers.ts | 30 ++++- apps/mesh/src/api/routes/decopilot/memory.ts | 25 ++++ apps/mesh/src/api/routes/decopilot/routes.ts | 15 +++ .../src/api/routes/decopilot/stream-core.ts | 109 +++++++++++++++++ .../routes/registry/public-publish-request.ts | 14 +++ apps/mesh/src/auth/index.ts | 43 ++++++- apps/mesh/src/mcp-apps/mcp-app-renderer.tsx | 17 +++ apps/mesh/src/posthog.ts | 49 ++++++++ .../mesh/src/tools/ai-providers/key-create.ts | 13 ++ .../mesh/src/tools/ai-providers/key-delete.ts | 11 ++ apps/mesh/src/tools/apiKeys/create.ts | 20 +++- apps/mesh/src/tools/apiKeys/delete.ts | 13 ++ apps/mesh/src/tools/automations/create.ts | 15 +++ apps/mesh/src/tools/automations/delete.ts | 21 +++- apps/mesh/src/tools/automations/run.ts | 20 ++++ apps/mesh/src/tools/connection/create.ts | 14 +++ apps/mesh/src/tools/connection/delete.ts | 23 +++- .../mesh/src/tools/organization/member-add.ts | 17 ++- .../src/tools/organization/member-remove.ts | 16 ++- .../tools/organization/member-update-role.ts | 20 +++- apps/mesh/src/tools/thread/create.ts | 13 ++ apps/mesh/src/tools/thread/delete.ts | 22 +++- apps/mesh/src/tools/thread/update.ts | 18 ++- .../src/web/components/account-popover.tsx | 6 +- .../src/web/components/chat/chat-context.tsx | 15 +++ .../components/chat/credits-empty-state.tsx | 31 ++++- .../chat/credits-exhausted-banner.tsx | 28 ++++- apps/mesh/src/web/components/chat/input.tsx | 62 +++++++++- .../chat/message/parts/text-part.tsx | 5 + .../src/web/components/chat/select-model.tsx | 12 +- .../web/components/chat/tiptap/mention-at.tsx | 31 ++++- .../components/chat/tiptap/mention-slash.tsx | 30 +++++ .../src/web/components/chat/tools-popover.tsx | 54 ++++++++- .../connections/create-connection-dialog.tsx | 1 + .../src/web/components/error-boundary.tsx | 21 +++- .../src/web/components/home/agents-list.tsx | 48 +++++++- .../home/ai-image-recruit-modal.tsx | 9 ++ .../home/ai-research-recruit-modal.tsx | 9 ++ .../home/lean-canvas-recruit-modal.tsx | 9 ++ .../home/site-diagnostics-recruit-modal.tsx | 9 ++ .../home/studio-pack-recruit-modal.tsx | 11 ++ .../components/import-from-deco-dialog.tsx | 10 ++ .../web/components/invite-member-dialog.tsx | 14 ++- .../web/components/manage-roles-dialog.tsx | 37 +++++- .../components/settings/domain-settings.tsx | 27 +++++ .../components/settings/organization-form.tsx | 10 ++ .../web/components/sidebar/agents-section.tsx | 31 ++++- .../web/components/sidebar/footer/inbox.tsx | 9 +- .../src/web/components/sidebar/navigation.tsx | 7 ++ .../src/web/components/unified-auth-form.tsx | 23 ++++ .../src/web/components/vm/preview/preview.tsx | 9 ++ apps/mesh/src/web/hooks/use-deco-credits.ts | 30 +++++ apps/mesh/src/web/index.tsx | 3 + .../agent-shell-layout/toggle-buttons.tsx | 17 ++- .../main-panel-tabs/main-panel-tabs-bar.tsx | 9 ++ apps/mesh/src/web/layouts/settings-layout.tsx | 13 +- .../web/layouts/tasks-panel/tasks-section.tsx | 43 ++++++- apps/mesh/src/web/lib/posthog-client.ts | 87 ++++++++++++++ .../src/web/providers/posthog-provider.tsx | 42 +++++++ apps/mesh/src/web/providers/providers.tsx | 5 +- apps/mesh/src/web/routes/agents-list.tsx | 51 +++++++- apps/mesh/src/web/routes/orgs/connections.tsx | 42 ++++++- apps/mesh/src/web/routes/orgs/members.tsx | 23 +++- .../src/web/routes/orgs/monitoring/index.tsx | 24 +++- .../web/routes/orgs/settings/automations.tsx | 7 ++ .../views/automations/automation-detail.tsx | 71 +++++++++++ .../views/automations/automations-list.tsx | 5 + .../web/views/settings/org-ai-providers.tsx | 74 ++++++++++-- .../web/views/settings/org-brand-context.tsx | 22 +++- apps/mesh/src/web/views/settings/org-sso.tsx | 10 ++ .../mesh/src/web/views/settings/org-store.tsx | 9 ++ .../views/settings/profile-preferences.tsx | 49 +++++--- .../virtual-mcp/add-connection-dialog.tsx | 111 +++++++++++++++++- apps/mesh/src/web/views/virtual-mcp/index.tsx | 83 ++++++++++++- .../virtual-mcp/virtual-mcp-share-modal.tsx | 46 +++++++- bun.lock | 60 +++++++++- 79 files changed, 2085 insertions(+), 110 deletions(-) create mode 100644 apps/mesh/src/posthog.ts create mode 100644 apps/mesh/src/web/lib/posthog-client.ts create mode 100644 apps/mesh/src/web/providers/posthog-provider.tsx diff --git a/apps/mesh/package.json b/apps/mesh/package.json index bd8c8e7183..5037acac72 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -66,6 +66,8 @@ "ink": "^6.8.0", "kysely": "^0.28.12", "nats": "^2.29.3", + "posthog-js": "^1.371.1", + "posthog-node": "^5.0.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/mesh/src/api/routes/auth.ts b/apps/mesh/src/api/routes/auth.ts index 03b3a157ef..77b687f46c 100644 --- a/apps/mesh/src/api/routes/auth.ts +++ b/apps/mesh/src/api/routes/auth.ts @@ -7,6 +7,7 @@ import { Hono } from "hono"; import { getConnInfo } from "hono/bun"; +import { posthog } from "../../posthog"; import { getSettings } from "../../settings"; import { auth, @@ -335,8 +336,20 @@ app.post("/domain-join", async (c) => { } } + posthog.capture({ + distinctId: session.user.id, + event: "organization_domain_joined", + groups: { organization: org.id }, + properties: { + organization_id: org.id, + organization_slug: org.slug, + email_domain: emailDomain, + }, + }); + return c.json({ success: true, slug: org.slug }); } catch (error) { + posthog.captureException(error, session.user.id); console.error("[Auth] Domain join failed:", error); return c.json( { success: false, error: "Failed to join organization" }, @@ -524,12 +537,46 @@ app.post("/domain-setup", async (c) => { console.error("[Auth] Brand extraction failed (non-fatal):", brandError); } + posthog.identify({ + distinctId: session.user.id, + properties: { + email: session.user.email, + $set: { email: session.user.email }, + $set_once: { first_organization_created_at: new Date().toISOString() }, + }, + }); + + posthog.groupIdentify({ + groupType: "organization", + groupKey: orgId, + properties: { + name: orgResult.slug ?? baseSlug, + slug: orgResult.slug ?? baseSlug, + email_domain: emailDomain, + brand_extracted: brandExtracted, + created_at: new Date().toISOString(), + }, + }); + + posthog.capture({ + distinctId: session.user.id, + event: "organization_created", + groups: { organization: orgId }, + properties: { + organization_id: orgId, + organization_slug: orgResult.slug ?? baseSlug, + email_domain: emailDomain, + brand_extracted: brandExtracted, + }, + }); + return c.json({ success: true, slug: orgResult.slug ?? baseSlug, brandExtracted, }); } catch (error) { + posthog.captureException(error, session.user?.id); console.error("[Auth] Domain setup failed:", error); return c.json( { success: false, error: "Failed to set up organization" }, diff --git a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts index 2be7606f49..460e50edb9 100644 --- a/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts +++ b/apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts @@ -6,8 +6,29 @@ */ import type { MeshContext, OrganizationScope } from "@/core/mesh-context"; +import { posthog } from "@/posthog"; import type { UIMessageStreamWriter } from "ai"; import { toolNeedsApproval, type ToolApprovalLevel } from "../helpers"; + +// Known destructive/read-only classifications for built-in tools. Mirrors +// the MCP annotations used by passthrough tools so dashboards can filter +// uniformly across both sources. +const BUILTIN_TOOL_ANNOTATIONS: Record< + string, + { readOnly?: boolean; destructive?: boolean } +> = { + agent_search: { readOnly: true, destructive: false }, + read_tool_output: { readOnly: true, destructive: false }, + read_resource: { readOnly: true, destructive: false }, + read_prompt: { readOnly: true, destructive: false }, + web_search: { readOnly: true, destructive: false }, + generate_image: { readOnly: false, destructive: false }, + open_in_agent: { readOnly: false, destructive: false }, + subtask: { readOnly: false, destructive: false }, + user_ask: { readOnly: true, destructive: false }, + propose_plan: { readOnly: true, destructive: false }, + enable_tools: { readOnly: true, destructive: false }, +}; import { createAgentSearchTool } from "./agent-search"; import { createReadToolOutputTool } from "./read-tool-output"; import { createReadPromptTool } from "./prompts"; @@ -166,6 +187,66 @@ async function buildAllTools( }; } +/** + * Wrap each tool's execute() with a posthog tool_called capture so built-in + * tool usage shows up in the same analytics pipeline as passthrough MCP + * tools. Preserves the original tool shape so AI SDK can't tell the wrapper + * is there. + */ +function instrumentBuiltIns>( + tools: T, + params: BuiltinToolParams, + ctx: MeshContext, +): T { + const orgId = params.organization.id; + const userId = ctx.auth?.user?.id; + const result: Record = {}; + for (const [name, tool] of Object.entries(tools)) { + const t = tool as { execute?: Function; [k: string]: unknown }; + const originalExecute = t.execute; + if (typeof originalExecute !== "function") { + result[name] = tool; + continue; + } + const hints = BUILTIN_TOOL_ANNOTATIONS[name]; + result[name] = { + ...t, + execute: async (input: unknown, options: unknown) => { + const startTime = performance.now(); + let isError = false; + try { + return await originalExecute.call(t, input, options); + } catch (err) { + isError = true; + throw err; + } finally { + const latencyMs = performance.now() - startTime; + if (orgId && userId) { + posthog.capture({ + distinctId: userId, + event: "tool_called", + groups: { organization: orgId }, + properties: { + organization_id: orgId, + tool_source: "builtin", + tool_name: name, + tool_safe_name: name, + read_only: hints?.readOnly ?? null, + destructive: hints?.destructive ?? null, + idempotent: null, + open_world: null, + latency_ms: Math.round(latencyMs), + is_error: isError, + }, + }); + } + } + }, + }; + } + return result as T; +} + /** * Get built-in tools as a ToolSet. * propose_plan is only included when chat mode is `plan`. @@ -175,7 +256,8 @@ export async function getBuiltInTools( params: BuiltinToolParams, ctx: MeshContext, ) { - const tools = await buildAllTools(writer, params, ctx); + const raw = await buildAllTools(writer, params, ctx); + const tools = instrumentBuiltIns(raw, params, ctx) as typeof raw; if (!params.isPlanMode) { const { propose_plan: _, ...rest } = tools; diff --git a/apps/mesh/src/api/routes/decopilot/helpers.ts b/apps/mesh/src/api/routes/decopilot/helpers.ts index f6aaa9f6a2..500b367d06 100644 --- a/apps/mesh/src/api/routes/decopilot/helpers.ts +++ b/apps/mesh/src/api/routes/decopilot/helpers.ts @@ -18,6 +18,7 @@ import { import type { Context } from "hono"; import type { MeshContext, OrganizationScope } from "@/core/mesh-context"; +import { posthog } from "@/posthog"; import { HTTPException } from "hono/http-exception"; import { MCP_TOOL_CALL_TIMEOUT_MS } from "@/core/constants"; import { resolveArgsStorageRefs } from "./file-materializer"; @@ -167,6 +168,7 @@ export async function toolsFromMCP( }) !== false, execute: async (input, callOptions) => { const startTime = performance.now(); + let isError = false; try { // Resolve any mesh-storage: URIs in tool arguments to fresh // presigned URLs before forwarding to the MCP client. @@ -187,10 +189,14 @@ export async function toolsFromMCP( timeout: MCP_TOOL_CALL_TIMEOUT_MS, }, ); + isError = Boolean((result as { isError?: boolean })?.isError); return result as unknown as CallToolResult; + } catch (err) { + isError = true; + throw err; } finally { + const latencyMs = performance.now() - startTime; if (writer) { - const latencyMs = performance.now() - startTime; writer.write({ type: "data-tool-metadata", id: callOptions.toolCallId, @@ -201,6 +207,28 @@ export async function toolsFromMCP( }, }); } + // Product analytics: fire-and-forget. Posthog-node batches. + const orgId = meshCtx?.organization?.id; + const userId = meshCtx?.auth?.user?.id; + if (orgId && userId) { + posthog.capture({ + distinctId: userId, + event: "tool_called", + groups: { organization: orgId }, + properties: { + organization_id: orgId, + tool_source: "mcp", + tool_name: t.name, + tool_safe_name: safeName, + read_only: annotations?.readOnlyHint ?? null, + destructive: annotations?.destructiveHint ?? null, + idempotent: annotations?.idempotentHint ?? null, + open_world: annotations?.openWorldHint ?? null, + latency_ms: Math.round(latencyMs), + is_error: isError, + }, + }); + } } }, toModelOutput: async ({ output, toolCallId }) => { diff --git a/apps/mesh/src/api/routes/decopilot/memory.ts b/apps/mesh/src/api/routes/decopilot/memory.ts index e45f207c48..e33cad226a 100644 --- a/apps/mesh/src/api/routes/decopilot/memory.ts +++ b/apps/mesh/src/api/routes/decopilot/memory.ts @@ -7,6 +7,7 @@ import type { OrgScopedThreadStorage } from "@/storage/threads"; import type { Thread, ThreadMessage } from "@/storage/types"; +import { posthog } from "@/posthog"; import { generatePrefixedId } from "@/shared/utils/generate-id"; /** @@ -116,6 +117,18 @@ export async function createMemory( virtual_mcp_id: virtualMcpId ?? "", branch: branch ?? null, }); + posthog.capture({ + distinctId: userId, + event: "chat_started", + groups: { organization: organization_id }, + properties: { + organization_id, + thread_id: thread.id, + created_via: triggerId ? "automation" : "stream_auto", + trigger_id: triggerId ?? null, + virtual_mcp_id: virtualMcpId || null, + }, + }); } else { // Try to get existing thread scoped to this org const existing = await storage.get(thread_id); @@ -134,6 +147,18 @@ export async function createMemory( virtual_mcp_id: virtualMcpId ?? "", branch: branch ?? null, }); + posthog.capture({ + distinctId: userId, + event: "chat_started", + groups: { organization: organization_id }, + properties: { + organization_id, + thread_id: thread.id, + created_via: triggerId ? "automation" : "stream_client_id", + trigger_id: triggerId ?? null, + virtual_mcp_id: virtualMcpId || null, + }, + }); } } diff --git a/apps/mesh/src/api/routes/decopilot/routes.ts b/apps/mesh/src/api/routes/decopilot/routes.ts index 938ce16c7f..eb617e7370 100644 --- a/apps/mesh/src/api/routes/decopilot/routes.ts +++ b/apps/mesh/src/api/routes/decopilot/routes.ts @@ -6,6 +6,7 @@ */ import type { MeshContext } from "@/core/mesh-context"; +import { posthog } from "@/posthog"; import { consumeStream, createUIMessageStream, @@ -209,6 +210,19 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { { runRegistry, streamBuffer, cancelBroadcast }, ); + posthog.capture({ + distinctId: userId, + event: "chat_message_started", + groups: { organization: organization.id }, + properties: { + organization_id: organization.id, + agent_id: agent, + mode, + thread_id: resolvedThreadId, + credential_id: models.credentialId, + }, + }); + return createUIMessageStreamResponse({ stream: result.stream, consumeSseStream: consumeStream, @@ -225,6 +239,7 @@ export function createDecopilotRoutes(deps: DecopilotDeps) { return c.json({ error: "Request aborted" }, 400); } + posthog.captureException(err); console.error("[decopilot:stream] Failed", { error: err instanceof Error ? err.message : JSON.stringify(err), stack: err instanceof Error ? err.stack : undefined, diff --git a/apps/mesh/src/api/routes/decopilot/stream-core.ts b/apps/mesh/src/api/routes/decopilot/stream-core.ts index b1e3f8a50f..3a50b7d19f 100644 --- a/apps/mesh/src/api/routes/decopilot/stream-core.ts +++ b/apps/mesh/src/api/routes/decopilot/stream-core.ts @@ -7,6 +7,7 @@ */ import type { MeshContext } from "@/core/mesh-context"; +import { posthog } from "@/posthog"; import { createVirtualClientFrom } from "@/mcp-clients/virtual-mcp"; import { monitorLlmCall } from "@/monitoring/emit-llm-call"; import { recordLlmCallMetrics } from "@/monitoring/record-llm-call-metrics"; @@ -67,6 +68,44 @@ import { getInternalUrl } from "@/core/server-constants"; import { traced, tracer } from "@/observability"; import { getPodId } from "@/core/pod-identity"; +/** + * Classify a stream error into a small, stable taxonomy for analytics. + * Consumers (dashboards) can rely on these values being consistent across + * providers — the raw error message stays in the separate `error_message` + * prop for debugging. + */ +function classifyStreamError( + error: unknown, +): + | "aborted" + | "insufficient_funds" + | "rate_limit" + | "timeout" + | "auth" + | "model_error" + | "tool_error" + | "unknown" { + if (error instanceof Error && error.name === "AbortError") return "aborted"; + const msg = ( + error instanceof Error ? error.message : String(error) + ).toLowerCase(); + if ( + /insufficient|no credits|out of credits|balance|payment|quota exceeded|402/i.test( + msg, + ) + ) { + return "insufficient_funds"; + } + if (/rate.?limit|too many requests|429/i.test(msg)) return "rate_limit"; + if (/timeout|timed out|deadline/i.test(msg)) return "timeout"; + if (/unauthor|forbidden|401|403|invalid.*(key|token)/i.test(msg)) + return "auth"; + if (/tool|mcp|connection/i.test(msg)) return "tool_error"; + if (/model|provider|anthropic|openai|gemini|claude/i.test(msg)) + return "model_error"; + return "unknown"; +} + /** * Creates a language model from the provider, enabling reasoning when the * model advertises the "reasoning" capability (e.g. OpenRouter thinking models). @@ -384,6 +423,12 @@ async function streamCoreInner( const toolOutputMap = new Map(); const organization = ctx.organization!; + const streamStartAt = Date.now(); + let aggregatedUsage: { + inputTokens: number; + outputTokens: number; + totalTokens: number; + } = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; let titleHandle: ReturnType | null = null; const uiStream = createUIMessageStream({ @@ -803,6 +848,14 @@ async function streamCoreInner( inputTokens: totalUsage.inputTokens, outputTokens: totalUsage.outputTokens, }); + aggregatedUsage = { + inputTokens: + aggregatedUsage.inputTokens + (totalUsage.inputTokens ?? 0), + outputTokens: + aggregatedUsage.outputTokens + (totalUsage.outputTokens ?? 0), + totalTokens: + aggregatedUsage.totalTokens + (totalUsage.totalTokens ?? 0), + }; monitorLlmCall({ ctx, organizationId: input.organizationId, @@ -1020,6 +1073,27 @@ async function streamCoreInner( taskId: mem.thread.id, threadStatus, }); + + posthog.capture({ + distinctId: input.userId, + event: "chat_message_completed", + groups: { organization: input.organizationId }, + properties: { + organization_id: input.organizationId, + thread_id: mem.thread.id, + agent_id: input.agent.id, + model_id: input.models.thinking.id, + model_title: input.models.thinking.title, + mode: input.mode, + duration_ms: Date.now() - streamStartAt, + finish_reason: finishReason, + thread_status: threadStatus, + input_tokens: aggregatedUsage.inputTokens, + output_tokens: aggregatedUsage.outputTokens, + total_tokens: aggregatedUsage.totalTokens, + is_resume: input.isResume ?? false, + }, + }); }, onStepFinish: ({ responseMessage }) => { const transitions = runRegistry.dispatch({ @@ -1049,10 +1123,45 @@ async function streamCoreInner( closeClients?.(); titleHandle?.finish(); if (registrySignal.aborted) { + // User cancelled (frontend stop button), tab closed mid-stream, or + // run was force-failed. Frontend chat_message_stopped covers the + // first case; this server event also covers the other two. + posthog.capture({ + distinctId: input.userId, + event: "chat_message_aborted", + groups: { organization: input.organizationId }, + properties: { + organization_id: input.organizationId, + thread_id: mem.thread.id, + agent_id: input.agent.id, + model_id: input.models.thinking.id, + mode: input.mode, + duration_ms: Date.now() - streamStartAt, + is_resume: input.isResume ?? false, + }, + }); return sanitizeStreamError(error); } console.error("[decopilot] stream error:", error); + posthog.capture({ + distinctId: input.userId, + event: "chat_message_failed", + groups: { organization: input.organizationId }, + properties: { + organization_id: input.organizationId, + thread_id: mem.thread.id, + agent_id: input.agent.id, + model_id: input.models.thinking.id, + mode: input.mode, + duration_ms: Date.now() - streamStartAt, + error_category: classifyStreamError(error), + error_message: + error instanceof Error ? error.message : String(error), + is_resume: input.isResume ?? false, + }, + }); + runRegistry .execute({ type: "FINISH", diff --git a/apps/mesh/src/api/routes/registry/public-publish-request.ts b/apps/mesh/src/api/routes/registry/public-publish-request.ts index 163c6c63b0..2df96f9cb8 100644 --- a/apps/mesh/src/api/routes/registry/public-publish-request.ts +++ b/apps/mesh/src/api/routes/registry/public-publish-request.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { posthog } from "@/posthog"; import type { ServerPluginContext } from "@decocms/bindings/server-plugin"; import { WellKnownOrgMCPId } from "@decocms/mesh-sdk"; import type { Kysely } from "kysely"; @@ -390,6 +391,19 @@ export function publicPublishRequestRoutes( ); } + posthog.capture({ + distinctId: parsed.data.requester?.email ?? `org:${organizationId}`, + event: "registry_publish_request_submitted", + groups: { organization: organizationId }, + properties: { + organization_id: organizationId, + request_id: created.id, + requested_id: created.requested_id, + title: parsed.data.data.title, + requester_email: parsed.data.requester?.email ?? null, + }, + }); + return c.json( { id: created.id, diff --git a/apps/mesh/src/auth/index.ts b/apps/mesh/src/auth/index.ts index ee2bb0e352..7edb4f8a55 100644 --- a/apps/mesh/src/auth/index.ts +++ b/apps/mesh/src/auth/index.ts @@ -30,6 +30,7 @@ import { } from "better-auth/plugins/organization/access"; import { getConfig } from "@/core/config"; +import { posthog } from "@/posthog"; import { getBaseUrl } from "@/core/server-constants"; import { createAccessControl, Role } from "@decocms/better-auth/plugins/access"; import { getDb, getDatabaseUrl, getDbDialect } from "../database"; @@ -433,6 +434,20 @@ export const auth = betterAuth({ user: { create: { after: async (user) => { + // Top-of-funnel signup event. Fires once per new user account, + // before any org is created. Use this (not organization_created) + // to measure raw signup volume. + posthog.capture({ + distinctId: user.id, + event: "user_signed_up", + properties: { + email: user.email, + email_domain: user.email?.split("@")[1]?.toLowerCase() ?? null, + email_verified: !!user.emailVerified, + has_name: !!user.name, + }, + }); + // Domain-based handling for verified corporate emails. // 1. If an org claimed the domain with auto-join → add as member // 2. If corporate but unclaimed → skip default org creation so @@ -482,13 +497,39 @@ export const auth = betterAuth({ const orgSlug = slugify(orgName); try { - await auth.api.createOrganization({ + const created = await auth.api.createOrganization({ body: { name: orgName, slug: orgSlug, userId: user.id, }, }); + + // Group identify for team-level analytics. + const orgId = + (created as { id?: string } | null)?.id ?? undefined; + if (orgId) { + posthog.groupIdentify({ + groupType: "organization", + groupKey: orgId, + properties: { + name: orgName, + slug: orgSlug, + created_at: new Date().toISOString(), + created_via: "signup_default", + }, + }); + posthog.capture({ + distinctId: user.id, + event: "organization_created", + groups: { organization: orgId }, + properties: { + organization_id: orgId, + organization_slug: orgSlug, + created_via: "signup_default", + }, + }); + } return; } catch (error) { const isConflictError = diff --git a/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx b/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx index 4dedacf0a0..de6a1a8757 100644 --- a/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx +++ b/apps/mesh/src/mcp-apps/mcp-app-renderer.tsx @@ -11,6 +11,11 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { injectCSP } from "./csp-injector.ts"; import type { McpUiResourceCsp } from "./types.ts"; import { useAppBridge } from "./use-app-bridge.ts"; +import { track } from "../web/lib/posthog-client"; + +// Module-level dedup so a given MCP app resource fires `mcp_app_opened` at +// most once per page session. Different resources still fire their own events. +const openedMcpApps = new Set(); // --------------------------------------------------------------------------- // useResourceHtml @@ -77,6 +82,18 @@ export function MCPAppRenderer({ const { data } = useMCPReadResource({ client, uri, staleTime: 30_000 }); const html = useResourceHtml(data); + // Fire mcp_app_opened once per (page session × resource URI) — render-time + // dedupe via module Set. Display mode distinguishes inline (shown inside + // chat messages) from fullscreen (opened as a view panel). + if (uri && !openedMcpApps.has(uri)) { + openedMcpApps.add(uri); + track("mcp_app_opened", { + resource_uri: uri, + display_mode: displayMode, + tool_name: toolInfo?.tool?.name ?? null, + }); + } + const { height, isLoading, error, iframeRef } = useAppBridge({ client, displayMode, diff --git a/apps/mesh/src/posthog.ts b/apps/mesh/src/posthog.ts new file mode 100644 index 0000000000..9976909bbd --- /dev/null +++ b/apps/mesh/src/posthog.ts @@ -0,0 +1,49 @@ +/** + * PostHog analytics client (server-side singleton). + * + * Enabled only when POSTHOG_KEY is set. On self-hosted / open-source + * deployments without the env var, all methods are no-ops so the rest of + * the app can call `posthog.capture(...)` unconditionally. + * + * Host defaults to PostHog US cloud and can be overridden with POSTHOG_HOST + * (e.g. https://eu.i.posthog.com for EU region or a self-hosted instance). + */ + +import { PostHog } from "posthog-node"; + +const apiKey = process.env.POSTHOG_KEY; +const host = process.env.POSTHOG_HOST; + +type PostHogLike = Pick< + PostHog, + "capture" | "identify" | "captureException" | "groupIdentify" | "shutdown" +>; + +function createNoopClient(): PostHogLike { + return { + capture: () => {}, + identify: () => {}, + captureException: () => {}, + groupIdentify: () => {}, + shutdown: async () => {}, + } as unknown as PostHogLike; +} + +export const posthog: PostHogLike = apiKey + ? new PostHog(apiKey, { + ...(host ? { host } : {}), + enableExceptionAutocapture: true, + // Flush every event immediately. Short-lived request contexts + // otherwise drop batched events before shutdown runs. + flushAt: 1, + flushInterval: 0, + }) + : createNoopClient(); + +if (apiKey) { + const shutdown = () => { + posthog.shutdown().catch(() => {}); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} diff --git a/apps/mesh/src/tools/ai-providers/key-create.ts b/apps/mesh/src/tools/ai-providers/key-create.ts index 18705f6f5f..b01297e111 100644 --- a/apps/mesh/src/tools/ai-providers/key-create.ts +++ b/apps/mesh/src/tools/ai-providers/key-create.ts @@ -1,4 +1,5 @@ import z from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { requireAuth, requireOrganization } from "../../core/mesh-context"; import { PROVIDER_IDS } from "../../ai-providers/provider-ids"; @@ -33,6 +34,18 @@ export const AI_PROVIDER_KEY_CREATE = defineTool({ createdBy: ctx.auth.user!.id, }); + posthog.capture({ + distinctId: ctx.auth.user!.id, + event: "ai_provider_key_created", + groups: { organization: org.id }, + properties: { + organization_id: org.id, + provider_id: key.providerId, + key_id: key.id, + label: key.label, + }, + }); + return { id: key.id, providerId: key.providerId, diff --git a/apps/mesh/src/tools/ai-providers/key-delete.ts b/apps/mesh/src/tools/ai-providers/key-delete.ts index 2b49e009df..71e710e209 100644 --- a/apps/mesh/src/tools/ai-providers/key-delete.ts +++ b/apps/mesh/src/tools/ai-providers/key-delete.ts @@ -1,4 +1,5 @@ import z from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { requireAuth, requireOrganization } from "../../core/mesh-context"; @@ -18,6 +19,16 @@ export const AI_PROVIDER_KEY_DELETE = defineTool({ await ctx.storage.aiProviderKeys.delete(input.keyId, org.id); + posthog.capture({ + distinctId: ctx.auth.user!.id, + event: "ai_provider_key_deleted", + groups: { organization: org.id }, + properties: { + organization_id: org.id, + key_id: input.keyId, + }, + }); + return { success: true }; }, }); diff --git a/apps/mesh/src/tools/apiKeys/create.ts b/apps/mesh/src/tools/apiKeys/create.ts index 3f397a0b07..4b492edde1 100644 --- a/apps/mesh/src/tools/apiKeys/create.ts +++ b/apps/mesh/src/tools/apiKeys/create.ts @@ -5,8 +5,9 @@ * IMPORTANT: The key value is only returned here and cannot be retrieved later. */ +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; -import { requireAuth } from "../../core/mesh-context"; +import { getUserId, requireAuth } from "../../core/mesh-context"; import { ApiKeyCreateInputSchema, ApiKeyCreateOutputSchema } from "./schema"; export const API_KEY_CREATE = defineTool({ @@ -55,6 +56,23 @@ export const API_KEY_CREATE = defineTool({ ? result.createdAt.toISOString() : result.createdAt; + const userId = getUserId(ctx); + if (userId) { + posthog.capture({ + distinctId: userId, + event: "api_key_created", + ...(ctx.organization?.id + ? { groups: { organization: ctx.organization.id } } + : {}), + properties: { + key_id: result.id, + key_name: result.name ?? input.name, + organization_id: ctx.organization?.id, + has_expiry: !!expiresAt, + }, + }); + } + return { id: result.id, name: result.name ?? input.name, // Fallback to input name if null diff --git a/apps/mesh/src/tools/apiKeys/delete.ts b/apps/mesh/src/tools/apiKeys/delete.ts index f561a4893d..5119013460 100644 --- a/apps/mesh/src/tools/apiKeys/delete.ts +++ b/apps/mesh/src/tools/apiKeys/delete.ts @@ -5,6 +5,7 @@ * Only allows deleting keys that belong to the current organization. */ +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { getUserId, requireAuth } from "../../core/mesh-context"; import { ApiKeyDeleteInputSchema, ApiKeyDeleteOutputSchema } from "./schema"; @@ -68,6 +69,18 @@ export const API_KEY_DELETE = defineTool({ // Delete the API key via Better Auth await ctx.boundAuth.apiKey.delete(input.keyId); + if (currentOrgId) { + posthog.capture({ + distinctId: userId, + event: "api_key_deleted", + groups: { organization: currentOrgId }, + properties: { + key_id: input.keyId, + organization_id: currentOrgId, + }, + }); + } + return { success: true, keyId: input.keyId, diff --git a/apps/mesh/src/tools/automations/create.ts b/apps/mesh/src/tools/automations/create.ts index 035fb83af0..25cbdc7c81 100644 --- a/apps/mesh/src/tools/automations/create.ts +++ b/apps/mesh/src/tools/automations/create.ts @@ -5,6 +5,7 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { getUserId, @@ -133,6 +134,20 @@ export const AUTOMATION_CREATE = defineTool({ virtual_mcp_id: input.virtual_mcp_id ?? null, }); + posthog.capture({ + distinctId: userId, + event: "automation_created", + groups: { organization: organization.id }, + properties: { + organization_id: organization.id, + automation_id: automation.id, + agent_id: input.agent.id, + has_virtual_mcp: !!input.virtual_mcp_id, + active: automation.active, + model_id: models.thinking.id, + }, + }); + return { id: automation.id, name: automation.name, diff --git a/apps/mesh/src/tools/automations/delete.ts b/apps/mesh/src/tools/automations/delete.ts index 8c2800db2c..06663e3079 100644 --- a/apps/mesh/src/tools/automations/delete.ts +++ b/apps/mesh/src/tools/automations/delete.ts @@ -6,8 +6,13 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; -import { requireAuth, requireOrganization } from "../../core/mesh-context"; +import { + getUserId, + requireAuth, + requireOrganization, +} from "../../core/mesh-context"; import { configureTriggerOnMcp } from "./configure-trigger"; export const AUTOMATION_DELETE = defineTool({ @@ -62,6 +67,20 @@ export const AUTOMATION_DELETE = defineTool({ organization.id, ); + const userId = getUserId(ctx); + if (userId) { + posthog.capture({ + distinctId: userId, + event: "automation_deleted", + groups: { organization: organization.id }, + properties: { + organization_id: organization.id, + automation_id: input.id, + trigger_count: triggers.length, + }, + }); + } + return { success }; }, }); diff --git a/apps/mesh/src/tools/automations/run.ts b/apps/mesh/src/tools/automations/run.ts index eb204aec2d..567e35f900 100644 --- a/apps/mesh/src/tools/automations/run.ts +++ b/apps/mesh/src/tools/automations/run.ts @@ -5,6 +5,7 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { requireAuth, @@ -47,6 +48,25 @@ export const AUTOMATION_RUN = defineTool({ const result = await ctx.automationRunner(input.id, org.id, userId); + posthog.capture({ + distinctId: userId, + event: "automation_run", + groups: { organization: org.id }, + properties: { + organization_id: org.id, + automation_id: input.id, + thread_id: "taskId" in result ? result.taskId : undefined, + status: + "skipped" in result + ? "skipped" + : "error" in result + ? "error" + : "started", + skip_reason: "skipped" in result ? result.skipped : undefined, + error_message: "error" in result ? result.error : undefined, + }, + }); + if ("skipped" in result) { return { skipped: result.skipped }; } diff --git a/apps/mesh/src/tools/connection/create.ts b/apps/mesh/src/tools/connection/create.ts index 5faada31b4..e180b9228f 100644 --- a/apps/mesh/src/tools/connection/create.ts +++ b/apps/mesh/src/tools/connection/create.ts @@ -7,6 +7,7 @@ import { WellKnownOrgMCPId } from "@decocms/mesh-sdk"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { getUserId, @@ -134,6 +135,19 @@ export const COLLECTION_CONNECTIONS_CREATE = defineTool({ }, ); + posthog.capture({ + distinctId: userId, + event: "connection_created", + groups: { organization: organization.id }, + properties: { + connection_id: connection.id, + connection_type: connection.connection_type, + app_name: connection.app_name ?? null, + organization_id: organization.id, + tools_count: tools?.length ?? 0, + }, + }); + return { item: connection, }; diff --git a/apps/mesh/src/tools/connection/delete.ts b/apps/mesh/src/tools/connection/delete.ts index 1eaeddf3ef..6e0a311fd8 100644 --- a/apps/mesh/src/tools/connection/delete.ts +++ b/apps/mesh/src/tools/connection/delete.ts @@ -9,8 +9,13 @@ import { createCollectionDeleteOutputSchema, } from "@decocms/bindings/collections"; import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; -import { requireAuth, requireOrganization } from "../../core/mesh-context"; +import { + getUserId, + requireAuth, + requireOrganization, +} from "../../core/mesh-context"; import { getMcpListCache } from "../../mcp-clients/mcp-list-cache"; import { ConnectionEntitySchema } from "./schema"; @@ -108,6 +113,22 @@ export const COLLECTION_CONNECTIONS_DELETE = defineTool({ ?.invalidate(input.id) .catch(() => {}); + const userId = getUserId(ctx); + if (userId) { + posthog.capture({ + distinctId: userId, + event: "connection_deleted", + groups: { organization: organization.id }, + properties: { + connection_id: connection.id, + connection_type: connection.connection_type, + app_name: connection.app_name ?? null, + organization_id: organization.id, + forced: input.force ?? false, + }, + }); + } + return { item: connection, }; diff --git a/apps/mesh/src/tools/organization/member-add.ts b/apps/mesh/src/tools/organization/member-add.ts index c49e4b053d..fe2c9302a0 100644 --- a/apps/mesh/src/tools/organization/member-add.ts +++ b/apps/mesh/src/tools/organization/member-add.ts @@ -5,8 +5,9 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; -import { requireAuth } from "../../core/mesh-context"; +import { getUserId, requireAuth } from "../../core/mesh-context"; export const ORGANIZATION_MEMBER_ADD = defineTool({ name: "ORGANIZATION_MEMBER_ADD", @@ -66,6 +67,20 @@ export const ORGANIZATION_MEMBER_ADD = defineTool({ throw new Error("Failed to add member"); } + const actorId = getUserId(ctx); + if (actorId) { + posthog.capture({ + distinctId: actorId, + event: "organization_member_added", + groups: { organization: organizationId }, + properties: { + organization_id: organizationId, + added_user_id: input.userId, + role: input.role, + }, + }); + } + // Better Auth returns role as string, but we accept string or array // Convert dates to ISO strings for JSON Schema compatibility return { diff --git a/apps/mesh/src/tools/organization/member-remove.ts b/apps/mesh/src/tools/organization/member-remove.ts index 66c9dbfd7f..9fa3e7aa4c 100644 --- a/apps/mesh/src/tools/organization/member-remove.ts +++ b/apps/mesh/src/tools/organization/member-remove.ts @@ -5,8 +5,9 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; -import { requireAuth } from "../../core/mesh-context"; +import { getUserId, requireAuth } from "../../core/mesh-context"; export const ORGANIZATION_MEMBER_REMOVE = defineTool({ name: "ORGANIZATION_MEMBER_REMOVE", @@ -54,6 +55,19 @@ export const ORGANIZATION_MEMBER_REMOVE = defineTool({ // invalidateOrg would be too broad; the TTL will handle cleanup // for removed members since the DB row is gone. + const actorId = getUserId(ctx); + if (actorId) { + posthog.capture({ + distinctId: actorId, + event: "organization_member_removed", + groups: { organization: organizationId }, + properties: { + organization_id: organizationId, + member_id_or_email: input.memberIdOrEmail, + }, + }); + } + return { success: true, memberIdOrEmail: input.memberIdOrEmail, diff --git a/apps/mesh/src/tools/organization/member-update-role.ts b/apps/mesh/src/tools/organization/member-update-role.ts index d668a71193..6597e994ba 100644 --- a/apps/mesh/src/tools/organization/member-update-role.ts +++ b/apps/mesh/src/tools/organization/member-update-role.ts @@ -5,8 +5,9 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; -import { requireAuth } from "../../core/mesh-context"; +import { getUserId, requireAuth } from "../../core/mesh-context"; export const ORGANIZATION_MEMBER_UPDATE_ROLE = defineTool({ name: "ORGANIZATION_MEMBER_UPDATE_ROLE", @@ -71,6 +72,23 @@ export const ORGANIZATION_MEMBER_UPDATE_ROLE = defineTool({ // Invalidate cached role ctx.invalidateMemberRole?.(result.userId, organizationId); + const actorId = getUserId(ctx); + if (actorId) { + posthog.capture({ + distinctId: actorId, + event: "organization_member_role_updated", + groups: { organization: organizationId }, + properties: { + organization_id: organizationId, + member_id: input.memberId, + target_user_id: result.userId, + new_role: Array.isArray(input.role) + ? input.role.join(",") + : input.role, + }, + }); + } + // Convert dates to ISO strings for JSON Schema compatibility return { ...result, diff --git a/apps/mesh/src/tools/thread/create.ts b/apps/mesh/src/tools/thread/create.ts index 3fc0fa575e..fb24cf736b 100644 --- a/apps/mesh/src/tools/thread/create.ts +++ b/apps/mesh/src/tools/thread/create.ts @@ -5,6 +5,7 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { getUserId, @@ -67,6 +68,18 @@ export const COLLECTION_THREADS_CREATE = defineTool({ created_by: userId, }); + posthog.capture({ + distinctId: userId, + event: "chat_started", + groups: { organization: organization.id }, + properties: { + organization_id: organization.id, + thread_id: taskId, + has_title: !!input.data.title, + created_via: "tool", + }, + }); + return { item: { ...result, diff --git a/apps/mesh/src/tools/thread/delete.ts b/apps/mesh/src/tools/thread/delete.ts index d34b529a72..6b98dc1763 100644 --- a/apps/mesh/src/tools/thread/delete.ts +++ b/apps/mesh/src/tools/thread/delete.ts @@ -8,8 +8,13 @@ import { CollectionDeleteInputSchema, createCollectionDeleteOutputSchema, } from "@decocms/bindings/collections"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; -import { requireAuth, requireOrganization } from "../../core/mesh-context"; +import { + getUserId, + requireAuth, + requireOrganization, +} from "../../core/mesh-context"; import { normalizeThreadForResponse } from "./helpers"; import { ThreadEntitySchema } from "./schema"; @@ -28,7 +33,7 @@ export const COLLECTION_THREADS_DELETE = defineTool({ handler: async (input, ctx) => { requireAuth(ctx); - requireOrganization(ctx); + const organization = requireOrganization(ctx); await ctx.access.check(); @@ -39,6 +44,19 @@ export const COLLECTION_THREADS_DELETE = defineTool({ await ctx.storage.threads.delete(input.id); + const userId = getUserId(ctx); + if (userId) { + posthog.capture({ + distinctId: userId, + event: "chat_deleted", + groups: { organization: organization.id }, + properties: { + organization_id: organization.id, + thread_id: input.id, + }, + }); + } + return { item: normalizeThreadForResponse(thread), }; diff --git a/apps/mesh/src/tools/thread/update.ts b/apps/mesh/src/tools/thread/update.ts index 932ec09b66..ba0a433d9a 100644 --- a/apps/mesh/src/tools/thread/update.ts +++ b/apps/mesh/src/tools/thread/update.ts @@ -5,6 +5,7 @@ */ import { z } from "zod"; +import { posthog } from "../../posthog"; import { defineTool } from "../../core/define-tool"; import { getUserId, @@ -44,7 +45,7 @@ export const COLLECTION_THREADS_UPDATE = defineTool({ handler: async (input, ctx) => { requireAuth(ctx); - requireOrganization(ctx); + const organization = requireOrganization(ctx); await ctx.access.check(); @@ -81,6 +82,21 @@ export const COLLECTION_THREADS_UPDATE = defineTool({ const thread = await ctx.storage.threads.update(id, updateData); + // Fire chat_archived / chat_unarchived when the hidden flag flips. Only + // fires on the specific transition, not on title/description edits that + // happen to include `hidden` unchanged. + if (data.hidden !== undefined && data.hidden !== existing.hidden) { + posthog.capture({ + distinctId: userId, + event: data.hidden ? "chat_archived" : "chat_unarchived", + groups: { organization: organization.id }, + properties: { + organization_id: organization.id, + thread_id: id, + }, + }); + } + return { item: normalizeThreadForResponse(thread), }; diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index 16fb17c3a1..59f8e61a2b 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -38,6 +38,7 @@ import { import { GitHubIcon } from "@daveyplate/better-auth-ui"; import { SidebarMenuButton } from "@deco/ui/components/sidebar.tsx"; import { authClient } from "@/web/lib/auth-client"; +import { track } from "@/web/lib/posthog-client"; import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; import { usePreferences, type ThemeMode } from "@/web/hooks/use-preferences.ts"; import { toast } from "@deco/ui/components/sonner.js"; @@ -564,7 +565,10 @@ export function AccountPopover() { key: "logout", label: "Sign out", icon: , - onClick: () => authClient.signOut(), + onClick: () => { + track("signed_out", { source: "account_popover" }); + authClient.signOut(); + }, }; const themeOptions: { diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index 91046d601c..29527f8dcc 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -48,6 +48,12 @@ import { import { useInvalidateCollectionsOnToolCall } from "../../hooks/use-invalidate-collections-on-tool-call"; import { useTaskReadState } from "../../hooks/use-task-read-state"; import { authClient } from "../../lib/auth-client"; +import { track } from "../../lib/posthog-client"; + +// Module-level set so a given chat fires `chat_opened` at most once per page +// session per thread_id. Prevents duplicates from re-renders while still +// re-firing when the user switches tasks. +const openedChats = new Set(); import { toMetadataModelInfo } from "../../lib/metadata-model-info"; import { useChatNavigation } from "./hooks/use-chat-navigation"; @@ -677,6 +683,15 @@ export function ActiveTaskProvider({ clearPendingMessage, currentBranch, } = useChatTask(); + + // Fire chat_opened once per (page session × taskId). Runs during render, but + // the Set gate keeps it idempotent. Fires for every thread a user views — + // new or existing — giving us a "chat session view" signal distinct from + // chat_started (thread creation). + if (taskId && !openedChats.has(taskId)) { + openedChats.add(taskId); + track("chat_opened", { thread_id: taskId }); + } const { selectedModel, imageModel, diff --git a/apps/mesh/src/web/components/chat/credits-empty-state.tsx b/apps/mesh/src/web/components/chat/credits-empty-state.tsx index 426ed8f4c0..cf0b9f0672 100644 --- a/apps/mesh/src/web/components/chat/credits-empty-state.tsx +++ b/apps/mesh/src/web/components/chat/credits-empty-state.tsx @@ -7,7 +7,8 @@ * so it doesn't reappear. The normal home page renders underneath. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { track } from "@/web/lib/posthog-client"; import { Coins04, ArrowRight } from "@untitledui/icons"; import { Button } from "@deco/ui/components/button.tsx"; import { Input } from "@deco/ui/components/input.tsx"; @@ -75,6 +76,7 @@ export function CreditsEmptyState() { const currencySymbol = currency === "brl" ? "R$" : "$"; const dismiss = () => { + track("credits_empty_state_dismissed", { organization_id: org.id }); setOpen(false); try { localStorage.setItem(dismissKeyForOrg(org.id), "1"); @@ -83,6 +85,13 @@ export function CreditsEmptyState() { } }; + // oxlint-disable-next-line ban-use-effect/ban-use-effect + useEffect(() => { + if (open) { + track("credits_empty_state_shown", { organization_id: org.id }); + } + }, [open, org.id]); + const { mutate: topUp, isPending } = useMutation({ mutationFn: async (amountCents: number) => { const result = (await client.callTool({ @@ -175,7 +184,15 @@ export function CreditsEmptyState() { key={dollars} type="button" disabled={isPending} - onClick={() => topUp(dollars * 100)} + onClick={() => { + track("credits_topup_clicked", { + amount_cents: dollars * 100, + currency, + tier_label: label, + source: "empty_state", + }); + topUp(dollars * 100); + }} className={cn( "relative flex flex-col items-center gap-1 py-6 rounded-xl border transition-all duration-150 cursor-pointer", "disabled:opacity-50 disabled:cursor-wait", @@ -212,7 +229,15 @@ export function CreditsEmptyState() { diff --git a/apps/mesh/src/web/components/chat/credits-exhausted-banner.tsx b/apps/mesh/src/web/components/chat/credits-exhausted-banner.tsx index 6220e18621..73c6cba0b8 100644 --- a/apps/mesh/src/web/components/chat/credits-exhausted-banner.tsx +++ b/apps/mesh/src/web/components/chat/credits-exhausted-banner.tsx @@ -6,7 +6,8 @@ * or navigate to settings for full provider management. */ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { track } from "@/web/lib/posthog-client"; import { Check } from "@untitledui/icons"; import { Button } from "@deco/ui/components/button.tsx"; import { @@ -78,6 +79,11 @@ export function CreditsExhaustedBanner({ const [currency, setCurrency] = useState<"usd" | "brl">("usd"); const currencySymbol = currency === "brl" ? "R$" : "$"; + // oxlint-disable-next-line ban-use-effect/ban-use-effect + useEffect(() => { + track("credits_exhausted_shown", { organization_id: org.id }); + }, [org.id]); + const { mutate: topUp, isPending } = useMutation({ mutationFn: async (amountCents: number) => { const result = (await client.callTool({ @@ -181,7 +187,15 @@ export function CreditsExhaustedBanner({ key={dollars} type="button" disabled={isPending} - onClick={() => topUp(dollars * 100)} + onClick={() => { + track("credits_topup_clicked", { + amount_cents: dollars * 100, + currency, + tier_label: label, + source: "exhausted_banner", + }); + topUp(dollars * 100); + }} className={cn( "relative flex flex-col items-center gap-1 py-5 rounded-xl border transition-all duration-150 cursor-pointer", "disabled:opacity-50 disabled:cursor-wait", @@ -220,7 +234,15 @@ export function CreditsExhaustedBanner({ diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 921d9a3828..a0f9cadea4 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -47,6 +47,7 @@ import { isTiptapDocEmpty } from "./tiptap/utils"; import { ToolsPopover } from "./tools-popover"; import { SessionStats } from "./usage-stats"; import { authClient } from "@/web/lib/auth-client.ts"; +import { track } from "@/web/lib/posthog-client"; import { useSound } from "@/web/hooks/use-sound.ts"; import { question004Sound } from "@deco/ui/lib/question-004.ts"; import { AddConnectionDialog } from "@/web/views/virtual-mcp/add-connection-dialog"; @@ -272,15 +273,30 @@ export function ChatInput({ const handleVoiceStart = async () => { voiceBaselineDocRef.current = tiptapDoc; await voice.startRecording(); + // Fire with the real outcome — voice.status is set inside startRecording + // before the promise resolves ("recording" on success, "unsupported" or + // "permission-denied" on failure). Button click on its own doesn't tell + // us if the mic actually started. + const outcome = + voice.status === "recording" + ? "started" + : voice.status === "unsupported" + ? "unsupported" + : voice.status === "permission-denied" + ? "permission_denied" + : "unknown"; + track("chat_voice_started", { thread_id: taskId, outcome }); }; const handleVoiceConfirm = () => { + track("chat_voice_confirmed", { thread_id: taskId }); const finalText = voice.stopRecording(); tiptapRef.current?.syncVoiceText(voiceBaselineDocRef.current, finalText); tiptapRef.current?.focus(); }; const handleVoiceCancel = () => { + track("chat_voice_cancelled", { thread_id: taskId }); voice.cancelRecording(); tiptapRef.current?.restoreContent(voiceBaselineDocRef.current); }; @@ -368,10 +384,20 @@ export function ChatInput({ const handleSubmit = (e?: FormEvent) => { e?.preventDefault(); if (isStreaming) { + track("chat_message_stopped", { thread_id: taskId }); stop(); } else if (isRunInProgress) { + track("chat_message_stopped", { thread_id: taskId }); stop(); } else if (canSubmit && tiptapDoc) { + track("chat_message_sent", { + thread_id: taskId, + mode: chatMode, + model_id: selectedModel?.modelId ?? null, + model_provider: selectedModel?.providerId ?? null, + virtual_mcp_id: selectedVirtualMcp?.id ?? null, + submission: e ? "button_or_enter" : "programmatic", + }); playClickSound(); void sendMessage(tiptapDoc); setTiptapDoc(undefined); @@ -470,7 +496,13 @@ export function ChatInput({ /> setConnectionsOpen(true)} + onOpenConnections={() => { + track("connections_dialog_opened", { + source: "tools_popover", + mode: "add", + }); + setConnectionsOpen(true); + }} virtualMcpId={selectedVirtualMcp?.id ?? decopilotId} /> {isPlanMode && ( @@ -478,6 +510,11 @@ export function ChatInput({ type="button" onClick={() => { playSwitchSound(); + track("chat_mode_changed", { + from_mode: "plan", + to_mode: "default", + source: "pill_dismiss", + }); setChatMode("default"); }} className="flex items-center gap-1.5 h-8 rounded-lg px-2.5 text-sm font-medium text-violet-600 dark:text-violet-400 hover:bg-violet-500/10 group whitespace-nowrap animate-in fade-in duration-200" @@ -495,6 +532,11 @@ export function ChatInput({ type="button" onClick={() => { playSwitchSound(); + track("chat_mode_changed", { + from_mode: "gen-image", + to_mode: "default", + source: "pill_dismiss", + }); setChatMode("default"); }} className="flex items-center gap-1.5 h-8 rounded-lg px-2.5 text-sm font-medium text-pink-600 dark:text-pink-400 hover:bg-pink-500/10 group whitespace-nowrap animate-in fade-in duration-200" @@ -521,6 +563,11 @@ export function ChatInput({ type="button" onClick={() => { playSwitchSound(); + track("chat_mode_changed", { + from_mode: "web-search", + to_mode: "default", + source: "pill_dismiss", + }); setChatMode("default"); }} className="flex items-center gap-1.5 h-8 rounded-lg px-2.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 group whitespace-nowrap animate-in fade-in duration-200" @@ -636,7 +683,18 @@ export function ChatInput({ {/* Connections Banner Footer - always visible on home */} {showConnectionsBanner && ( - setConnectionsOpen(true)} /> + { + track("connections_banner_clicked", { + source: "home_chat_input", + }); + track("connections_dialog_opened", { + source: "home_banner", + mode: "add", + }); + setConnectionsOpen(true); + }} + /> )} diff --git a/apps/mesh/src/web/components/chat/message/parts/text-part.tsx b/apps/mesh/src/web/components/chat/message/parts/text-part.tsx index d62e89ccc3..317fb5b3cc 100644 --- a/apps/mesh/src/web/components/chat/message/parts/text-part.tsx +++ b/apps/mesh/src/web/components/chat/message/parts/text-part.tsx @@ -4,6 +4,7 @@ import { cn } from "@deco/ui/lib/utils.ts"; import { MemoizedMarkdown } from "../../markdown.tsx"; import { Check, Copy01 } from "@untitledui/icons"; import type { TextUIPart } from "ai"; +import { track } from "@/web/lib/posthog-client"; interface MessageTextPartProps { id: string; @@ -25,6 +26,10 @@ export function MessageTextPart({ const [isCopied, setIsCopied] = useState(false); const handleCopyMessage = async () => { + track("chat_message_copied", { + message_id: id, + chars: part.text.length, + }); await handleCopy(part.text); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); diff --git a/apps/mesh/src/web/components/chat/select-model.tsx b/apps/mesh/src/web/components/chat/select-model.tsx index f40f5a2ce3..fc83f4ec61 100644 --- a/apps/mesh/src/web/components/chat/select-model.tsx +++ b/apps/mesh/src/web/components/chat/select-model.tsx @@ -54,6 +54,7 @@ import { useAiProviders, } from "../../hooks/collections/use-ai-providers"; import { ErrorBoundary } from "../error-boundary"; +import { track } from "@/web/lib/posthog-client"; import { useChatPrefs } from "./context"; import { getProviderLogo } from "@/web/utils/ai-providers-logos"; import { useNavigate } from "@tanstack/react-router"; @@ -1218,10 +1219,19 @@ function ModelSelectorContent({ onClose }: { onClose: () => void }) { { + track("chat_credential_changed", { credential_id: id }); + setCredentialId(id); + }} selectedModel={selectedModel} onModelChange={(model) => { if (!credentialId) return; + track("chat_model_changed", { + from_model_id: selectedModel?.modelId ?? null, + to_model_id: model.modelId, + to_model_provider: model.providerId ?? null, + credential_id: credentialId, + }); setModel({ ...model, keyId: credentialId }); }} /> diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx index 6deac69ea8..d5b5db4bd8 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/mention-at.tsx @@ -18,6 +18,7 @@ import type { Editor } from "@tiptap/react"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { BaseItem, insertMention, OnSelectProps, Suggestion } from "./mention"; +import { track } from "@/web/lib/posthog-client"; interface AtMentionProps { editor: Editor; @@ -66,6 +67,10 @@ export const AtMention = ({ editor, virtualMcpId }: AtMentionProps) => { const modeRef = useRef(mode); modeRef.current = mode; + // Track picker open → close outcome so we can measure abandonment. + const pickerOpenedAtRef = useRef(null); + const pickerHadSelectionRef = useRef(false); + // Reset mode when menu closes/opens (query key changes signal re-render) const queryKey = ["at-mention", org.id, virtualMcpId ?? "default", mode]; @@ -73,6 +78,16 @@ export const AtMention = ({ editor, virtualMcpId }: AtMentionProps) => { item, range, }: OnSelectProps): Promise => { + track("chat_picker_item_selected", { + picker: "@", + item_kind: item.kind, + item_name: item.name, + }); + // Category clicks drill deeper — don't mark as final selection yet. + if (item.kind !== "category") { + pickerHadSelectionRef.current = true; + } + if (item.kind === "category") { // Drill into category — keep menu open setMode(item.name === "agents" ? "agents" : "resources"); @@ -233,7 +248,21 @@ export const AtMention = ({ editor, virtualMcpId }: AtMentionProps) => { }; const handleOpenChange = (open: boolean) => { - if (!open) { + if (open) { + // Fires when the @ picker dropdown actually renders (TipTap's onStart). + // NOT when a literal "@" is typed — e.g. inside an email address the + // picker won't open so the event won't fire. + pickerOpenedAtRef.current = Date.now(); + pickerHadSelectionRef.current = false; + track("chat_picker_opened", { picker: "@" }); + } else { + const openedAt = pickerOpenedAtRef.current; + track("chat_picker_closed", { + picker: "@", + outcome: pickerHadSelectionRef.current ? "selected" : "dismissed", + duration_ms: openedAt ? Date.now() - openedAt : null, + }); + pickerOpenedAtRef.current = null; setMode("categories"); } }; diff --git a/apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx b/apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx index ad54cf69d6..a33fbfda09 100644 --- a/apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx +++ b/apps/mesh/src/web/components/chat/tiptap/mention-slash.tsx @@ -32,6 +32,7 @@ import { type PromptArgumentValues, } from "../dialog-prompt-arguments.tsx"; import { BaseItem, insertMention, OnSelectProps, Suggestion } from "./mention"; +import { track } from "@/web/lib/posthog-client"; interface SlashMentionProps { editor: Editor; @@ -121,10 +122,21 @@ export const SlashMention = ({ editor, virtualMcpId }: SlashMentionProps) => { null, ); + // Track picker open → close outcome so we can measure abandonment. + const pickerOpenedAtRef = useRef(null); + const pickerHadSelectionRef = useRef(false); + const handleItemSelect = async ({ item, range, }: OnSelectProps) => { + track("chat_picker_item_selected", { + picker: "/", + item_kind: item.kind, + item_name: item.name, + }); + pickerHadSelectionRef.current = true; + if (!client) return; if (item.kind === "prompt") { @@ -230,6 +242,24 @@ export const SlashMention = ({ editor, virtualMcpId }: SlashMentionProps) => { queryKey={queryKey} queryFn={fetchItems} onSelect={handleItemSelect} + onOpenChange={(open) => { + // Fires when the / picker dropdown actually renders (TipTap's + // onStart). NOT when a literal "/" is typed — e.g. inside a URL + // the picker won't open so the event won't fire. + if (open) { + pickerOpenedAtRef.current = Date.now(); + pickerHadSelectionRef.current = false; + track("chat_picker_opened", { picker: "/" }); + } else { + const openedAt = pickerOpenedAtRef.current; + track("chat_picker_closed", { + picker: "/", + outcome: pickerHadSelectionRef.current ? "selected" : "dismissed", + duration_ms: openedAt ? Date.now() - openedAt : null, + }); + pickerOpenedAtRef.current = null; + } + }} /> { playSwitchSound(); - setChatMode(isPlanMode ? "default" : "plan"); + const nextMode = isPlanMode ? "default" : "plan"; + track("chat_mode_changed", { + from_mode: chatMode, + to_mode: nextMode, + source: "tools_popover", + }); + setChatMode(nextMode); setOpen(false); }; @@ -168,7 +175,12 @@ export function ToolsPopover({ const handlePromptSelect = async (prompt: Prompt) => { setOpen(false); - if (prompt.arguments && prompt.arguments.length > 0) { + const hasArgs = !!(prompt.arguments && prompt.arguments.length > 0); + track("chat_prompt_inserted", { + prompt_name: prompt.name, + with_arguments: hasArgs, + }); + if (hasArgs) { setActivePrompt(prompt); return; } @@ -183,25 +195,47 @@ export function ToolsPopover({ const handleImageModelSelect = (model: AiProviderModel) => { playSwitchSound(); + track("chat_image_model_selected", { + model_id: model.modelId, + model_title: model.title, + provider: model.providerId ?? null, + }); setImageModel(model); setOpen(false); }; const handleSearchModelSelect = (model: AiProviderModel) => { playSwitchSound(); + track("chat_search_model_selected", { + model_id: model.modelId, + model_title: model.title, + provider: model.providerId ?? null, + }); setDeepResearchModel(model); setOpen(false); }; const handleForceImageGeneration = () => { playSwitchSound(); - setChatMode(chatMode === "gen-image" ? "default" : "gen-image"); + const nextMode = chatMode === "gen-image" ? "default" : "gen-image"; + track("chat_mode_changed", { + from_mode: chatMode, + to_mode: nextMode, + source: "tools_popover", + }); + setChatMode(nextMode); setOpen(false); }; const handleForceWebSearch = () => { playSwitchSound(); - setChatMode(chatMode === "web-search" ? "default" : "web-search"); + const nextMode = chatMode === "web-search" ? "default" : "web-search"; + track("chat_mode_changed", { + from_mode: chatMode, + to_mode: nextMode, + source: "tools_popover", + }); + setChatMode(nextMode); setOpen(false); }; @@ -210,7 +244,17 @@ export function ToolsPopover({ return ( <> - + { + if (next && !open) { + track("chat_tools_popover_opened", { + chat_mode: chatMode, + }); + } + setOpen(next); + }} + >