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 000000000..64d746d40 --- /dev/null +++ b/apps/code/src/main/services/posthog-analytics.test.ts @@ -0,0 +1,100 @@ +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("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")); + + expect(mockCaptureException).toHaveBeenCalledWith( + expect.any(Error), + expect.any(String), + expect.objectContaining({ + team: "posthog-code", + app_version: "0.0.0-test", + }), + ); + }); + + 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 a2756d090..6eb43841e 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; @@ -47,6 +48,7 @@ export function trackAppEvent( properties: { team: "posthog-code", ...properties, + app_version: getAppVersion(), $process_person_profile: !!currentUserId, }, }); @@ -95,5 +97,6 @@ export function captureException( posthogClient.captureException(error, distinctId, { team: "posthog-code", ...additionalProperties, + app_version: getAppVersion(), }); } diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index c6595e9e2..958205659 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,15 @@ function App() { const [showTransition, setShowTransition] = useState(false); const wasInMainApp = useRef(isAuthenticated && hasCompletedOnboarding); - // Initialize PostHog analytics + // Initialize PostHog analytics and register the app version super property. 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 bf9f51289..3a56c8edd 100644 --- a/apps/code/src/renderer/utils/analytics.test.ts +++ b/apps/code/src/renderer/utils/analytics.test.ts @@ -91,6 +91,41 @@ 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(); + }); + + 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", () => { 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 9ea3330d4..b4b2414ce 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/apps/code/src/renderer/utils/analytics.ts @@ -14,6 +14,19 @@ const log = logger.scope("analytics"); let isInitialized = false; +// Cached so it can be re-applied after posthog.reset() clears super properties. +let registeredAppVersion: string | null = null; + +// posthog.reset() wipes 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 +62,10 @@ export function initializePostHog() { }, }); - posthog.register({ team: "posthog-code" }); - isInitialized = true; + registerPersistentSuperProperties(); + for (const listener of pendingFlagListeners) { listener.unsubscribe = posthog.onFeatureFlags(listener.callback); } @@ -102,6 +115,17 @@ export function startSessionRecording() { }, 1000); } +// Register the app version as a super property so it rides along on every event. +export function registerAppVersion(appVersion: string) { + registeredAppVersion = appVersion; + + if (!isInitialized) { + return; + } + + posthog.register({ app_version: appVersion }); +} + export function identifyUser( userId: string, properties?: UserIdentifyProperties, @@ -146,6 +170,9 @@ export function resetUser() { } posthog.reset(); + + // reset() clears super properties; re-apply the persistent ones. + registerPersistentSuperProperties(); } export function track( diff --git a/apps/code/vite-plugin-auto-services.ts b/apps/code/vite-plugin-auto-services.ts index 522ca3e18..1079eab89 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", );