diff --git a/apps/code/src/renderer/utils/analytics.test.ts b/apps/code/src/renderer/utils/analytics.test.ts new file mode 100644 index 000000000..bf9f51289 --- /dev/null +++ b/apps/code/src/renderer/utils/analytics.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockPosthog = { + init: vi.fn(), + register: vi.fn(), + onFeatureFlags: vi.fn(), + isFeatureEnabled: vi.fn(), + startSessionRecording: vi.fn(), + capture: vi.fn(), + identify: vi.fn(), + group: vi.fn(), + reset: vi.fn(), + captureException: vi.fn(), + reloadFeatureFlags: vi.fn(), +}; + +vi.mock("posthog-js/dist/module.full.no-external", () => ({ + default: mockPosthog, +})); + +vi.mock("posthog-js/dist/posthog-recorder", () => ({})); + +async function loadAnalytics() { + vi.resetModules(); + return await import("./analytics"); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("VITE_POSTHOG_API_KEY", "test-key"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("onFeatureFlagsLoaded", () => { + it("delivers pre-init subscribers when init runs", async () => { + const { initializePostHog, onFeatureFlagsLoaded } = await loadAnalytics(); + + const cb = vi.fn(); + onFeatureFlagsLoaded(cb); + + expect(mockPosthog.onFeatureFlags).not.toHaveBeenCalled(); + + initializePostHog(); + + expect(mockPosthog.onFeatureFlags).toHaveBeenCalledTimes(1); + expect(mockPosthog.onFeatureFlags).toHaveBeenCalledWith(cb); + }); + + it("does not register a buffered listener that unsubscribed before init", async () => { + const { initializePostHog, onFeatureFlagsLoaded } = await loadAnalytics(); + + const cb = vi.fn(); + const off = onFeatureFlagsLoaded(cb); + off(); + + initializePostHog(); + + expect(mockPosthog.onFeatureFlags).not.toHaveBeenCalled(); + }); + + it("propagates unsubscribe to PostHog when called after init", async () => { + const realUnsub = vi.fn(); + mockPosthog.onFeatureFlags.mockReturnValue(realUnsub); + + const { initializePostHog, onFeatureFlagsLoaded } = await loadAnalytics(); + + const off = onFeatureFlagsLoaded(vi.fn()); + initializePostHog(); + off(); + + expect(realUnsub).toHaveBeenCalledTimes(1); + }); + + it("routes post-init subscribers directly to PostHog", async () => { + const realUnsub = vi.fn(); + mockPosthog.onFeatureFlags.mockReturnValue(realUnsub); + + const { initializePostHog, onFeatureFlagsLoaded } = await loadAnalytics(); + initializePostHog(); + + const cb = vi.fn(); + const off = onFeatureFlagsLoaded(cb); + + expect(mockPosthog.onFeatureFlags).toHaveBeenCalledWith(cb); + + off(); + expect(realUnsub).toHaveBeenCalledTimes(1); + }); +}); + +describe("initializePostHog", () => { + it("is idempotent across repeat calls", async () => { + const { initializePostHog } = await loadAnalytics(); + + initializePostHog(); + initializePostHog(); + + expect(mockPosthog.init).toHaveBeenCalledTimes(1); + }); + + it("does nothing when no API key is set", async () => { + vi.stubEnv("VITE_POSTHOG_API_KEY", ""); + const { initializePostHog, onFeatureFlagsLoaded } = await loadAnalytics(); + + const cb = vi.fn(); + onFeatureFlagsLoaded(cb); + initializePostHog(); + + expect(mockPosthog.init).not.toHaveBeenCalled(); + expect(mockPosthog.onFeatureFlags).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/code/src/renderer/utils/analytics.ts b/apps/code/src/renderer/utils/analytics.ts index 3e106d6a5..9ea3330d4 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/apps/code/src/renderer/utils/analytics.ts @@ -14,6 +14,14 @@ const log = logger.scope("analytics"); let isInitialized = false; +type PendingFlagListener = { + callback: () => void; + unsubscribe: (() => void) | null; +}; + +// Subscribers added before initializePostHog runs. +const pendingFlagListeners = new Set(); + export function initializePostHog() { const apiKey = import.meta.env.VITE_POSTHOG_API_KEY; const apiHost = @@ -44,6 +52,11 @@ export function initializePostHog() { posthog.register({ team: "posthog-code" }); isInitialized = true; + + for (const listener of pendingFlagListeners) { + listener.unsubscribe = posthog.onFeatureFlags(listener.callback); + } + pendingFlagListeners.clear(); } /** @@ -212,11 +225,19 @@ export function isFeatureFlagEnabled(flagKey: string): boolean { * Returns unsubscribe function. */ export function onFeatureFlagsLoaded(callback: () => void): () => void { - if (!isInitialized) { - return () => {}; + if (isInitialized) { + return posthog.onFeatureFlags(callback); } - return posthog.onFeatureFlags(callback); + const listener: PendingFlagListener = { callback, unsubscribe: null }; + pendingFlagListeners.add(listener); + return () => { + if (listener.unsubscribe) { + listener.unsubscribe(); + } else { + pendingFlagListeners.delete(listener); + } + }; } /**