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);
+ }}
+ >
diff --git a/apps/mesh/src/web/components/error-boundary.tsx b/apps/mesh/src/web/components/error-boundary.tsx
index ceb7d14414..2025248cdf 100644
--- a/apps/mesh/src/web/components/error-boundary.tsx
+++ b/apps/mesh/src/web/components/error-boundary.tsx
@@ -1,6 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button } from "@deco/ui/components/button.tsx";
import { AlertTriangle, RefreshCw01 } from "@untitledui/icons";
+import { captureException } from "@/web/lib/posthog-client";
const CHUNK_RELOAD_KEY = "__mesh_chunk_reload_ts";
@@ -56,6 +57,13 @@ export class ErrorBoundary extends Component {
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
+ captureException(error, {
+ boundary: "default",
+ error_name: error.name,
+ error_message: error.message,
+ component_stack: errorInfo.componentStack ?? null,
+ route: typeof window !== "undefined" ? window.location.pathname : null,
+ });
}
private resetError = () => {
@@ -119,8 +127,17 @@ export class ChunkErrorBoundary extends Component<
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
-
- if (!isChunkLoadError(error)) return;
+ const isChunk = isChunkLoadError(error);
+ captureException(error, {
+ boundary: "chunk_root",
+ is_chunk_load_error: isChunk,
+ error_name: error.name,
+ error_message: error.message,
+ component_stack: errorInfo.componentStack ?? null,
+ route: typeof window !== "undefined" ? window.location.pathname : null,
+ });
+
+ if (!isChunk) return;
// Auto-reload once. Guard against infinite loops with a timestamp check.
const lastReload = sessionStorage.getItem(CHUNK_RELOAD_KEY);
diff --git a/apps/mesh/src/web/components/home/agents-list.tsx b/apps/mesh/src/web/components/home/agents-list.tsx
index 3ce44c705a..dd4c3521fc 100644
--- a/apps/mesh/src/web/components/home/agents-list.tsx
+++ b/apps/mesh/src/web/components/home/agents-list.tsx
@@ -33,6 +33,10 @@ import { AiResearchRecruitModal } from "@/web/components/home/ai-research-recrui
import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp";
import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent";
import { Suspense, useState } from "react";
+import { track } from "@/web/lib/posthog-client";
+
+type TileKind = "template" | "existing" | "recent";
+type TileAction = "new_chat" | "open_modal" | "navigate";
/**
* Individual agent preview component
@@ -40,6 +44,7 @@ import { Suspense, useState } from "react";
function AgentPreview({
agent,
onSpecialClick,
+ tracking,
}: {
agent: {
id: string;
@@ -47,11 +52,23 @@ function AgentPreview({
icon?: string | null;
};
onSpecialClick?: () => void;
+ tracking: {
+ template_id: string | null;
+ tile_kind: TileKind;
+ action: TileAction;
+ };
}) {
const { org } = useProjectContext();
const navigate = useNavigate();
const handleClick = () => {
+ track("home_agent_tile_clicked", {
+ template_id: tracking.template_id,
+ agent_id: agent.id,
+ agent_title: agent.title,
+ tile_kind: tracking.tile_kind,
+ action: tracking.action,
+ });
if (onSpecialClick) {
onSpecialClick();
} else {
@@ -110,6 +127,7 @@ function SeeAllButton() {
)}
aria-label="See all agents"
onClick={() => {
+ track("home_see_all_agents_clicked");
navigate({ to: "/$org/settings/agents", params: { org: org.slug } });
}}
>
@@ -134,7 +152,10 @@ function CreateAgentButton() {
return (
createVirtualMCP()}
- onImportGitHub={() => setGithubPickerOpen(true)}
- onImportDeco={() => setImportDecoOpen(true)}
+ onCreateFromScratch={() => {
+ track("agent_create_clicked", {
+ source: "agents_list",
+ method: "scratch",
+ });
+ createVirtualMCP();
+ }}
+ onImportGitHub={() => {
+ track("agent_create_clicked", {
+ source: "agents_list",
+ method: "github",
+ });
+ setGithubPickerOpen(true);
+ }}
+ onImportDeco={() => {
+ track("agent_create_clicked", {
+ source: "agents_list",
+ method: "deco",
+ });
+ setImportDecoOpen(true);
+ }}
isCreating={isCreating}
align="end"
/>
@@ -185,9 +206,27 @@ export default function AgentsListPage() {
createVirtualMCP()}
- onImportGitHub={() => setGithubPickerOpen(true)}
- onImportDeco={() => setImportDecoOpen(true)}
+ onCreateFromScratch={() => {
+ track("agent_create_clicked", {
+ source: "agents_list_empty",
+ method: "scratch",
+ });
+ createVirtualMCP();
+ }}
+ onImportGitHub={() => {
+ track("agent_create_clicked", {
+ source: "agents_list_empty",
+ method: "github",
+ });
+ setGithubPickerOpen(true);
+ }}
+ onImportDeco={() => {
+ track("agent_create_clicked", {
+ source: "agents_list_empty",
+ method: "deco",
+ });
+ setImportDecoOpen(true);
+ }}
isCreating={isCreating}
align="center"
showBetaBadge
diff --git a/apps/mesh/src/web/routes/orgs/connections.tsx b/apps/mesh/src/web/routes/orgs/connections.tsx
index 7f02d46ab6..d6a275cc67 100644
--- a/apps/mesh/src/web/routes/orgs/connections.tsx
+++ b/apps/mesh/src/web/routes/orgs/connections.tsx
@@ -102,6 +102,7 @@ import {
} from "@untitledui/icons";
import { Suspense, useState } from "react";
import { useForm } from "react-hook-form";
+import { track } from "@/web/lib/posthog-client";
import {
connectionFormSchema,
type ConnectionFormData,
@@ -580,6 +581,9 @@ function CatalogItemCard({
};
const handleCommunityConfirm = () => {
+ track("connections_community_warning_confirmed", {
+ registry_item_id: item.id,
+ });
setCommunityWarningOpen(false);
if (pendingAction === "connect") {
onConnect(item);
@@ -763,6 +767,11 @@ function ConnectionResults({
const handleInlineConnect = async (item: RegistryItem) => {
if (!org || !session?.user?.id) return;
+ track("connection_add_clicked", {
+ action: "connect_new",
+ registry_item_id: item.id,
+ source: "connections_page",
+ });
setConnectingItemId(item.id);
try {
@@ -805,9 +814,18 @@ function ConnectionResults({
scope: "offline_access",
});
if (error || !token) {
+ track("connection_oauth_failed", {
+ connection_id: id,
+ flow: "connections_page_connect",
+ error: error ?? "no_token",
+ });
toast.error(`Authentication failed: ${error ?? "no token received"}`);
return;
} else {
+ track("connection_oauth_succeeded", {
+ connection_id: id,
+ flow: "connections_page_connect",
+ });
if (tokenInfo) {
try {
const response = await fetch(
@@ -886,6 +904,7 @@ function ConnectionResults({
const handleBulkDelete = async () => {
setBulkDeleteOpen(false);
const ids = [...selectedIds];
+ track("connections_bulk_delete", { count: ids.length });
let deleted = 0;
for (const id of ids) {
@@ -907,6 +926,10 @@ function ConnectionResults({
const handleBulkToggleStatus = async (status: "active" | "inactive") => {
const ids = [...selectedIds];
+ track("connections_bulk_status_toggled", {
+ count: ids.length,
+ to_status: status,
+ });
let updated = 0;
for (const id of ids) {
@@ -928,6 +951,10 @@ function ConnectionResults({
const handleAddToAgent = async (agentId: string) => {
const agent = agents.find((a) => a.id === agentId);
if (!agent || !selfClient) return;
+ track("connections_bulk_add_to_agent", {
+ agent_id: agentId,
+ count: selectedIds.size,
+ });
const existingConnIds = new Set(
agent.connections.map((c) => c.connection_id),
@@ -1291,6 +1318,9 @@ function OrgMcpsContent() {
const isCreating = search.action === "create";
const openCreateDialog = () => {
+ track("connections_custom_dialog_opened", {
+ source: "connections_page",
+ });
navigate({
to: "/$org/settings/connections",
params: { org: org.slug },
@@ -1341,6 +1371,10 @@ function OrgMcpsContent() {
}
const newId = generatePrefixedId("conn");
+ track("connection_custom_created", {
+ connection_type: connectionType,
+ ui_type: data.ui_type,
+ });
// Create new connection
await actions.create.mutateAsync({
id: newId,
@@ -1953,7 +1987,13 @@ function OrgMcpsContent() {
{ id: "connected", label: "Connected" },
]}
activeTab={activeTab}
- onTabChange={(id) => setActiveTab(id as ConnectionTab)}
+ onTabChange={(id) => {
+ const next = id as ConnectionTab;
+ if (next !== activeTab) {
+ track("connections_page_tab_changed", { to_tab: next });
+ }
+ setActiveTab(next);
+ }}
/>
{
+ track("member_removed");
queryClient.invalidateQueries({ queryKey: KEYS.members(locator) });
toast.success("Member has been removed from the organization");
setMemberToRemove(null);
},
onError: (error) => {
+ track("member_remove_failed", {
+ error: error instanceof Error ? error.message : String(error),
+ });
toast.error(
error instanceof Error ? error.message : "Failed to remove member",
);
@@ -549,11 +554,16 @@ function OrgMembersContent() {
throw new Error(result.error.message);
}
},
- onSuccess: () => {
+ onSuccess: (_res, vars) => {
+ track("member_role_updated", { new_role: vars.role });
queryClient.invalidateQueries({ queryKey: KEYS.members(locator) });
toast.success("Member's role has been updated");
},
- onError: (error) => {
+ onError: (error, vars) => {
+ track("member_role_update_failed", {
+ new_role: vars.role,
+ error: error instanceof Error ? error.message : String(error),
+ });
toast.error(
error instanceof Error ? error.message : "Failed to update role",
);
@@ -587,11 +597,16 @@ function OrgMembersContent() {
throw new Error(inviteResult.error.message);
}
},
- onSuccess: () => {
+ onSuccess: (_res, vars) => {
+ track("invitation_role_updated", { new_role: vars.role });
queryClient.invalidateQueries({ queryKey: KEYS.invitations(locator) });
toast.success("Invitation role has been updated");
},
- onError: (error) => {
+ onError: (error, vars) => {
+ track("invitation_role_update_failed", {
+ new_role: vars.role,
+ error: error instanceof Error ? error.message : String(error),
+ });
toast.error(
error instanceof Error
? error.message
diff --git a/apps/mesh/src/web/routes/orgs/monitoring/index.tsx b/apps/mesh/src/web/routes/orgs/monitoring/index.tsx
index dc71a91683..f999cbed05 100644
--- a/apps/mesh/src/web/routes/orgs/monitoring/index.tsx
+++ b/apps/mesh/src/web/routes/orgs/monitoring/index.tsx
@@ -71,6 +71,7 @@ import { OverviewTabContent, OverviewTabSkeleton } from "./overview.tsx";
import { AuditTabContent, MonitoringLogsTable } from "./audit.tsx";
import { ThreadsTabContent, ThreadsFiltersPopover } from "./threads.tsx";
import { getOrgMembers } from "./utils.ts";
+import { track } from "@/web/lib/posthog-client";
// ============================================================================
// Filters Popover Component
@@ -951,9 +952,26 @@ export default function MonitoringDashboard() {
to={to}
propertyFilters={propertyFilters}
onUpdateFilters={updateFilters}
- onTimeRangeChange={handleTimeRangeChange}
- onStreamingToggle={() => updateFilters({ streaming: !streaming })}
- onTabChange={(newTab) => updateFilters({ tab: newTab })}
+ onTimeRangeChange={(range) => {
+ track("monitoring_time_range_changed", {
+ from: range.from,
+ to: range.to,
+ });
+ handleTimeRangeChange(range);
+ }}
+ onStreamingToggle={() => {
+ track("monitoring_live_toggled", { enabled: !streaming });
+ updateFilters({ streaming: !streaming });
+ }}
+ onTabChange={(newTab) => {
+ if (newTab !== tab) {
+ track("monitoring_tab_changed", {
+ from_tab: tab,
+ to_tab: newTab,
+ });
+ }
+ updateFilters({ tab: newTab });
+ }}
/>
diff --git a/apps/mesh/src/web/routes/orgs/settings/automations.tsx b/apps/mesh/src/web/routes/orgs/settings/automations.tsx
index bc85865dfc..1cbbfc8899 100644
--- a/apps/mesh/src/web/routes/orgs/settings/automations.tsx
+++ b/apps/mesh/src/web/routes/orgs/settings/automations.tsx
@@ -9,6 +9,7 @@ import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent";
import { AutomationListRow } from "@/web/views/automations/automation-list-row";
import { useVirtualMCPs, useProjectContext } from "@decocms/mesh-sdk";
import { useNavigate } from "@tanstack/react-router";
+import { track } from "@/web/lib/posthog-client";
export default function SettingsAutomationsPage() {
const { org } = useProjectContext();
@@ -31,12 +32,18 @@ export default function SettingsAutomationsPage() {
const handleRowClick = (automationId: string, agentId: string | null) => {
if (!agentId) return;
+ track("automations_list_row_clicked", {
+ automation_id: automationId,
+ agent_id: agentId,
+ source: "settings_automations",
+ });
navigateToAgent(agentId, {
search: { main: "automation:" + automationId },
});
};
const handleBrowseAgents = () => {
+ track("automations_empty_state_browse_agents_clicked");
navigate({ to: "/$org/settings/agents", params: { org: org.slug } });
};
diff --git a/apps/mesh/src/web/views/automations/automation-detail.tsx b/apps/mesh/src/web/views/automations/automation-detail.tsx
index cdf20c22e0..c3ab77079e 100644
--- a/apps/mesh/src/web/views/automations/automation-detail.tsx
+++ b/apps/mesh/src/web/views/automations/automation-detail.tsx
@@ -79,6 +79,7 @@ import {
SelectTrigger,
SelectValue,
} from "@deco/ui/components/select.tsx";
+import { track } from "@/web/lib/posthog-client";
// ============================================================================
// Event Trigger Form
@@ -113,6 +114,12 @@ function EventTriggerForm({
connection_id: connectionId,
params,
});
+ track("automation_trigger_added", {
+ automation_id: automationId,
+ trigger_type: "event",
+ connection_id: connectionId,
+ event_type: eventType,
+ });
toast.success("Event trigger added");
onDone();
} catch {
@@ -321,6 +328,13 @@ export function SettingsTab({
.join("\n");
if (!instructionsText.trim()) return;
+ flushEditSession();
+ track("automation_improve_clicked", {
+ automation_id: automationId,
+ agent_id: agentId,
+ instructions_length: instructionsText.length,
+ });
+
setChatMode("plan");
createTaskWithMessage({
@@ -360,9 +374,41 @@ export function SettingsTab({
const selectedModel: AiProviderModel | null =
models.find((m) => m.modelId === watchModelId) ?? null;
+ // Session-based tracking for automation_updated. Auto-saves persist every
+ // ~1s but we only emit one PostHog event per edit-session (aggregated
+ // fields + save_count + edit_duration_ms). A session ends after 30s of
+ // quiet, or on explicit flush (tab-leave, improve, test).
+ const editSessionStartRef = useRef(null);
+ const editSessionFieldsRef = useRef>(new Set());
+ const editSessionSaveCountRef = useRef(0);
+ const editSessionFlushRef = useRef | null>(
+ null,
+ );
+ const EDIT_SESSION_QUIET_MS = 30_000;
+
+ const flushEditSession = () => {
+ if (editSessionFlushRef.current) {
+ clearTimeout(editSessionFlushRef.current);
+ editSessionFlushRef.current = null;
+ }
+ if (editSessionStartRef.current === null) return;
+ track("automation_updated", {
+ automation_id: automationId,
+ agent_id: agentId,
+ fields: Array.from(editSessionFieldsRef.current),
+ save_count: editSessionSaveCountRef.current,
+ edit_duration_ms: Date.now() - editSessionStartRef.current,
+ });
+ editSessionStartRef.current = null;
+ editSessionFieldsRef.current = new Set();
+ editSessionSaveCountRef.current = 0;
+ };
+
const saveForm = async (): Promise => {
const hasDirtyFields = Object.keys(form.formState.dirtyFields).length > 0;
if (!hasDirtyFields && !tiptapDirtyRef.current) return true;
+ const dirtyFormKeys = Object.keys(form.formState.dirtyFields);
+ const tiptapWasDirty = tiptapDirtyRef.current;
tiptapDirtyRef.current = false;
const values = form.getValues();
@@ -389,6 +435,22 @@ export function SettingsTab({
temperature: 0,
};
await updateMutation.mutateAsync(updatePayload);
+
+ // Accumulate into the edit session.
+ if (editSessionStartRef.current === null) {
+ editSessionStartRef.current = Date.now();
+ }
+ for (const k of dirtyFormKeys) editSessionFieldsRef.current.add(k);
+ if (tiptapWasDirty) editSessionFieldsRef.current.add("messages");
+ editSessionSaveCountRef.current += 1;
+ if (editSessionFlushRef.current) {
+ clearTimeout(editSessionFlushRef.current);
+ }
+ editSessionFlushRef.current = setTimeout(
+ flushEditSession,
+ EDIT_SESSION_QUIET_MS,
+ );
+
form.reset({
...values,
credential_id: coercedCredentialId,
@@ -432,7 +494,12 @@ export function SettingsTab({
}
const handleRunClick = async () => {
+ track("automation_test_clicked", {
+ automation_id: automationId,
+ agent_id: agentId,
+ });
const saved = await flushAndSave();
+ flushEditSession();
if (!saved) return;
if (!tiptapDoc) {
@@ -581,6 +648,10 @@ export function SettingsTab({
type: "cron",
cron_expression: val,
});
+ track("automation_trigger_added", {
+ automation_id: automationId,
+ trigger_type: "cron",
+ });
toast.success("Starter added");
setShowCustomCron(false);
setCronInput("");
diff --git a/apps/mesh/src/web/views/automations/automations-list.tsx b/apps/mesh/src/web/views/automations/automations-list.tsx
index 297a032806..e614580f71 100644
--- a/apps/mesh/src/web/views/automations/automations-list.tsx
+++ b/apps/mesh/src/web/views/automations/automations-list.tsx
@@ -11,6 +11,7 @@ import {
useAutomations,
} from "@/web/hooks/use-automations";
import { AutomationListRow } from "./automation-list-row";
+import { track } from "@/web/lib/posthog-client";
export function AutomationsList({ virtualMcpId }: { virtualMcpId: string }) {
const navigate = useNavigate();
@@ -35,6 +36,10 @@ export function AutomationsList({ virtualMcpId }: { virtualMcpId: string }) {
const handleNew = async () => {
if (create.isPending) return;
+ track("automation_new_clicked", {
+ virtual_mcp_id: virtualMcpId,
+ existing_count: automations.length,
+ });
const created = await create.mutateAsync(
buildDefaultAutomationInput(virtualMcpId),
);
diff --git a/apps/mesh/src/web/views/settings/org-ai-providers.tsx b/apps/mesh/src/web/views/settings/org-ai-providers.tsx
index 862514758d..19ce56d56f 100644
--- a/apps/mesh/src/web/views/settings/org-ai-providers.tsx
+++ b/apps/mesh/src/web/views/settings/org-ai-providers.tsx
@@ -62,6 +62,7 @@ import {
pickSimpleModeDefaults,
} from "@decocms/mesh-sdk";
import { KEYS } from "@/web/lib/query-keys";
+import { track } from "@/web/lib/posthog-client";
import { cn } from "@deco/ui/lib/utils.ts";
import { ErrorBoundary } from "@/web/components/error-boundary";
import {
@@ -241,7 +242,7 @@ function ConnectApiKeyForm({
type={showKey ? "text" : "password"}
placeholder="sk-..."
{...register("apiKey")}
- className="h-8 text-sm pr-8"
+ className="ph-no-capture h-8 text-sm pr-8"
/>
{
+ track("ai_provider_oauth_succeeded", { provider_id: provider.id });
queryClient.invalidateQueries({ queryKey: KEYS.aiProviderKeys(org.id) });
queryClient.invalidateQueries({ queryKey: KEYS.aiProviders(org.id) });
toast.success(`${provider.name} connected successfully`);
@@ -490,6 +492,10 @@ function ProviderCard({
setOauthStateToken(null);
},
onError: (err) => {
+ track("ai_provider_oauth_failed", {
+ provider_id: provider.id,
+ error: err.message,
+ });
toast.error(`OAuth connection failed: ${err.message}`);
setIsOAuthPending(false);
setOauthStateToken(null);
@@ -513,9 +519,14 @@ function ProviderCard({
},
onSuccess: (data) => {
if (!data?.activated) {
+ track("ai_provider_cli_activate_failed", {
+ provider_id: provider.id,
+ error: data?.error ?? "unknown",
+ });
toast.error(data?.error ?? "CLI activation failed");
return;
}
+ track("ai_provider_cli_activated", { provider_id: provider.id });
queryClient.invalidateQueries({
queryKey: KEYS.aiProviderKeys(org.id),
});
@@ -524,7 +535,13 @@ function ProviderCard({
});
toast.success(`${provider.name} activated`);
},
- onError: (err) => toast.error(err.message),
+ onError: (err) => {
+ track("ai_provider_cli_activate_failed", {
+ provider_id: provider.id,
+ error: err.message,
+ });
+ toast.error(err.message);
+ },
});
const { mutate: provisionKey, isPending: isProvisioning } = useMutation({
@@ -542,11 +559,18 @@ function ProviderCard({
}
},
onSuccess: () => {
+ track("ai_provider_provision_succeeded", {
+ provider_id: provider.id,
+ });
queryClient.invalidateQueries({ queryKey: KEYS.aiProviderKeys(org.id) });
queryClient.invalidateQueries({ queryKey: KEYS.aiProviders(org.id) });
toast.success(`${provider.name} connected successfully`);
},
onError: (err) => {
+ track("ai_provider_provision_failed", {
+ provider_id: provider.id,
+ error: err.message,
+ });
toast.error(`Failed to connect ${provider.name}: ${err.message}`);
},
});
@@ -555,11 +579,19 @@ function ProviderCard({
useEffect(() => {
if (!isOAuthPending || !oauthStateToken) return;
+ // Local flag — once the popup posts back and exchangeOAuth starts, the
+ // exchange has its own onSuccess/onError handlers. Without this, a slow
+ // exchange (>2min) would race the timeout and fire a false-positive
+ // ai_provider_oauth_failed{error:"timeout"} alongside the eventual
+ // ai_provider_oauth_succeeded.
+ let exchangeStarted = false;
+
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === "AI_PROVIDER_OAUTH_CALLBACK") {
const { code, stateToken } = event.data;
if (stateToken === oauthStateToken) {
+ exchangeStarted = true;
exchangeOAuth({ code, stateToken });
} else {
console.error("State token mismatch");
@@ -572,20 +604,22 @@ function ProviderCard({
window.addEventListener("message", handleMessage);
- // Timeout after 2 minutes
+ // 2-minute popup-wait timeout. Distinct from exchange-failure: this means
+ // the user never came back from the OAuth popup. Tracked as a separate
+ // event so funnel math stays clean.
const timeoutId = setTimeout(() => {
- if (isOAuthPending) {
- setIsOAuthPending(false);
- setOauthStateToken(null);
- toast.error("Connection timed out");
- }
+ if (exchangeStarted) return;
+ track("ai_provider_oauth_timeout", { provider_id: provider.id });
+ setIsOAuthPending(false);
+ setOauthStateToken(null);
+ toast.error("Connection timed out");
}, 120000);
return () => {
window.removeEventListener("message", handleMessage);
clearTimeout(timeoutId);
};
- }, [isOAuthPending, oauthStateToken, exchangeOAuth]);
+ }, [isOAuthPending, oauthStateToken, exchangeOAuth, provider.id]);
const supportsProvision = !!provider.supportsProvision;
const supportsOAuth = provider.supportedMethods.includes("oauth-pkce");
@@ -595,14 +629,32 @@ function ProviderCard({
if (isConnectFormOpen || isOAuthPending || isActivating || isProvisioning)
return;
if (isCliActivate) {
- if (!isActive) activateCli();
+ if (!isActive) {
+ track("ai_provider_connect_clicked", {
+ provider_id: provider.id,
+ method: "cli-activate",
+ });
+ activateCli();
+ }
return;
}
if (supportsProvision) {
+ track("ai_provider_connect_clicked", {
+ provider_id: provider.id,
+ method: "provision",
+ });
provisionKey();
} else if (supportsOAuth) {
+ track("ai_provider_connect_clicked", {
+ provider_id: provider.id,
+ method: "oauth-pkce",
+ });
handleConnectOAuth();
} else if (supportsApiKey) {
+ track("ai_provider_connect_clicked", {
+ provider_id: provider.id,
+ method: "api-key",
+ });
setIsConnectFormOpen(true);
}
};
diff --git a/apps/mesh/src/web/views/settings/org-brand-context.tsx b/apps/mesh/src/web/views/settings/org-brand-context.tsx
index 168d8ae4e2..feec280659 100644
--- a/apps/mesh/src/web/views/settings/org-brand-context.tsx
+++ b/apps/mesh/src/web/views/settings/org-brand-context.tsx
@@ -27,6 +27,7 @@ import { Page } from "@/web/components/page";
import { KEYS } from "@/web/lib/query-keys";
import { unwrapToolResult } from "@/web/lib/unwrap-tool-result";
import { usePublicConfig } from "@/web/hooks/use-public-config";
+import { track } from "@/web/lib/posthog-client";
// --- Types ---
@@ -633,7 +634,11 @@ function ExpandableBrandEntry({
arguments: merged,
});
},
- onSuccess: () => {
+ onSuccess: (_res, data) => {
+ track("brand_updated", {
+ brand_id: brand.id,
+ fields: Object.keys(data),
+ });
onChanged();
toast.success("Brand context saved");
},
@@ -656,6 +661,9 @@ function ExpandableBrandEntry({
}
},
onSuccess: () => {
+ track(archived ? "brand_restored" : "brand_archived", {
+ brand_id: brand.id,
+ });
onChanged();
toast.success(archived ? "Brand restored" : "Brand archived");
},
@@ -673,6 +681,7 @@ function ExpandableBrandEntry({
});
},
onSuccess: () => {
+ track("brand_set_as_default", { brand_id: brand.id });
onChanged();
toast.success("Set as default brand");
},
@@ -879,6 +888,7 @@ export function OrgBrandContextPage() {
});
},
onSuccess: () => {
+ track("brand_created");
invalidate();
toast.success("Brand created");
},
@@ -887,6 +897,7 @@ export function OrgBrandContextPage() {
const { mutate: extractBrand, isPending: isExtracting } = useMutation({
mutationFn: async (domain: string) => {
+ track("brand_extract_started", { domain });
const result = await client.callTool({
name: "BRAND_CONTEXT_EXTRACT",
arguments: { domain },
@@ -895,13 +906,18 @@ export function OrgBrandContextPage() {
unwrapToolResult(result);
},
onSuccess: () => {
+ track("brand_extract_succeeded");
invalidate();
toast.success("Brand extracted successfully");
},
- onError: (err) =>
+ onError: (err) => {
+ track("brand_extract_failed", {
+ error: err instanceof Error ? err.message : "unknown",
+ });
toast.error(
err instanceof Error ? err.message : "Failed to extract brand",
- ),
+ );
+ },
});
return (
diff --git a/apps/mesh/src/web/views/settings/org-sso.tsx b/apps/mesh/src/web/views/settings/org-sso.tsx
index 6e961d453b..95010fc95e 100644
--- a/apps/mesh/src/web/views/settings/org-sso.tsx
+++ b/apps/mesh/src/web/views/settings/org-sso.tsx
@@ -13,6 +13,7 @@ import {
useToggleSsoEnforcement,
} from "@/web/hooks/use-org-sso";
import { CheckCircle, AlertCircle, Trash01 } from "@untitledui/icons";
+import { track } from "@/web/lib/posthog-client";
export function OrgSsoPage() {
const { org } = useProjectContext();
@@ -70,6 +71,10 @@ export function OrgSsoPage() {
domain: formState.domain,
enforced: config?.enforced ?? false,
});
+ track(isConfigured ? "sso_config_updated" : "sso_configured", {
+ organization_id: org.id,
+ email_domain: formState.domain,
+ });
toast.success("SSO configuration saved");
setIsEditing(false);
} catch (err) {
@@ -83,6 +88,7 @@ export function OrgSsoPage() {
if (!confirm("Are you sure you want to remove SSO configuration?")) return;
try {
await deleteMutation.mutateAsync();
+ track("sso_config_removed", { organization_id: org.id });
toast.success("SSO configuration removed");
setIsEditing(false);
} catch {
@@ -93,6 +99,10 @@ export function OrgSsoPage() {
const handleEnforceToggle = async (enforced: boolean) => {
try {
await enforceMutation.mutateAsync(enforced);
+ track("sso_enforcement_toggled", {
+ organization_id: org.id,
+ enforced,
+ });
toast.success(
enforced ? "SSO enforcement enabled" : "SSO enforcement disabled",
);
diff --git a/apps/mesh/src/web/views/settings/org-store.tsx b/apps/mesh/src/web/views/settings/org-store.tsx
index 316ce1a173..2389c758a4 100644
--- a/apps/mesh/src/web/views/settings/org-store.tsx
+++ b/apps/mesh/src/web/views/settings/org-store.tsx
@@ -24,6 +24,7 @@ import { Page } from "@/web/components/page";
import { ErrorBoundary } from "@/web/components/error-boundary";
import { useRegistryConnections } from "@/web/hooks/use-registry-connections";
import { useRegistrySettings } from "@/web/hooks/use-registry-settings";
+import { track } from "@/web/lib/posthog-client";
function ErrorFallback({ error }: { error: Error }) {
return (
@@ -69,6 +70,9 @@ function AddPrivateRegistryForm({
return created.id;
},
onSuccess: (connectionId) => {
+ track("store_private_registry_added", {
+ connection_id: connectionId,
+ });
toast.success("Private registry added");
onSuccess(connectionId);
},
@@ -269,6 +273,10 @@ function OrgStoreContent() {
);
const handleToggle = async (connectionId: string, enabled: boolean) => {
+ track("store_registry_toggled", {
+ connection_id: connectionId,
+ enabled,
+ });
const current = registryConfig ?? { registries: {}, blockedMcps: [] };
await updateRegistryConfig({
...current,
@@ -280,6 +288,7 @@ function OrgStoreContent() {
};
const handleDelete = async (connectionId: string) => {
+ track("store_private_registry_removed", { connection_id: connectionId });
await connectionActions.delete.mutateAsync(connectionId);
queryClient.invalidateQueries({ queryKey: KEYS.registryConfig(org.id) });
};
diff --git a/apps/mesh/src/web/views/settings/profile-preferences.tsx b/apps/mesh/src/web/views/settings/profile-preferences.tsx
index 056ba3c786..253edce529 100644
--- a/apps/mesh/src/web/views/settings/profile-preferences.tsx
+++ b/apps/mesh/src/web/views/settings/profile-preferences.tsx
@@ -32,6 +32,7 @@ import {
import { playSound } from "@deco/ui/lib/sound-engine.ts";
import { question004Sound } from "@deco/ui/lib/question-004.ts";
import { toast } from "@deco/ui/components/sonner.js";
+import { track } from "@/web/lib/posthog-client";
function PreferenceRow({
label,
@@ -92,6 +93,7 @@ function ProfileSection() {
setSaving(true);
try {
await authClient.updateUser({ name });
+ track("profile_updated", { fields: ["name"] });
setEditedName(null);
toast.success("Profile updated");
} catch {
@@ -164,6 +166,7 @@ function PreferencesSection() {
if (checked) {
const result = await Notification.requestPermission();
if (result !== "granted") {
+ track("preferences_notifications_permission_denied");
toast.error(
"Notifications denied. Please enable them in your browser settings.",
);
@@ -171,6 +174,7 @@ function PreferencesSection() {
return;
}
}
+ track("preferences_notifications_toggled", { enabled: checked });
setPreferences((prev) => ({ ...prev, enableNotifications: checked }));
};
@@ -191,6 +195,7 @@ function PreferencesSection() {
value={preferences.theme}
onValueChange={(value) => {
if (value) {
+ track("preferences_theme_changed", { to_value: value });
setPreferences((prev) => ({
...prev,
theme: value as ThemeMode,
@@ -228,18 +233,22 @@ function PreferencesSection() {
+ onClick={() => {
+ track("preferences_sounds_toggled", {
+ enabled: !preferences.enableSounds,
+ });
setPreferences((prev) => ({
...prev,
enableSounds: !prev.enableSounds,
- }))
- }
+ }));
+ }}
control={
{
+ track("preferences_sounds_previewed");
playSound(question004Sound.dataUri).catch(() => {});
}}
className="size-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors cursor-pointer"
@@ -248,12 +257,13 @@ function PreferencesSection() {
+ onCheckedChange={(checked) => {
+ track("preferences_sounds_toggled", { enabled: checked });
setPreferences((prev) => ({
...prev,
enableSounds: checked,
- }))
- }
+ }));
+ }}
/>
}
@@ -264,12 +274,15 @@ function PreferencesSection() {
control={
Server is starting\\u2026
This page will refresh automatically.
");
- if (idx !== -1) {
- html = html.slice(0, idx) + BOOTSTRAP + html.slice(idx);
- } else {
- html += BOOTSTRAP;
- }
- res.end(html);
- });
- } else {
- res.writeHead(upstream.statusCode, upstream.headers);
- upstream.pipe(res);
- }
- });
- p.on("error", (e) => {
- log("proxy error", req.method, req.url, e.message);
- const connErr = ["ECONNREFUSED", "ECONNRESET", "ECONNABORTED"].includes(e.code);
- if (req.url === "/" && connErr) {
- res.writeHead(503, { "Content-Type": "text/html; charset=utf-8", "Retry-After": "1", "Access-Control-Allow-Origin": "*" });
- res.end('