From 9519c2e583ab2036885541e48c1eb71bec121bf7 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Tue, 26 May 2026 12:06:11 +0100 Subject: [PATCH 1/4] feat(analytics): attach app version to all custom events --- .../main/services/posthog-analytics.test.ts | 76 +++++++++++++++++++ .../src/main/services/posthog-analytics.ts | 3 + apps/code/src/renderer/App.tsx | 11 ++- .../code/src/renderer/utils/analytics.test.ts | 19 +++++ apps/code/src/renderer/utils/analytics.ts | 14 ++++ 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 apps/code/src/main/services/posthog-analytics.test.ts diff --git a/apps/code/src/main/services/posthog-analytics.test.ts b/apps/code/src/main/services/posthog-analytics.test.ts new file mode 100644 index 0000000000..d4320c23fb --- /dev/null +++ b/apps/code/src/main/services/posthog-analytics.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCapture = vi.hoisted(() => vi.fn()); +const mockCaptureException = vi.hoisted(() => vi.fn()); +const mockIdentify = vi.hoisted(() => vi.fn()); +const mockShutdown = vi.hoisted(() => vi.fn()); +const MockPostHog = vi.hoisted(() => vi.fn()); + +vi.mock("posthog-node", () => ({ PostHog: MockPostHog })); + +import { + captureException, + initializePostHog, + resetUser, + shutdownPostHog, + trackAppEvent, +} from "./posthog-analytics"; + +describe("posthog-analytics", () => { + beforeEach(() => { + vi.clearAllMocks(); + MockPostHog.mockImplementation(function (this: Record) { + this.capture = mockCapture; + this.captureException = mockCaptureException; + this.identify = mockIdentify; + this.shutdown = mockShutdown; + }); + process.env.VITE_POSTHOG_API_KEY = "test-key"; + resetUser(); + initializePostHog(); + }); + + afterEach(async () => { + await shutdownPostHog(); + }); + + it("includes the app version on every tracked event", () => { + trackAppEvent("app_started"); + + expect(mockCapture).toHaveBeenCalledWith( + expect.objectContaining({ + event: "app_started", + properties: expect.objectContaining({ + team: "posthog-code", + app_version: "0.0.0-test", + }), + }), + ); + }); + + it("lets caller-supplied properties coexist with the app version", () => { + trackAppEvent("app_quit", { reason: "user-initiated" }); + + expect(mockCapture).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + reason: "user-initiated", + app_version: "0.0.0-test", + }), + }), + ); + }); + + it("includes the app version on captured exceptions", () => { + captureException(new Error("boom")); + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.any(String), + expect.objectContaining({ + team: "posthog-code", + app_version: "0.0.0-test", + }), + ); + }); +}); diff --git a/apps/code/src/main/services/posthog-analytics.ts b/apps/code/src/main/services/posthog-analytics.ts index a2756d0903..7b1b170835 100644 --- a/apps/code/src/main/services/posthog-analytics.ts +++ b/apps/code/src/main/services/posthog-analytics.ts @@ -1,4 +1,5 @@ import { PostHog } from "posthog-node"; +import { getAppVersion } from "../utils/env"; let posthogClient: PostHog | null = null; let currentUserId: string | null = null; @@ -46,6 +47,7 @@ export function trackAppEvent( event: eventName, properties: { team: "posthog-code", + app_version: getAppVersion(), ...properties, $process_person_profile: !!currentUserId, }, @@ -94,6 +96,7 @@ export function captureException( const distinctId = currentUserId || "anonymous-app-event"; posthogClient.captureException(error, distinctId, { team: "posthog-code", + app_version: getAppVersion(), ...additionalProperties, }); } diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index c6595e9e27..ff120774e1 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -26,7 +26,7 @@ import { isNotAuthenticatedError } from "@shared/errors"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; -import { initializePostHog, track } from "@utils/analytics"; +import { initializePostHog, registerAppVersion, track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { AnimatePresence, motion } from "framer-motion"; @@ -48,9 +48,16 @@ function App() { const [showTransition, setShowTransition] = useState(false); const wasInMainApp = useRef(isAuthenticated && hasCompletedOnboarding); - // Initialize PostHog analytics + // Initialize PostHog analytics and attach the app version as a super + // property so every event is sliceable by PostHog Code version. useEffect(() => { initializePostHog(); + trpcClient.os.getAppVersion + .query() + .then(registerAppVersion) + .catch((error) => { + log.warn("Failed to register app version super property", { error }); + }); }, []); // Initialize connectivity monitoring diff --git a/apps/code/src/renderer/utils/analytics.test.ts b/apps/code/src/renderer/utils/analytics.test.ts index bf9f51289b..421ac5ff5f 100644 --- a/apps/code/src/renderer/utils/analytics.test.ts +++ b/apps/code/src/renderer/utils/analytics.test.ts @@ -91,6 +91,25 @@ describe("onFeatureFlagsLoaded", () => { }); }); +describe("registerAppVersion", () => { + it("registers app_version as a super property after init", async () => { + const { initializePostHog, registerAppVersion } = await loadAnalytics(); + + initializePostHog(); + registerAppVersion("1.2.3"); + + expect(mockPosthog.register).toHaveBeenCalledWith({ app_version: "1.2.3" }); + }); + + it("does nothing before init", async () => { + const { registerAppVersion } = await loadAnalytics(); + + registerAppVersion("1.2.3"); + + expect(mockPosthog.register).not.toHaveBeenCalled(); + }); +}); + describe("initializePostHog", () => { it("is idempotent across repeat calls", async () => { const { initializePostHog } = await loadAnalytics(); diff --git a/apps/code/src/renderer/utils/analytics.ts b/apps/code/src/renderer/utils/analytics.ts index 9ea3330d43..659e98b749 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/apps/code/src/renderer/utils/analytics.ts @@ -102,6 +102,20 @@ export function startSessionRecording() { }, 1000); } +/** + * Register the PostHog Code version as a super property so it is attached to + * every subsequently captured event. This lets us slice analytics by app + * version — e.g. to spot users stuck on old versions or failing auto-updates. + * The version is fetched from the main process at startup (see App.tsx). + */ +export function registerAppVersion(appVersion: string) { + if (!isInitialized) { + return; + } + + posthog.register({ app_version: appVersion }); +} + export function identifyUser( userId: string, properties?: UserIdentifyProperties, From 9065d1d45d04f912a089705b3424c08d4fe7a1b9 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Tue, 26 May 2026 14:57:28 +0100 Subject: [PATCH 2/4] fix(analytics): make app_version authoritative and survive reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #2372: - Move app_version after the properties spread in trackAppEvent and captureException so a caller-supplied app_version can never override the real runtime version. - Re-register team + app_version super properties after posthog.reset() (logout), which otherwise wipes them — events after a logout/login cycle now keep their version tag without an app restart. Adds regression tests for both behaviours. --- .../main/services/posthog-analytics.test.ts | 24 +++++++++++++++ .../src/main/services/posthog-analytics.ts | 4 +-- .../code/src/renderer/utils/analytics.test.ts | 16 ++++++++++ apps/code/src/renderer/utils/analytics.ts | 29 +++++++++++++++++-- 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/apps/code/src/main/services/posthog-analytics.test.ts b/apps/code/src/main/services/posthog-analytics.test.ts index d4320c23fb..64d746d40f 100644 --- a/apps/code/src/main/services/posthog-analytics.test.ts +++ b/apps/code/src/main/services/posthog-analytics.test.ts @@ -61,6 +61,18 @@ describe("posthog-analytics", () => { ); }); + it("does not let caller-supplied app_version override the system value", () => { + trackAppEvent("app_quit", { app_version: "spoofed" }); + + expect(mockCapture).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + app_version: "0.0.0-test", + }), + }), + ); + }); + it("includes the app version on captured exceptions", () => { captureException(new Error("boom")); @@ -73,4 +85,16 @@ describe("posthog-analytics", () => { }), ); }); + + it("does not let additionalProperties override app_version on exceptions", () => { + captureException(new Error("boom"), { app_version: "spoofed" }); + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.any(String), + expect.objectContaining({ + app_version: "0.0.0-test", + }), + ); + }); }); diff --git a/apps/code/src/main/services/posthog-analytics.ts b/apps/code/src/main/services/posthog-analytics.ts index 7b1b170835..6eb43841e3 100644 --- a/apps/code/src/main/services/posthog-analytics.ts +++ b/apps/code/src/main/services/posthog-analytics.ts @@ -47,8 +47,8 @@ export function trackAppEvent( event: eventName, properties: { team: "posthog-code", - app_version: getAppVersion(), ...properties, + app_version: getAppVersion(), $process_person_profile: !!currentUserId, }, }); @@ -96,7 +96,7 @@ export function captureException( const distinctId = currentUserId || "anonymous-app-event"; posthogClient.captureException(error, distinctId, { team: "posthog-code", - app_version: getAppVersion(), ...additionalProperties, + app_version: getAppVersion(), }); } diff --git a/apps/code/src/renderer/utils/analytics.test.ts b/apps/code/src/renderer/utils/analytics.test.ts index 421ac5ff5f..3a56c8edde 100644 --- a/apps/code/src/renderer/utils/analytics.test.ts +++ b/apps/code/src/renderer/utils/analytics.test.ts @@ -108,6 +108,22 @@ describe("registerAppVersion", () => { expect(mockPosthog.register).not.toHaveBeenCalled(); }); + + it("re-registers app_version after resetUser clears super properties", async () => { + const { initializePostHog, registerAppVersion, resetUser } = + await loadAnalytics(); + + initializePostHog(); + registerAppVersion("1.2.3"); + + resetUser(); + + expect(mockPosthog.reset).toHaveBeenCalledTimes(1); + expect(mockPosthog.register).toHaveBeenLastCalledWith({ + team: "posthog-code", + app_version: "1.2.3", + }); + }); }); describe("initializePostHog", () => { diff --git a/apps/code/src/renderer/utils/analytics.ts b/apps/code/src/renderer/utils/analytics.ts index 659e98b749..aec5c66842 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/apps/code/src/renderer/utils/analytics.ts @@ -14,6 +14,23 @@ const log = logger.scope("analytics"); let isInitialized = false; +// The app version fetched from the main process at startup. Cached so it can be +// re-applied after `posthog.reset()` (logout), which clears all super +// properties — see registerPersistentSuperProperties / resetUser. +let registeredAppVersion: string | null = null; + +// Super properties that must ride along on every event for the lifetime of the +// app, including across logout/login cycles. `posthog.reset()` wipes all super +// properties, so these are re-registered after each reset. +function registerPersistentSuperProperties() { + posthog.register({ + team: "posthog-code", + ...(registeredAppVersion !== null + ? { app_version: registeredAppVersion } + : {}), + }); +} + type PendingFlagListener = { callback: () => void; unsubscribe: (() => void) | null; @@ -49,10 +66,10 @@ export function initializePostHog() { }, }); - posthog.register({ team: "posthog-code" }); - isInitialized = true; + registerPersistentSuperProperties(); + for (const listener of pendingFlagListeners) { listener.unsubscribe = posthog.onFeatureFlags(listener.callback); } @@ -109,6 +126,10 @@ export function startSessionRecording() { * The version is fetched from the main process at startup (see App.tsx). */ export function registerAppVersion(appVersion: string) { + // Cache regardless of init state so it survives a later reset and gets + // re-applied by registerPersistentSuperProperties. + registeredAppVersion = appVersion; + if (!isInitialized) { return; } @@ -160,6 +181,10 @@ export function resetUser() { } posthog.reset(); + + // reset() clears all super properties; re-apply the ones that must persist + // across logout/login cycles (team + app_version). + registerPersistentSuperProperties(); } export function track( From f532674a538b3094e15ddfcc92a0abf8e6681d09 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Tue, 26 May 2026 15:14:37 +0100 Subject: [PATCH 3/4] chore(analytics): trim verbose comments per review --- apps/code/src/renderer/App.tsx | 3 +-- apps/code/src/renderer/utils/analytics.ts | 20 ++++---------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index ff120774e1..9582056596 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -48,8 +48,7 @@ function App() { const [showTransition, setShowTransition] = useState(false); const wasInMainApp = useRef(isAuthenticated && hasCompletedOnboarding); - // Initialize PostHog analytics and attach the app version as a super - // property so every event is sliceable by PostHog Code version. + // Initialize PostHog analytics and register the app version super property. useEffect(() => { initializePostHog(); trpcClient.os.getAppVersion diff --git a/apps/code/src/renderer/utils/analytics.ts b/apps/code/src/renderer/utils/analytics.ts index aec5c66842..b4b2414cee 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/apps/code/src/renderer/utils/analytics.ts @@ -14,14 +14,10 @@ const log = logger.scope("analytics"); let isInitialized = false; -// The app version fetched from the main process at startup. Cached so it can be -// re-applied after `posthog.reset()` (logout), which clears all super -// properties — see registerPersistentSuperProperties / resetUser. +// Cached so it can be re-applied after posthog.reset() clears super properties. let registeredAppVersion: string | null = null; -// Super properties that must ride along on every event for the lifetime of the -// app, including across logout/login cycles. `posthog.reset()` wipes all super -// properties, so these are re-registered after each reset. +// posthog.reset() wipes super properties, so these are re-registered after each reset. function registerPersistentSuperProperties() { posthog.register({ team: "posthog-code", @@ -119,15 +115,8 @@ export function startSessionRecording() { }, 1000); } -/** - * Register the PostHog Code version as a super property so it is attached to - * every subsequently captured event. This lets us slice analytics by app - * version — e.g. to spot users stuck on old versions or failing auto-updates. - * The version is fetched from the main process at startup (see App.tsx). - */ +// Register the app version as a super property so it rides along on every event. export function registerAppVersion(appVersion: string) { - // Cache regardless of init state so it survives a later reset and gets - // re-applied by registerPersistentSuperProperties. registeredAppVersion = appVersion; if (!isInitialized) { @@ -182,8 +171,7 @@ export function resetUser() { posthog.reset(); - // reset() clears all super properties; re-apply the ones that must persist - // across logout/login cycles (team + app_version). + // reset() clears super properties; re-apply the persistent ones. registerPersistentSuperProperties(); } From 250feaa48e5871a962f5272b92d2072a4dc98e18 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Tue, 26 May 2026 19:49:05 -0700 Subject: [PATCH 4/4] Update vite-plugin-auto-services.ts --- apps/code/vite-plugin-auto-services.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/code/vite-plugin-auto-services.ts b/apps/code/vite-plugin-auto-services.ts index 522ca3e18b..1079eab896 100644 --- a/apps/code/vite-plugin-auto-services.ts +++ b/apps/code/vite-plugin-auto-services.ts @@ -14,6 +14,8 @@ export function autoServicesPlugin(servicesDir: string): Plugin { (f) => f.endsWith(".ts") && !f.endsWith(".types.ts") && + !f.endsWith(".test.ts") && + !f.endsWith(".spec.ts") && f !== "index.ts" && f !== "types.ts", );