diff --git a/apps/admin/src/app/(dashboard)/page.tsx b/apps/admin/src/app/(dashboard)/page.tsx index a4116798294..623e6a7028d 100644 --- a/apps/admin/src/app/(dashboard)/page.tsx +++ b/apps/admin/src/app/(dashboard)/page.tsx @@ -19,15 +19,26 @@ import { WeekPicker } from "./components/WeekPicker"; export default function DashboardPage() { const trpc = useTRPC(); - const [funnelRange, setFunnelRange] = useState("-7d"); + const [activationFunnelRange, setActivationFunnelRange] = + useState("-7d"); + const [marketingFunnelRange, setMarketingFunnelRange] = + useState("-7d"); const [signupsRange, setSignupsRange] = useState("-30d"); const [trafficRange, setTrafficRange] = useState("-30d"); const [revenueRange, setRevenueRange] = useState("-30d"); const [wauRange, setWauRange] = useState("-30d"); const [leaderboardWeekOffset, setLeaderboardWeekOffset] = useState(0); - const fullJourneyFunnel = useQuery( - trpc.analytics.getFullJourneyFunnel.queryOptions({ dateFrom: funnelRange }), + const activationFunnel = useQuery( + trpc.analytics.getActivationFunnel.queryOptions({ + dateFrom: activationFunnelRange, + }), + ); + + const marketingFunnel = useQuery( + trpc.analytics.getMarketingFunnel.queryOptions({ + dateFrom: marketingFunnelRange, + }), ); const wau = useQuery( @@ -106,13 +117,30 @@ export default function DashboardPage() { /> + + } + /> + + } /> diff --git a/apps/desktop/src/lib/trpc/routers/analytics/index.ts b/apps/desktop/src/lib/trpc/routers/analytics/index.ts new file mode 100644 index 00000000000..50aa7ac6104 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/analytics/index.ts @@ -0,0 +1,15 @@ +import { setUserId } from "main/lib/analytics"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +export const createAnalyticsRouter = () => { + return router({ + setUserId: publicProcedure + .input(z.object({ userId: z.string().nullable() })) + .mutation(({ input }) => { + setUserId(input.userId); + }), + }); +}; + +export type AnalyticsRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index 684b9415b7a..d580391a015 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -1,6 +1,5 @@ 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"; @@ -54,7 +53,6 @@ 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/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index bb53dea8f41..585f2c3d918 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; +import { createAnalyticsRouter } from "./analytics"; import { createAuthRouter } from "./auth"; import { createChangesRouter } from "./changes"; import { createConfigRouter } from "./config"; @@ -25,6 +26,7 @@ import { createWorkspacesRouter } from "./workspaces"; */ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ + analytics: createAnalyticsRouter(), auth: createAuthRouter(getWindow), user: createUserRouter(), window: createWindowRouter(getWindow), diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index c3ea40d1df9..bd5da74b855 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -301,6 +301,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const defaultBranch = await getDefaultBranch(mainRepoPath); const project = upsertProject(mainRepoPath, defaultBranch); + track("project_opened", { + project_id: project.id, + method: "open", + }); + return { canceled: false, project, @@ -351,6 +356,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const project = upsertProject(input.path, defaultBranch); + track("project_opened", { + project_id: project.id, + method: "init", + }); + return { project }; }), @@ -420,6 +430,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .set({ lastOpenedAt: Date.now() }) .where(eq(projects.id, existingProject.id)) .run(); + + track("project_opened", { + project_id: existingProject.id, + method: "clone", + }); + return { canceled: false as const, success: true as const, @@ -462,6 +478,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .returning() .get(); + track("project_opened", { + project_id: project.id, + method: "clone", + }); + return { canceled: false as const, success: true as const, diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b8780e684ac..ea5541318fc 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -7,7 +7,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 { posthog } from "./lib/analytics"; import { initAppState } from "./lib/app-state"; import { authService, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; @@ -56,7 +56,6 @@ 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); @@ -148,7 +147,7 @@ if (!gotTheLock) { // Clean up all terminals and analytics when app is quitting app.on("before-quit", async () => { - await Promise.all([terminalManager.cleanup(), shutdownAnalytics()]); + await Promise.all([terminalManager.cleanup(), posthog?.shutdown()]); }); })(); } diff --git a/apps/desktop/src/main/lib/analytics/index.ts b/apps/desktop/src/main/lib/analytics/index.ts index 0202c617510..0b0178c8b95 100644 --- a/apps/desktop/src/main/lib/analytics/index.ts +++ b/apps/desktop/src/main/lib/analytics/index.ts @@ -1,73 +1,44 @@ 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; +export let posthog: PostHog | null = null; +let userId: 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, { + if (!posthog) { + posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { host: env.NEXT_PUBLIC_POSTHOG_HOST, - flushAt: 1, // Send events immediately for desktop app + flushAt: 1, flushInterval: 0, }); } - return client; + return posthog; } -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; +export function setUserId(id: string | null): void { + userId = id; } -/** - * 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(); + if (!userId) return; + + const client = getClient(); + if (!client) return; + + client.capture({ + distinctId: userId, + event, + properties: { + ...properties, + app_name: "desktop", + platform: process.platform, + }, + }); } diff --git a/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx b/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx index af2f45fdc71..33ed0e197bd 100644 --- a/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx +++ b/apps/desktop/src/renderer/components/PostHogUserIdentifier/PostHogUserIdentifier.tsx @@ -3,16 +3,28 @@ import { trpc } from "renderer/lib/trpc"; import { posthog } from "../../lib/posthog"; +const AUTH_COMPLETED_KEY = "superset_auth_completed"; + export function PostHogUserIdentifier() { const { data: user, isSuccess } = trpc.user.me.useQuery(); + const { mutate: setUserId } = trpc.analytics.setUserId.useMutation(); useEffect(() => { if (user) { posthog.identify(user.id, { email: user.email, name: user.name }); + setUserId({ userId: user.id }); + + const trackedUserId = localStorage.getItem(AUTH_COMPLETED_KEY); + if (trackedUserId !== user.id) { + posthog.capture("auth_completed"); + localStorage.setItem(AUTH_COMPLETED_KEY, user.id); + } } else if (isSuccess) { posthog.reset(); + setUserId({ userId: null }); + localStorage.removeItem(AUTH_COMPLETED_KEY); } - }, [user, isSuccess]); + }, [user, isSuccess, setUserId]); return null; } diff --git a/packages/trpc/src/router/analytics/analytics.ts b/packages/trpc/src/router/analytics/analytics.ts index 3eea1445442..11dc8796e1d 100644 --- a/packages/trpc/src/router/analytics/analytics.ts +++ b/packages/trpc/src/router/analytics/analytics.ts @@ -54,7 +54,7 @@ function formatWeekData( } export const analyticsRouter = { - getFullJourneyFunnel: adminProcedure + getActivationFunnel: adminProcedure .input( z .object({ @@ -67,26 +67,56 @@ export const analyticsRouter = { const results = await executeFunnelQuery( [ - { kind: "EventsNode", event: "$pageview", name: "Site Visit" }, { kind: "EventsNode", - event: "download_clicked", - name: "Download Clicked", + event: "desktop_opened", + name: "App Opened", }, { kind: "EventsNode", - event: "desktop_opened", - name: "Desktop Opened", + event: "auth_completed", + name: "Signed Up", }, { kind: "EventsNode", - event: "auth_completed", - name: "Auth Completed", + event: "project_opened", + name: "Opened Project", }, { kind: "EventsNode", - event: "terminal_opened", - name: "Terminal Opened", + event: "workspace_created", + name: "Created Workspace", + }, + ], + dateFrom, + ); + + return formatFunnelResults(results); + }), + + getMarketingFunnel: adminProcedure + .input( + z + .object({ + dateFrom: z.string().optional().default("-7d"), + }) + .optional(), + ) + .query(async ({ input }) => { + const dateFrom = input?.dateFrom ?? "-7d"; + + const results = await executeFunnelQuery( + [ + { kind: "EventsNode", event: "$pageview", name: "Site Visit" }, + { + kind: "EventsNode", + event: "download_clicked", + name: "Download Clicked", + }, + { + kind: "EventsNode", + event: "desktop_opened", + name: "App Opened", }, ], dateFrom, @@ -105,10 +135,8 @@ export const analyticsRouter = { ) .query(async ({ input }) => { const days = input?.days ?? 30; - const lookbackDays = days + 7; // Extra 7 days to cover rolling windows + const lookbackDays = days + 7; - // Rolling 7-day WAU: for each day, count users with 3+ active days - // of workspace_created events in the preceding 7-day window const { results } = await executeHogQLQuery<[string, number][]>(` SELECT report_date as date, @@ -139,10 +167,7 @@ export const analyticsRouter = { ORDER BY report_date ASC `); - // Create a map of existing data const dataMap = new Map(results.map(([date, count]) => [date, count])); - - // Fill in all dates in the range (in case some days have 0 WAU) const filledData: { date: string; count: number }[] = []; const now = new Date(); for (let i = days - 1; i >= 0; i--) { @@ -159,10 +184,9 @@ export const analyticsRouter = { }), getRetention: adminProcedure.query(async () => { - // Weekly cohort retention: users who auth'd and returned (any event) const cohorts = await executeRetentionQuery({ targetEvent: "auth_completed", - returningEvent: "$pageview", // Any activity counts as returning + returningEvent: "terminal_opened", period: "Week", totalIntervals: 5, dateFrom: "-35d", @@ -216,17 +240,12 @@ export const analyticsRouter = { return [] as LeaderboardEntry[]; } - // Extract user IDs from PostHog results const userIds = results.map(([distinctId]) => distinctId); - - // Fetch user details from our database const dbUsers = await db.query.users.findMany({ where: inArray(users.id, userIds), }); - const userMap = new Map(dbUsers.map((u) => [u.id, u])); - // Join PostHog data with DB user data const leaderboard: LeaderboardEntry[] = results .map(([distinctId, count]) => { const user = userMap.get(distinctId); @@ -266,10 +285,7 @@ export const analyticsRouter = { ORDER BY date ASC `); - // Create a map of existing data const dataMap = new Map(results.map(([date, count]) => [date, count])); - - // Fill in all dates in the range const filledData: { date: string; count: number }[] = []; const now = new Date(); for (let i = days - 1; i >= 0; i--) { @@ -296,7 +312,6 @@ export const analyticsRouter = { .query(async ({ input }) => { const days = input?.days ?? 30; - // Use TrendsQuery with breakdown for more reliable results const query: InsightVizNode = { kind: "InsightVizNode", source: { @@ -343,8 +358,6 @@ export const analyticsRouter = { ) .query(async ({ input }) => { const days = input?.days ?? 30; - - // Fill in all dates in the range with zeros (no revenue tracking yet) const filledData: { date: string; revenue: number; mrr: number }[] = []; const now = new Date();