Skip to content
Merged
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
46 changes: 37 additions & 9 deletions apps/admin/src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,26 @@ import { WeekPicker } from "./components/WeekPicker";
export default function DashboardPage() {
const trpc = useTRPC();

const [funnelRange, setFunnelRange] = useState<TimeRange>("-7d");
const [activationFunnelRange, setActivationFunnelRange] =
useState<TimeRange>("-7d");
const [marketingFunnelRange, setMarketingFunnelRange] =
useState<TimeRange>("-7d");
const [signupsRange, setSignupsRange] = useState<TimeRange>("-30d");
const [trafficRange, setTrafficRange] = useState<TimeRange>("-30d");
const [revenueRange, setRevenueRange] = useState<TimeRange>("-30d");
const [wauRange, setWauRange] = useState<TimeRange>("-30d");
const [leaderboardWeekOffset, setLeaderboardWeekOffset] = useState(0);

const fullJourneyFunnel = useQuery(
trpc.analytics.getFullJourneyFunnel.queryOptions({ dateFrom: funnelRange }),
const activationFunnel = useQuery(
trpc.analytics.getActivationFunnel.queryOptions({
dateFrom: activationFunnelRange,
}),
);

const marketingFunnel = useQuery(
trpc.analytics.getMarketingFunnel.queryOptions({
dateFrom: marketingFunnelRange,
}),
);

const wau = useQuery(
Expand Down Expand Up @@ -106,13 +117,30 @@ export default function DashboardPage() {
/>

<FunnelChart
title="Full Journey Funnel"
description="From site visit to terminal usage"
data={fullJourneyFunnel.data}
isLoading={fullJourneyFunnel.isLoading}
error={fullJourneyFunnel.error}
title="Activation Funnel"
description="From app open to workspace creation"
data={activationFunnel.data}
isLoading={activationFunnel.isLoading}
error={activationFunnel.error}
headerAction={
<TimeRangePicker value={funnelRange} onChange={setFunnelRange} />
<TimeRangePicker
value={activationFunnelRange}
onChange={setActivationFunnelRange}
/>
}
/>

<FunnelChart
title="Marketing Funnel"
description="From site visit to app download"
data={marketingFunnel.data}
isLoading={marketingFunnel.isLoading}
error={marketingFunnel.error}
headerAction={
<TimeRangePicker
value={marketingFunnelRange}
onChange={setMarketingFunnelRange}
/>
}
/>

Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/lib/trpc/routers/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { setUserId } from "main/lib/analytics";
import { z } from "zod";
import { publicProcedure, router } from "../..";

export const createAnalyticsRouter = () => {
return router({
setUserId: publicProcedure
.input(z.object({ userId: z.string().nullable() }))
.mutation(({ input }) => {
setUserId(input.userId);
}),
});
};

export type AnalyticsRouter = ReturnType<typeof createAnalyticsRouter>;
2 changes: 0 additions & 2 deletions apps/desktop/src/lib/trpc/routers/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { observable } from "@trpc/server/observable";
import type { BrowserWindow } from "electron";
import { clearUserCache } from "main/lib/analytics";
import { authService } from "main/lib/auth";
import { AUTH_PROVIDERS } from "shared/auth";
import { z } from "zod";
Expand Down Expand Up @@ -54,7 +53,6 @@ export const createAuthRouter = (getWindow: () => BrowserWindow | null) => {
*/
signOut: publicProcedure.mutation(async () => {
await authService.signOut();
clearUserCache();
return { success: true };
}),
});
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { BrowserWindow } from "electron";
import { router } from "..";
import { createAnalyticsRouter } from "./analytics";
import { createAuthRouter } from "./auth";
import { createChangesRouter } from "./changes";
import { createConfigRouter } from "./config";
Expand All @@ -25,6 +26,7 @@ import { createWorkspacesRouter } from "./workspaces";
*/
export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
return router({
analytics: createAnalyticsRouter(),
auth: createAuthRouter(getWindow),
user: createUserRouter(),
window: createWindowRouter(getWindow),
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
const defaultBranch = await getDefaultBranch(mainRepoPath);
const project = upsertProject(mainRepoPath, defaultBranch);

track("project_opened", {
project_id: project.id,
method: "open",
});

return {
canceled: false,
project,
Expand Down Expand Up @@ -351,6 +356,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {

const project = upsertProject(input.path, defaultBranch);

track("project_opened", {
project_id: project.id,
method: "init",
});

return { project };
}),

Expand Down Expand Up @@ -420,6 +430,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.set({ lastOpenedAt: Date.now() })
.where(eq(projects.id, existingProject.id))
.run();

track("project_opened", {
project_id: existingProject.id,
method: "clone",
});

return {
canceled: false as const,
success: true as const,
Expand Down Expand Up @@ -462,6 +478,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
.returning()
.get();

track("project_opened", {
project_id: project.id,
method: "clone",
});

return {
canceled: false as const,
success: true as const,
Expand Down
5 changes: 2 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { app, BrowserWindow } from "electron";
import { makeAppSetup } from "lib/electron-app/factories/app/setup";
import { PROTOCOL_SCHEME } from "shared/constants";
import { setupAgentHooks } from "./lib/agent-setup";
import { shutdown as shutdownAnalytics, track } from "./lib/analytics";
import { posthog } from "./lib/analytics";
import { initAppState } from "./lib/app-state";
import { authService, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth";
import { setupAutoUpdater } from "./lib/auto-updater";
Expand Down Expand Up @@ -56,7 +56,6 @@ async function processDeepLink(url: string): Promise<void> {
refreshTokenExpiresAt: result.refreshTokenExpiresAt,
state: result.state,
});
track("auth_completed");
focusMainWindow();
} else {
console.error("[main] Auth deep link failed:", result.error);
Expand Down Expand Up @@ -148,7 +147,7 @@ if (!gotTheLock) {

// Clean up all terminals and analytics when app is quitting
app.on("before-quit", async () => {
await Promise.all([terminalManager.cleanup(), shutdownAnalytics()]);
await Promise.all([terminalManager.cleanup(), posthog?.shutdown()]);
});
})();
}
73 changes: 22 additions & 51 deletions apps/desktop/src/main/lib/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,44 @@
import { env } from "main/env.main";
import { apiClient } from "main/lib/api-client";
import { PostHog } from "posthog-node";

let client: PostHog | null = null;
let cachedUserId: string | null = null;
export let posthog: PostHog | null = null;
let userId: string | null = null;

function getClient(): PostHog | null {
if (!env.NEXT_PUBLIC_POSTHOG_KEY) {
return null;
}

if (!client) {
client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
if (!posthog) {
posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1, // Send events immediately for desktop app
flushAt: 1,
flushInterval: 0,
Comment on lines +15 to 16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n apps/desktop/src/main/lib/analytics/index.ts

Repository: superset-sh/superset

Length of output: 1181


🏁 Script executed:

# Search for any configuration or environment-based settings for PostHog
rg -i "flush|posthog" apps/desktop/src/main/lib/analytics/ -A 3 -B 3

Repository: superset-sh/superset

Length of output: 1533


🏁 Script executed:

# Check if there are environment-specific configurations
rg "NODE_ENV|process.env" apps/desktop/src/main/lib/analytics/ -A 2 -B 2

Repository: superset-sh/superset

Length of output: 46


🌐 Web query:

PostHog Electron desktop application recommended flush settings production

💡 Result:

Short answer

  • Use the SDK defaults for a desktop Electron app: flushAt = 20 and flushInterval = 10000 ms (10s). These are the sane production defaults for long‑running apps. [1][2]

When you might change them

  • Make flushAt smaller (eg. 1) and flushInterval = 0 only for short‑lived/serverless contexts to avoid losing events. [2]
  • If you want fewer network calls (reduce bandwidth/battery), increase flushAt (e.g. 50) and/or flushInterval (e.g. 60000 ms). If you need near‑real‑time events, lower flushAt or flushInterval. [1][2]

References

  • PostHog Node SDK options (defaults & serverless guidance). [1]
  • PostHog library docs showing flushAt/flushInterval settings and description. [2]

Sources
[1] PostHog Node.js docs — options and serverless recommendations.
[2] PostHog library docs (flushInterval / flushAt description).


Use PostHog's default flush settings for long-running desktop applications.

flushAt: 1 and flushInterval: 0 cause every event to be flushed immediately. PostHog recommends these settings only for short-lived/serverless contexts. Since this is an Electron desktop application (a long-running process), use the SDK defaults: flushAt: 20 and flushInterval: 10000 (10 seconds). The current settings will create unnecessary network calls on every tracking event, impacting performance and battery life in production.

If different settings are needed for testing, configure them based on the environment rather than hardcoding aggressive values.

🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/analytics/index.ts around lines 15-16, the PostHog
client is configured with flushAt: 1 and flushInterval: 0 which forces immediate
flushes; change these to use PostHog SDK defaults for long-running desktop apps
(flushAt: 20 and flushInterval: 10000) and make the values
environment-configurable so tests/dev can override (e.g., read from env or a
config flag and fall back to defaults) to avoid per-event network calls in
production.

});
}
return client;
return posthog;
Comment on lines +12 to +19
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential race condition in PostHog client initialization.

If getClient() is called concurrently before posthog is initialized, multiple PostHog instances could be created. While this is unlikely in a single-threaded Node.js environment, consider initializing the client once during module load or using a more explicit initialization pattern.

🔎 Alternative initialization pattern
-export let posthog: PostHog | null = null;
+export let posthog: PostHog | null = initializePostHog();
 let userId: string | null = null;

-function getClient(): PostHog | null {
+function initializePostHog(): PostHog | null {
 	if (!env.NEXT_PUBLIC_POSTHOG_KEY) {
 		return null;
 	}
-
-	if (!posthog) {
-		posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
-			host: env.NEXT_PUBLIC_POSTHOG_HOST,
-			flushAt: 1,
-			flushInterval: 0,
-		});
-	}
-	return posthog;
+	return new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
+		host: env.NEXT_PUBLIC_POSTHOG_HOST,
+		flushAt: 1,
+		flushInterval: 0,
+	});
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!posthog) {
posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1, // Send events immediately for desktop app
flushAt: 1,
flushInterval: 0,
});
}
return client;
return posthog;
export let posthog: PostHog | null = initializePostHog();
let userId: string | null = null;
function initializePostHog(): PostHog | null {
if (!env.NEXT_PUBLIC_POSTHOG_KEY) {
return null;
}
return new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
flushAt: 1,
flushInterval: 0,
});
}
function setUserId(id: string | null): void {
userId = id;
if (posthog && id) {
posthog.identify(id);
}
}
function track(event: string, properties?: Record<string, unknown>): void {
if (!posthog || !userId) {
return;
}
posthog.capture({
distinctId: userId,
event,
properties,
});
}
🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/analytics/index.ts around lines 12-19, the lazy
getClient() code can create multiple PostHog instances if called concurrently;
replace the lazy unchecked init with a single-module initialization or an
explicit init/get pattern: either instantiate posthog once at module load using
env.NEXT_PUBLIC_POSTHOG_KEY and env.NEXT_PUBLIC_POSTHOG_HOST, or add a
module-level initPromise and an init(env) function that creates the PostHog
client exactly once (store the instance on success and have getClient await
initPromise), ensuring concurrent callers share the same in-flight
initialization and only one PostHog instance is created.

}

async function getUserId(): Promise<string | null> {
if (cachedUserId) return cachedUserId;
try {
const user = await apiClient.user.me.query();
cachedUserId = user?.id ?? null;
return cachedUserId;
} catch {
return null;
}
}

/**
* Clear cached user ID (call on sign out)
*/
export function clearUserCache(): void {
cachedUserId = null;
export function setUserId(id: string | null): void {
userId = id;
}

/**
* Track an event with the current user's ID as distinct_id.
* Fire-and-forget - errors are silently ignored.
*/
export function track(
event: string,
properties?: Record<string, unknown>,
): void {
const posthog = getClient();
if (!posthog) return;

getUserId()
.then((userId) => {
if (!userId) return;
posthog.capture({
distinctId: userId,
event,
properties: {
...properties,
app_name: "desktop",
platform: process.platform,
},
});
})
.catch(() => {});
}

/**
* Shutdown PostHog client (call on app quit)
*/
export async function shutdown(): Promise<void> {
await client?.shutdown();
if (!userId) return;

const client = getClient();
if (!client) return;

client.capture({
distinctId: userId,
event,
properties: {
...properties,
app_name: "desktop",
platform: process.platform,
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,28 @@ import { trpc } from "renderer/lib/trpc";

import { posthog } from "../../lib/posthog";

const AUTH_COMPLETED_KEY = "superset_auth_completed";

export function PostHogUserIdentifier() {
const { data: user, isSuccess } = trpc.user.me.useQuery();
const { mutate: setUserId } = trpc.analytics.setUserId.useMutation();

useEffect(() => {
if (user) {
posthog.identify(user.id, { email: user.email, name: user.name });
setUserId({ userId: user.id });

const trackedUserId = localStorage.getItem(AUTH_COMPLETED_KEY);
if (trackedUserId !== user.id) {
posthog.capture("auth_completed");
localStorage.setItem(AUTH_COMPLETED_KEY, user.id);
}
} else if (isSuccess) {
posthog.reset();
setUserId({ userId: null });
localStorage.removeItem(AUTH_COMPLETED_KEY);
}
}, [user, isSuccess]);
}, [user, isSuccess, setUserId]);

return null;
}
Loading