From b908dad4b1553e689a0fe5ec612189b3ffb51205 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 22 Dec 2025 18:49:51 -0500 Subject: [PATCH] feat: add PostHog event tracking across apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add custom event tracking for key user actions: - Marketing: download_clicked, waitlist_clicked - Desktop: desktop_opened, auth_started, auth_completed, workspace_created/opened/closed/deleted, terminal_opened - Fix user identity to use database user ID consistently across all apps (was using Clerk ID in web/admin which differed from desktop) Also adds posthog-node for reliable server-side tracking in desktop main process. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../PostHogUserIdentifier.tsx | 21 +++--- apps/desktop/package.json | 1 + .../src/lib/trpc/routers/auth/index.ts | 2 + .../lib/trpc/routers/workspaces/workspaces.ts | 25 +++++++ apps/desktop/src/main/env.main.ts | 4 + apps/desktop/src/main/index.ts | 6 +- apps/desktop/src/main/lib/analytics/index.ts | 73 +++++++++++++++++++ .../src/main/lib/terminal/manager.test.ts | 6 +- apps/desktop/src/main/lib/terminal/manager.ts | 6 +- .../PostHogUserIdentifier.tsx | 13 +--- .../src/renderer/screens/sign-in/index.tsx | 10 ++- apps/desktop/test-setup.ts | 13 ++++ .../DownloadButton/DownloadButton.tsx | 7 +- .../PostHogUserIdentifier.tsx | 21 +++--- bun.lock | 7 +- 15 files changed, 179 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/main/lib/analytics/index.ts diff --git a/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx b/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx index 656dce84cb1..68f00c993dd 100644 --- a/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/admin/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -1,24 +1,27 @@ "use client"; import { useUser } from "@clerk/nextjs"; +import { useQuery } from "@tanstack/react-query"; import posthog from "posthog-js"; import { useEffect } from "react"; +import { useTRPC } from "../../trpc/react"; export function PostHogUserIdentifier() { - const { user, isLoaded } = useUser(); + const { isSignedIn } = useUser(); + const trpc = useTRPC(); - useEffect(() => { - if (!isLoaded) return; + const { data: user } = useQuery({ + ...trpc.user.me.queryOptions(), + enabled: isSignedIn, + }); + useEffect(() => { if (user) { - posthog.identify(user.id, { - email: user.primaryEmailAddress?.emailAddress, - name: user.fullName, - }); - } else { + posthog.identify(user.id, { email: user.email, name: user.name }); + } else if (isSignedIn === false) { posthog.reset(); } - }, [user, isLoaded]); + }, [user, isSignedIn]); return null; } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6aef313966e..2b6e646a3b7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -79,6 +79,7 @@ "node-pty": "1.1.0-beta30", "os-locale": "^6.0.2", "posthog-js": "^1.306.1", + "posthog-node": "^5.18.0", "react": "^19.2.3", "react-arborist": "^3.4.3", "react-dnd": "^16.0.1", diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index d580391a015..684b9415b7a 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -1,5 +1,6 @@ import { observable } from "@trpc/server/observable"; import type { BrowserWindow } from "electron"; +import { clearUserCache } from "main/lib/analytics"; import { authService } from "main/lib/auth"; import { AUTH_PROVIDERS } from "shared/auth"; import { z } from "zod"; @@ -53,6 +54,7 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => { */ signOut: publicProcedure.mutation(async () => { await authService.signOut(); + clearUserCache(); return { success: true }; }), }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index e4bbd5bb0c6..093eca9cc66 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -1,5 +1,6 @@ import { homedir } from "node:os"; import { join } from "node:path"; +import { track } from "main/lib/analytics"; import { db } from "main/lib/db"; import { terminalManager } from "main/lib/terminal"; import { nanoid } from "nanoid"; @@ -166,6 +167,13 @@ export const createWorkspacesRouter = () => { // Load setup configuration from the main repo (where .superset/config.json lives) const setupConfig = loadSetupConfig(project.mainRepoPath); + track("workspace_created", { + workspace_id: workspace.id, + project_id: project.id, + branch: branch, + base_branch: targetBranch, + }); + return { workspace, initialCommands: setupConfig?.setup || null, @@ -271,6 +279,13 @@ export const createWorkspacesRouter = () => { } }); + track("workspace_opened", { + workspace_id: returnedWorkspace.id, + project_id: project.id, + type: "branch", + was_existing: wasExisting, + }); + return { workspace: returnedWorkspace, worktreePath: project.mainRepoPath, @@ -772,6 +787,8 @@ export const createWorkspacesRouter = () => { ? `${terminalResult.failed} terminal process(es) may still be running` : undefined; + track("workspace_deleted", { workspace_id: input.id }); + return { success: true, teardownError, terminalWarning }; }), @@ -1059,6 +1076,12 @@ export const createWorkspacesRouter = () => { // Load setup configuration from the main repo const setupConfig = loadSetupConfig(project.mainRepoPath); + track("workspace_opened", { + workspace_id: workspace.id, + project_id: project.id, + type: "worktree", + }); + return { workspace, initialCommands: setupConfig?.setup || null, @@ -1110,6 +1133,8 @@ export const createWorkspacesRouter = () => { ? `${terminalResult.failed} terminal process(es) may still be running` : undefined; + track("workspace_closed", { workspace_id: input.id }); + return { success: true, terminalWarning }; }), }); diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts index 6f8bc42dd94..38be99adba0 100644 --- a/apps/desktop/src/main/env.main.ts +++ b/apps/desktop/src/main/env.main.ts @@ -18,6 +18,8 @@ export const env = createEnv({ NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), GOOGLE_CLIENT_ID: z.string().min(1), GH_CLIENT_ID: z.string().min(1), + NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), + NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), }, runtimeEnv: { @@ -29,6 +31,8 @@ export const env = createEnv({ NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, GH_CLIENT_ID: process.env.GH_CLIENT_ID, + NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, + NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, }, emptyStringAsUndefined: true, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3c45f4d588d..a5b3acda691 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,6 +3,7 @@ import { app, BrowserWindow } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { PROTOCOL_SCHEME } from "shared/constants"; import { setupAgentHooks } from "./lib/agent-setup"; +import { shutdown as shutdownAnalytics, track } from "./lib/analytics"; import { initAppState } from "./lib/app-state"; import { authService, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; @@ -48,6 +49,7 @@ async function processDeepLink(url: string): Promise { refreshTokenExpiresAt: result.refreshTokenExpiresAt, state: result.state, }); + track("auth_completed"); focusMainWindow(); } else { console.error("[main] Auth deep link failed:", result.error); @@ -138,9 +140,9 @@ if (!gotTheLock) { await processDeepLink(coldStartUrl); } - // Clean up all terminals when app is quitting + // Clean up all terminals and analytics when app is quitting app.on("before-quit", async () => { - await terminalManager.cleanup(); + await Promise.all([terminalManager.cleanup(), shutdownAnalytics()]); }); })(); } diff --git a/apps/desktop/src/main/lib/analytics/index.ts b/apps/desktop/src/main/lib/analytics/index.ts new file mode 100644 index 00000000000..0202c617510 --- /dev/null +++ b/apps/desktop/src/main/lib/analytics/index.ts @@ -0,0 +1,73 @@ +import { env } from "main/env.main"; +import { apiClient } from "main/lib/api-client"; +import { PostHog } from "posthog-node"; + +let client: PostHog | null = null; +let cachedUserId: string | null = null; + +function getClient(): PostHog | null { + if (!env.NEXT_PUBLIC_POSTHOG_KEY) { + return null; + } + + if (!client) { + client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { + host: env.NEXT_PUBLIC_POSTHOG_HOST, + flushAt: 1, // Send events immediately for desktop app + flushInterval: 0, + }); + } + return client; +} + +async function getUserId(): Promise { + if (cachedUserId) return cachedUserId; + try { + const user = await apiClient.user.me.query(); + cachedUserId = user?.id ?? null; + return cachedUserId; + } catch { + return null; + } +} + +/** + * Clear cached user ID (call on sign out) + */ +export function clearUserCache(): void { + cachedUserId = null; +} + +/** + * Track an event with the current user's ID as distinct_id. + * Fire-and-forget - errors are silently ignored. + */ +export function track( + event: string, + properties?: Record, +): void { + const posthog = getClient(); + if (!posthog) return; + + getUserId() + .then((userId) => { + if (!userId) return; + posthog.capture({ + distinctId: userId, + event, + properties: { + ...properties, + app_name: "desktop", + platform: process.platform, + }, + }); + }) + .catch(() => {}); +} + +/** + * Shutdown PostHog client (call on app quit) + */ +export async function shutdown(): Promise { + await client?.shutdown(); +} diff --git a/apps/desktop/src/main/lib/terminal/manager.test.ts b/apps/desktop/src/main/lib/terminal/manager.test.ts index 6058c67a717..58eb06598d1 100644 --- a/apps/desktop/src/main/lib/terminal/manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/manager.test.ts @@ -6,14 +6,14 @@ import * as pty from "node-pty"; import { getHistoryDir } from "../terminal-history"; import { TerminalManager } from "./manager"; -// Use real history implementation - it will write to tmpdir thanks to NODE_ENV=test -const testTmpDir = join(tmpdir(), "superset-test"); - // Mock node-pty mock.module("node-pty", () => ({ spawn: mock(() => {}), })); +// Use real history implementation - it will write to tmpdir thanks to NODE_ENV=test +const testTmpDir = join(tmpdir(), "superset-test"); + describe("TerminalManager", () => { let manager: TerminalManager; let mockPty: { diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index d038d7928fd..ebd883bea71 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { track } from "main/lib/analytics"; import { FALLBACK_SHELL, SHELL_CRASH_THRESHOLD_MS } from "./env"; import { closeSessionHistory, @@ -58,7 +59,7 @@ export class TerminalManager extends EventEmitter { private async doCreateSession( params: InternalCreateSessionParams, ): Promise { - const { paneId, initialCommands } = params; + const { paneId, workspaceId, initialCommands } = params; // Create the session const session = await createSession(params, (id, data) => { @@ -75,6 +76,9 @@ export class TerminalManager extends EventEmitter { this.sessions.set(paneId, session); + // Track terminal opened (only fires once per session creation) + track("terminal_opened", { workspace_id: workspaceId, pane_id: paneId }); + return { isNew: true, scrollback: session.scrollback, diff --git a/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx b/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx index b361223dfc4..af2f45fdc71 100644 --- a/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -4,20 +4,15 @@ import { trpc } from "renderer/lib/trpc"; import { posthog } from "../../lib/posthog"; export function PostHogUserIdentifier() { - const { data: user, isLoading } = trpc.user.me.useQuery(); + const { data: user, isSuccess } = trpc.user.me.useQuery(); useEffect(() => { - if (isLoading) return; - if (user) { - posthog.identify(user.id, { - email: user.email, - name: user.name, - }); - } else { + posthog.identify(user.id, { email: user.email, name: user.name }); + } else if (isSuccess) { posthog.reset(); } - }, [user, isLoading]); + }, [user, isSuccess]); return null; } diff --git a/apps/desktop/src/renderer/screens/sign-in/index.tsx b/apps/desktop/src/renderer/screens/sign-in/index.tsx index ba7eae0c9f1..032ebb07a23 100644 --- a/apps/desktop/src/renderer/screens/sign-in/index.tsx +++ b/apps/desktop/src/renderer/screens/sign-in/index.tsx @@ -1,7 +1,9 @@ import { COMPANY } from "@superset/shared/constants"; import { Button } from "@superset/ui/button"; +import { useEffect } from "react"; import { FaGithub } from "react-icons/fa"; import { FcGoogle } from "react-icons/fc"; +import { posthog } from "renderer/lib/posthog"; import { trpc } from "renderer/lib/trpc"; import type { AuthProvider } from "shared/auth"; import { SupersetLogo } from "./components/SupersetLogo"; @@ -9,8 +11,14 @@ import { SupersetLogo } from "./components/SupersetLogo"; export function SignInScreen() { const signInMutation = trpc.auth.signIn.useMutation(); - const signIn = (provider: AuthProvider) => + useEffect(() => { + posthog.capture("desktop_opened"); + }, []); + + const signIn = (provider: AuthProvider) => { + posthog.capture("auth_started", { provider }); signInMutation.mutate({ provider }); + }; return (
diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index e9282ae35b4..9b3cb61b4a7 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -85,4 +85,17 @@ mock.module("electron", () => ({ handle: mock(), on: mock(), }, + shell: { + openExternal: mock(() => Promise.resolve()), + }, +})); + +// ============================================================================= +// Analytics Mock (has Electron/API dependencies) +// ============================================================================= + +mock.module("main/lib/analytics", () => ({ + track: mock(() => {}), + clearUserCache: mock(() => {}), + shutdown: mock(() => Promise.resolve()), })); diff --git a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx index 57c364c6e24..7cea54197a6 100644 --- a/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx +++ b/apps/marketing/src/app/components/DownloadButton/DownloadButton.tsx @@ -1,6 +1,7 @@ "use client"; import { COMPANY, DOWNLOAD_URL_MAC_ARM64 } from "@superset/shared/constants"; +import posthog from "posthog-js"; import { HiMiniArrowDownTray, HiMiniClock } from "react-icons/hi2"; import { type DropdownSection, PlatformDropdown } from "../PlatformDropdown"; @@ -21,6 +22,7 @@ export function DownloadButton({ : "px-3 sm:px-6 py-2 sm:py-3 text-sm sm:text-base"; const handleAppleSiliconDownload = () => { + posthog.capture("download_clicked"); window.open(DOWNLOAD_URL_MAC_ARM64, "_blank"); }; @@ -76,7 +78,10 @@ export function DownloadButton({ id: "waitlist", label: "Join waitlist for Windows & Linux", icon: , - onClick: onJoinWaitlist || (() => {}), + onClick: () => { + posthog.capture("waitlist_clicked"); + onJoinWaitlist?.(); + }, }, { id: "build-from-source", diff --git a/apps/web/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx b/apps/web/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx index 656dce84cb1..68f00c993dd 100644 --- a/apps/web/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/web/src/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -1,24 +1,27 @@ "use client"; import { useUser } from "@clerk/nextjs"; +import { useQuery } from "@tanstack/react-query"; import posthog from "posthog-js"; import { useEffect } from "react"; +import { useTRPC } from "../../trpc/react"; export function PostHogUserIdentifier() { - const { user, isLoaded } = useUser(); + const { isSignedIn } = useUser(); + const trpc = useTRPC(); - useEffect(() => { - if (!isLoaded) return; + const { data: user } = useQuery({ + ...trpc.user.me.queryOptions(), + enabled: isSignedIn, + }); + useEffect(() => { if (user) { - posthog.identify(user.id, { - email: user.primaryEmailAddress?.emailAddress, - name: user.fullName, - }); - } else { + posthog.identify(user.id, { email: user.email, name: user.name }); + } else if (isSignedIn === false) { posthog.reset(); } - }, [user, isLoaded]); + }, [user, isSignedIn]); return null; } diff --git a/bun.lock b/bun.lock index 1411daed9a4..8ddbf92deaf 100644 --- a/bun.lock +++ b/bun.lock @@ -114,7 +114,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.26", + "version": "0.0.31", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -164,6 +164,7 @@ "node-pty": "1.1.0-beta30", "os-locale": "^6.0.2", "posthog-js": "^1.306.1", + "posthog-node": "^5.18.0", "react": "^19.2.3", "react-arborist": "^3.4.3", "react-dnd": "^16.0.1", @@ -2826,6 +2827,8 @@ "posthog-js": ["posthog-js@1.306.1", "", { "dependencies": { "@posthog/core": "1.7.1", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-wO7bliv/5tlAlfoKCUzwkGXZVNexk0dHigMf9tNp0q1rzs62wThogREY7Tz7h/iWKYiuXy1RumtVlTmHuBXa1w=="], + "posthog-node": ["posthog-node@5.18.0", "", { "dependencies": { "@posthog/core": "1.9.0" } }, "sha512-SLBEs+sCThxzTGSSDEe97nZHuFFYh6DupObR1yQdvQND3CJh0ogZ0Sa1Vb+Tbrnf0cWbfBC9XNkm44yhaWf3aA=="], + "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], @@ -3798,6 +3801,8 @@ "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "posthog-node/@posthog/core": ["@posthog/core@1.9.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw=="], + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "promise-retry/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],