diff --git a/apps/code/src/main/preload.ts b/apps/code/src/main/preload.ts index b90c21f89e..5798d81ffa 100644 --- a/apps/code/src/main/preload.ts +++ b/apps/code/src/main/preload.ts @@ -2,8 +2,14 @@ import { exposeElectronTRPC } from "@posthog/electron-trpc/main"; import { contextBridge, webUtils } from "electron"; import "electron-log/preload"; +const SESSION_ID_ARG = "--posthog-session-id="; +const posthogSessionId = process.argv + .find((arg) => arg.startsWith(SESSION_ID_ARG)) + ?.slice(SESSION_ID_ARG.length); + contextBridge.exposeInMainWorld("electronUtils", { getPathForFile: (file: File) => webUtils.getPathForFile(file), + posthogSessionId, }); if (process.argv.includes("--posthog-code-dev")) { diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index e9df218cc4..519dfdd76d 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -13,6 +13,7 @@ import { import { container } from "./di/container"; import { buildApplicationMenu } from "./menu"; import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; +import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics"; import { trpcRouter } from "./trpc/router"; import { collectMemorySnapshot } from "./utils/crash-diagnostics"; import { isDevBuild } from "./utils/env"; @@ -203,7 +204,10 @@ export function createWindow(): void { preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", partition: "persist:main", - additionalArguments: isDev ? ["--posthog-code-dev"] : [], + additionalArguments: [ + ...(isDev ? ["--posthog-code-dev"] : []), + `--posthog-session-id=${posthogNodeAnalytics.getOrCreateSessionId()}`, + ], ...(isDev && { webSecurity: false }), }, }); diff --git a/apps/code/src/renderer/contributions/app-boot.contributions.ts b/apps/code/src/renderer/contributions/app-boot.contributions.ts index 040573f6ea..5dc17ab5d8 100644 --- a/apps/code/src/renderer/contributions/app-boot.contributions.ts +++ b/apps/code/src/renderer/contributions/app-boot.contributions.ts @@ -12,21 +12,13 @@ const log = logger.scope("app-boot"); @injectable() export class AnalyticsBootContribution implements Contribution { start(): void { - void (async () => { - let sessionId: string | undefined; - try { - ({ sessionId } = await trpcClient.analytics.getSessionId.query()); - } catch (error) { - log.warn("Failed to fetch session id from main", { error }); - } - initializePostHog(sessionId); - trpcClient.os.getAppVersion - .query() - .then(registerAppVersion) - .catch((error) => { - log.warn("Failed to register app version super property", { error }); - }); - })(); + initializePostHog(window.electronUtils?.posthogSessionId); + trpcClient.os.getAppVersion + .query() + .then(registerAppVersion) + .catch((error) => { + log.warn("Failed to register app version super property", { error }); + }); } } diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index efa7a5f59a..b124431dd0 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -22,6 +22,10 @@ import { import { container } from "@renderer/di/container"; export function registerDesktopContributions(): void { + // Bound first so boot() initializes PostHog synchronously before any other + // contribution awaits and React effects start firing analytics calls. + container.bind(CONTRIBUTION).to(AnalyticsBootContribution).inSingletonScope(); + for (const module of [ agentUiModule, authUiModule, @@ -43,6 +47,5 @@ export function registerDesktopContributions(): void { container.load(module); } - container.bind(CONTRIBUTION).to(AnalyticsBootContribution).inSingletonScope(); container.bind(CONTRIBUTION).to(InboxDemoDevContribution).inSingletonScope(); } diff --git a/apps/code/src/renderer/types/electron.d.ts b/apps/code/src/renderer/types/electron.d.ts index 0da53ae4a1..66488bcc45 100644 --- a/apps/code/src/renderer/types/electron.d.ts +++ b/apps/code/src/renderer/types/electron.d.ts @@ -4,6 +4,7 @@ declare global { interface Window { electronUtils?: { getPathForFile: (file: File) => string; + posthogSessionId?: string; }; } } diff --git a/packages/host-router/src/routers/analytics.router.ts b/packages/host-router/src/routers/analytics.router.ts index 1ced4d3b6b..3e8425bfe4 100644 --- a/packages/host-router/src/routers/analytics.router.ts +++ b/packages/host-router/src/routers/analytics.router.ts @@ -24,14 +24,6 @@ export const analyticsRouter = router({ } }), - getSessionId: publicProcedure - .output(z.object({ sessionId: z.string() })) - .query(({ ctx }) => ({ - sessionId: ctx.container - .get(ANALYTICS_SERVICE) - .getOrCreateSessionId(), - })), - resetUser: publicProcedure.mutation(({ ctx }) => { ctx.container.get(ANALYTICS_SERVICE).resetUser(); }), diff --git a/packages/ui/src/features/auth/useAuthSession.test.tsx b/packages/ui/src/features/auth/useAuthSession.test.tsx new file mode 100644 index 0000000000..bdbae769d8 --- /dev/null +++ b/packages/ui/src/features/auth/useAuthSession.test.tsx @@ -0,0 +1,89 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resetUser = vi.hoisted(() => vi.fn()); +const hostResetUser = vi.hoisted(() => vi.fn()); +const clearAuthScopedQueries = vi.hoisted(() => vi.fn()); +const setStaleRegion = vi.hoisted(() => vi.fn()); +const clearStaleRegion = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/ui/shell/analytics", () => ({ + identifyUser: vi.fn(), + resetUser, + setUserGroups: vi.fn(), +})); +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPCClient: () => ({ + analytics: { resetUser: { mutate: hostResetUser } }, + }), +})); +vi.mock("./authQueries", () => ({ + clearAuthScopedQueries, + getAuthIdentity: vi.fn(), + refreshAuthStateQuery: vi.fn(), + useAuthStateValue: vi.fn(), + useCurrentUser: vi.fn(() => ({ data: undefined })), +})); +vi.mock("./authClient", () => ({ + useOptionalAuthenticatedClient: vi.fn(() => null), +})); +vi.mock("./authUiStateStore", () => ({ + useAuthUiStateStore: { + getState: () => ({ setStaleRegion, clearStaleRegion }), + }, +})); +vi.mock("@posthog/ui/features/billing/seatStore", () => ({ + useSeatStore: { getState: () => ({ reset: vi.fn(), fetchSeat: vi.fn() }) }, +})); +vi.mock("@posthog/ui/shell/logger", () => ({ + logger: { scope: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }) }, +})); + +import { useAuthIdentitySync } from "./useAuthSession"; + +describe("useAuthIdentitySync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does not reset analytics identity before auth bootstrap completes", () => { + renderHook(() => useAuthIdentitySync(null, null, false)); + + expect(resetUser).not.toHaveBeenCalled(); + expect(hostResetUser).not.toHaveBeenCalled(); + expect(clearAuthScopedQueries).toHaveBeenCalledTimes(1); + }); + + it("resets analytics identity when logged out after bootstrap", () => { + renderHook(() => useAuthIdentitySync(null, null, true)); + + expect(resetUser).toHaveBeenCalledTimes(1); + expect(hostResetUser).toHaveBeenCalledTimes(1); + }); + + it("resets exactly once across boot, login and logout", () => { + const { rerender } = renderHook( + ({ + identity, + bootstrapComplete, + }: { + identity: string | null; + bootstrapComplete: boolean; + }) => useAuthIdentitySync(identity, null, bootstrapComplete), + { + initialProps: { + identity: null as string | null, + bootstrapComplete: false, + }, + }, + ); + + rerender({ identity: "user-1", bootstrapComplete: true }); + expect(resetUser).not.toHaveBeenCalled(); + expect(clearStaleRegion).toHaveBeenCalled(); + + rerender({ identity: null, bootstrapComplete: true }); + expect(resetUser).toHaveBeenCalledTimes(1); + expect(hostResetUser).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/features/auth/useAuthSession.ts b/packages/ui/src/features/auth/useAuthSession.ts index 68bfc3521d..8d32273f99 100644 --- a/packages/ui/src/features/auth/useAuthSession.ts +++ b/packages/ui/src/features/auth/useAuthSession.ts @@ -39,15 +39,21 @@ function useAuthSubscriptionSync(): void { }, [hostClient]); } -function useAuthIdentitySync( +export function useAuthIdentitySync( authIdentity: string | null, cloudRegion: "us" | "eu" | "dev" | null, + bootstrapComplete: boolean, ): void { const hostClient = useHostTRPCClient(); useEffect(() => { if (!authIdentity) { - resetUser(); - void hostClient.analytics.resetUser.mutate(); + // Pre-bootstrap the identity is transiently null on every launch; + // resetting then would wipe the persisted PostHog distinct_id and + // make feature flags evaluate for an anonymous user. + if (bootstrapComplete) { + resetUser(); + void hostClient.analytics.resetUser.mutate(); + } clearAuthScopedQueries(); if (cloudRegion) { useAuthUiStateStore.getState().setStaleRegion(cloudRegion); @@ -56,7 +62,7 @@ function useAuthIdentitySync( } useAuthUiStateStore.getState().clearStaleRegion(); - }, [authIdentity, cloudRegion, hostClient]); + }, [authIdentity, cloudRegion, hostClient, bootstrapComplete]); } function useAuthAnalyticsIdentity( @@ -124,7 +130,11 @@ export function useAuthSession() { const billingEnabled = useFeatureFlag(BILLING_FLAG); useAuthSubscriptionSync(); - useAuthIdentitySync(authIdentity, authState.cloudRegion); + useAuthIdentitySync( + authIdentity, + authState.cloudRegion, + authState.bootstrapComplete, + ); useAuthAnalyticsIdentity(authIdentity, authState, currentUser); useSeatSync(authIdentity, billingEnabled); diff --git a/packages/ui/src/shell/posthogAnalyticsImpl.test.ts b/packages/ui/src/shell/posthogAnalyticsImpl.test.ts index 37e1cc8c6f..cf70761d7c 100644 --- a/packages/ui/src/shell/posthogAnalyticsImpl.test.ts +++ b/packages/ui/src/shell/posthogAnalyticsImpl.test.ts @@ -50,6 +50,18 @@ describe("onFeatureFlagsLoaded", () => { expect(mockPosthog.onFeatureFlags).toHaveBeenCalledWith(cb); }); + it("invokes buffered listener callbacks once at init to read cached flags", async () => { + const { initializePostHog, onFeatureFlagsLoaded } = await loadAnalytics(); + + const cb = vi.fn(); + onFeatureFlagsLoaded(cb); + expect(cb).not.toHaveBeenCalled(); + + initializePostHog(); + + expect(cb).toHaveBeenCalledTimes(1); + }); + it("does not register a buffered listener that unsubscribed before init", async () => { const { initializePostHog, onFeatureFlagsLoaded } = await loadAnalytics(); @@ -60,6 +72,7 @@ describe("onFeatureFlagsLoaded", () => { initializePostHog(); expect(mockPosthog.onFeatureFlags).not.toHaveBeenCalled(); + expect(cb).not.toHaveBeenCalled(); }); it("propagates unsubscribe to PostHog when called after init", async () => { diff --git a/packages/ui/src/shell/posthogAnalyticsImpl.ts b/packages/ui/src/shell/posthogAnalyticsImpl.ts index e4ddbada6d..7acc2f3b2e 100644 --- a/packages/ui/src/shell/posthogAnalyticsImpl.ts +++ b/packages/ui/src/shell/posthogAnalyticsImpl.ts @@ -81,6 +81,9 @@ export function initializePostHog(sessionId?: string) { for (const listener of pendingFlagListeners) { listener.unsubscribe = posthog.onFeatureFlags(listener.callback); + // Fire once so pre-init subscribers pick up locally cached flag values + // immediately instead of waiting for the first /flags network response. + listener.callback(); } pendingFlagListeners.clear(); }