Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/code/src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
6 changes: 5 additions & 1 deletion apps/code/src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }),
},
});
Expand Down
22 changes: 7 additions & 15 deletions apps/code/src/renderer/contributions/app-boot.contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
}
}

Expand Down
5 changes: 4 additions & 1 deletion apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -43,6 +47,5 @@ export function registerDesktopContributions(): void {
container.load(module);
}

container.bind(CONTRIBUTION).to(AnalyticsBootContribution).inSingletonScope();
container.bind(CONTRIBUTION).to(InboxDemoDevContribution).inSingletonScope();
}
1 change: 1 addition & 0 deletions apps/code/src/renderer/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ declare global {
interface Window {
electronUtils?: {
getPathForFile: (file: File) => string;
posthogSessionId?: string;
};
}
}
8 changes: 0 additions & 8 deletions packages/host-router/src/routers/analytics.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ export const analyticsRouter = router({
}
}),

getSessionId: publicProcedure
.output(z.object({ sessionId: z.string() }))
.query(({ ctx }) => ({
sessionId: ctx.container
.get<IAnalytics>(ANALYTICS_SERVICE)
.getOrCreateSessionId(),
})),

resetUser: publicProcedure.mutation(({ ctx }) => {
ctx.container.get<IAnalytics>(ANALYTICS_SERVICE).resetUser();
}),
Expand Down
89 changes: 89 additions & 0 deletions packages/ui/src/features/auth/useAuthSession.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 15 additions & 5 deletions packages/ui/src/features/auth/useAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -56,7 +62,7 @@ function useAuthIdentitySync(
}

useAuthUiStateStore.getState().clearStaleRegion();
}, [authIdentity, cloudRegion, hostClient]);
}, [authIdentity, cloudRegion, hostClient, bootstrapComplete]);
}

function useAuthAnalyticsIdentity(
Expand Down Expand Up @@ -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);

Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/shell/posthogAnalyticsImpl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/shell/posthogAnalyticsImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading