diff --git a/bun.lock b/bun.lock index 8fe559d08f..7e1786ecaf 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "mux", @@ -50,6 +49,7 @@ "ollama-ai-provider-v2": "^1.5.4", "openai": "^6.9.1", "parse-duration": "^2.1.4", + "posthog-node": "^5.17.0", "rehype-harden": "^1.1.5", "shescape": "^2.1.6", "source-map-support": "^0.5.21", @@ -3003,6 +3003,8 @@ "posthog-js": ["posthog-js@1.299.0", "", { "dependencies": { "@posthog/core": "1.6.0", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-euHXKcEqQpRJNWitudVl4/doTJsftgaBDRLNGczt/v3S9N6ppLMzEOmeoqvNhNDIlpxGVlTvSawfw9HeW1r5nA=="], + "posthog-node": ["posthog-node@5.17.0", "", { "dependencies": { "@posthog/core": "1.7.0" } }, "sha512-M+ftj0kLJk6wVF1xW5cStSany0LBC6YDVO7RPma2poo+PrpeiTk+ovhqcIqWAySDdTcBHJfBV9aIFYWPl2y6kg=="], + "preact": ["preact@10.28.0", "", {}, "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -4085,6 +4087,8 @@ "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "posthog-node/@posthog/core": ["@posthog/core@1.7.0", "", { "dependencies": { "cross-spawn": "^7.0.6" } }, "sha512-d6ZV4grpzeH/6/LP8quMVpSjY1puRkrqfwcPvGRKUAX7tb7YHyp/zMiTDuJmOFbpUxAMBXH5nDwcPiyCY2WGzA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/docs/telemetry.md b/docs/telemetry.md index 08063314e1..692f49a840 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -4,11 +4,10 @@ mux collects anonymous usage telemetry to help us understand how the product is ## Privacy Policy -- **Opt-out by default**: You can disable telemetry at any time - **No personal information**: We never collect usernames, project names, file paths, or code content - **Random IDs only**: Only randomly-generated workspace IDs are sent (impossible to trace back to you) - **No hashing**: We don't hash sensitive data because hashing is vulnerable to rainbow table attacks -- **Transparent data**: See exactly what data structures we send in [`src/telemetry/payload.ts`](https://github.com/coder/mux/blob/main/src/telemetry/payload.ts) +- **Transparent data**: See exactly what data structures we send in [`src/common/telemetry/payload.ts`](https://github.com/coder/mux/blob/main/src/common/telemetry/payload.ts) ## What We Track @@ -36,26 +35,19 @@ All telemetry events include basic system information: ## Disabling Telemetry -You can disable telemetry at any time using the `/telemetry` slash command: +To disable telemetry, set the `MUX_DISABLE_TELEMETRY` environment variable before starting the app: -``` -/telemetry off -``` - -To re-enable it: - -``` -/telemetry on +```bash +MUX_DISABLE_TELEMETRY=1 mux ``` -Your preference is saved and persists across app restarts. +This completely disables all telemetry collection at the backend level. ## Source Code For complete transparency, you can review the telemetry implementation: -- **Payload definitions**: [`src/telemetry/payload.ts`](https://github.com/coder/mux/blob/main/src/telemetry/payload.ts) - All data structures we send -- **Client code**: [`src/telemetry/client.ts`](https://github.com/coder/mux/blob/main/src/telemetry/client.ts) - How telemetry is sent -- **Privacy utilities**: [`src/telemetry/utils.ts`](https://github.com/coder/mux/blob/main/src/telemetry/utils.ts) - Base-2 rounding and helpers - -The telemetry system includes debug logging that you can see in the developer console (View → Toggle Developer Tools). +- **Payload definitions**: [`src/common/telemetry/payload.ts`](https://github.com/coder/mux/blob/main/src/common/telemetry/payload.ts) - All data structures we send +- **Backend service**: [`src/node/services/telemetryService.ts`](https://github.com/coder/mux/blob/main/src/node/services/telemetryService.ts) - Server-side telemetry handling +- **Frontend client**: [`src/common/telemetry/client.ts`](https://github.com/coder/mux/blob/main/src/common/telemetry/client.ts) - Frontend to backend relay +- **Privacy utilities**: [`src/common/telemetry/utils.ts`](https://github.com/coder/mux/blob/main/src/common/telemetry/utils.ts) - Base-2 rounding helper diff --git a/package.json b/package.json index 52ed6935a2..84886417dd 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "ollama-ai-provider-v2": "^1.5.4", "openai": "^6.9.1", "parse-duration": "^2.1.4", + "posthog-node": "^5.17.0", "rehype-harden": "^1.1.5", "shescape": "^2.1.6", "source-map-support": "^0.5.21", diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 02abe3e7ac..c3d1d3c077 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -63,7 +63,7 @@ import type { ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; import { MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels"; import { useTelemetry } from "@/browser/hooks/useTelemetry"; -import { setTelemetryEnabled } from "@/common/telemetry"; + import { getTokenCountPromise } from "@/browser/utils/tokenizer/rendererClient"; import { CreationCenterContent } from "./CreationCenterContent"; import { cn } from "@/common/lib/utils"; @@ -726,18 +726,6 @@ export const ChatInput: React.FC = (props) => { return; } - // Handle /telemetry command - if (parsed.type === "telemetry-set") { - setInput(""); // Clear input immediately - setTelemetryEnabled(parsed.enabled); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Telemetry ${parsed.enabled ? "enabled" : "disabled"}`, - }); - return; - } - // Handle /compact command if (parsed.type === "compact") { if (!api) { diff --git a/src/browser/components/ChatInputToasts.tsx b/src/browser/components/ChatInputToasts.tsx index 3276f3e610..76402f1934 100644 --- a/src/browser/components/ChatInputToasts.tsx +++ b/src/browser/components/ChatInputToasts.tsx @@ -88,26 +88,6 @@ export const createCommandToast = (parsed: ParsedCommand): Toast | null => { ), }; - case "telemetry-help": - return { - id: Date.now().toString(), - type: "error", - title: "Telemetry Command", - message: "Enable or disable usage telemetry", - solution: ( - <> - Usage: - /telemetry <on|off> -
-
- Examples: - /telemetry off -
- /telemetry on - - ), - }; - case "fork-help": return { id: Date.now().toString(), diff --git a/src/browser/components/TitleBar.tsx b/src/browser/components/TitleBar.tsx index 617e25df30..5b829ccd67 100644 --- a/src/browser/components/TitleBar.tsx +++ b/src/browser/components/TitleBar.tsx @@ -4,7 +4,7 @@ import { VERSION } from "@/version"; import { SettingsButton } from "./SettingsButton"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { UpdateStatus } from "@/common/orpc/types"; -import { isTelemetryEnabled } from "@/common/telemetry"; + import { useTutorial } from "@/browser/contexts/TutorialContext"; import { useAPI } from "@/browser/contexts/API"; @@ -80,7 +80,7 @@ export function TitleBar() { const [updateStatus, setUpdateStatus] = useState({ type: "idle" }); const [isCheckingOnHover, setIsCheckingOnHover] = useState(false); const lastHoverCheckTime = useRef(0); - const telemetryEnabled = isTelemetryEnabled(); + const { startSequence } = useTutorial(); // Start settings tutorial on first launch @@ -93,11 +93,6 @@ export function TitleBar() { }, [startSequence]); useEffect(() => { - // Skip update checks if telemetry is disabled - if (!telemetryEnabled) { - return; - } - // Skip update checks in browser mode - app updates only apply to Electron if (!window.api) { return; @@ -134,11 +129,9 @@ export function TitleBar() { controller.abort(); clearInterval(checkInterval); }; - }, [telemetryEnabled, api]); + }, [api]); const handleIndicatorHover = () => { - if (!telemetryEnabled) return; - // Debounce: Only check once per cooldown period on hover const now = Date.now(); @@ -161,8 +154,6 @@ export function TitleBar() { }; const handleUpdateClick = () => { - if (!telemetryEnabled) return; // No-op if telemetry disabled - if (updateStatus.type === "available") { api?.update.download().catch(console.error); } else if (updateStatus.type === "downloaded") { @@ -174,12 +165,7 @@ export function TitleBar() { const currentVersion = gitDescribe ?? "dev"; const lines: React.ReactNode[] = [`Current: ${currentVersion}`]; - if (!telemetryEnabled) { - lines.push( - "Update checks disabled (telemetry is off)", - "Enable telemetry to receive updates." - ); - } else if (isCheckingOnHover || updateStatus.type === "checking") { + if (isCheckingOnHover || updateStatus.type === "checking") { lines.push("Checking for updates..."); } else { switch (updateStatus.type) { @@ -224,8 +210,6 @@ export function TitleBar() { }; const getIndicatorStatus = (): "available" | "downloading" | "downloaded" | "disabled" => { - if (!telemetryEnabled) return "disabled"; - if (isCheckingOnHover || updateStatus.type === "checking") return "disabled"; switch (updateStatus.type) { diff --git a/src/browser/hooks/useTelemetry.ts b/src/browser/hooks/useTelemetry.ts index f2e14bacc1..a807be0a58 100644 --- a/src/browser/hooks/useTelemetry.ts +++ b/src/browser/hooks/useTelemetry.ts @@ -1,11 +1,13 @@ import { useCallback } from "react"; -import { trackEvent, getBaseTelemetryProperties, roundToBase2 } from "@/common/telemetry"; +import { trackEvent, roundToBase2 } from "@/common/telemetry"; import type { ErrorContext } from "@/common/telemetry/payload"; /** * Hook for clean telemetry integration in React components * - * Provides type-safe telemetry tracking with base properties automatically included. + * Provides type-safe telemetry tracking. Base properties (version, platform, etc.) + * are automatically added by the backend TelemetryService. + * * Usage: * * ```tsx @@ -30,7 +32,6 @@ export function useTelemetry() { trackEvent({ event: "workspace_switched", properties: { - ...getBaseTelemetryProperties(), fromWorkspaceId, toWorkspaceId, }, @@ -42,7 +43,6 @@ export function useTelemetry() { trackEvent({ event: "workspace_created", properties: { - ...getBaseTelemetryProperties(), workspaceId, }, }); @@ -53,7 +53,6 @@ export function useTelemetry() { trackEvent({ event: "message_sent", properties: { - ...getBaseTelemetryProperties(), model, mode, message_length_b2: roundToBase2(messageLength), @@ -66,7 +65,6 @@ export function useTelemetry() { trackEvent({ event: "error_occurred", properties: { - ...getBaseTelemetryProperties(), errorType, context, }, diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index f7fbdaab40..2632049593 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -32,7 +32,6 @@ import { DEFAULT_COMPACTION_WORD_TARGET, WORDS_TO_TOKENS_RATIO } from "@/common/ // ============================================================================ import { createCommandToast } from "@/browser/components/ChatInputToasts"; -import { setTelemetryEnabled } from "@/common/telemetry"; export interface ForkOptions { client: RouterClient; @@ -227,17 +226,6 @@ export async function processSlashCommand( return { clearInput: true, toastShown: false }; } - if (parsed.type === "telemetry-set") { - setInput(""); - setTelemetryEnabled(parsed.enabled); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Telemetry ${parsed.enabled ? "enabled" : "disabled"}`, - }); - return { clearInput: true, toastShown: true }; - } - // 2. Workspace Commands const workspaceCommands = ["clear", "truncate", "compact", "fork", "new"]; const isWorkspaceCommand = workspaceCommands.includes(parsed.type); diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index 3110ee6a1f..975d332505 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -456,46 +456,6 @@ const vimCommandDefinition: SlashCommandDefinition = { }, }; -const telemetryCommandDefinition: SlashCommandDefinition = { - key: "telemetry", - description: "Enable or disable telemetry", - handler: ({ cleanRemainingTokens }): ParsedCommand => { - if (cleanRemainingTokens.length === 0) { - return { type: "telemetry-help" }; - } - - if (cleanRemainingTokens.length === 1) { - const arg = cleanRemainingTokens[0].toLowerCase(); - if (arg === "on" || arg === "off") { - return { type: "telemetry-set", enabled: arg === "on" }; - } - } - - return { - type: "unknown-command", - command: "telemetry", - subcommand: cleanRemainingTokens[0], - }; - }, - suggestions: ({ stage, partialToken }) => { - if (stage === 1) { - const options = [ - { key: "on", description: "Enable telemetry" }, - { key: "off", description: "Disable telemetry" }, - ]; - - return filterAndMapSuggestions(options, partialToken, (definition) => ({ - id: `command:telemetry:${definition.key}`, - display: definition.key, - description: definition.description, - replacement: `/telemetry ${definition.key}`, - })); - } - - return null; - }, -}; - const forkCommandDefinition: SlashCommandDefinition = { key: "fork", description: @@ -631,7 +591,7 @@ export const SLASH_COMMAND_DEFINITIONS: readonly SlashCommandDefinition[] = [ compactCommandDefinition, modelCommandDefinition, providersCommandDefinition, - telemetryCommandDefinition, + forkCommandDefinition, newCommandDefinition, vimCommandDefinition, diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index 00c7f64fee..f4a8627564 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -19,8 +19,6 @@ export type ParsedCommand = | { type: "clear" } | { type: "truncate"; percentage: number } | { type: "compact"; maxOutputTokens?: number; continueMessage?: string; model?: string } - | { type: "telemetry-set"; enabled: boolean } - | { type: "telemetry-help" } | { type: "fork"; newName: string; startMessage?: string } | { type: "fork-help" } | { diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 58ca6f1973..1cffe05d1c 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -67,6 +67,7 @@ async function createTestServer(authToken?: string): Promise { serverService: services.serverService, menuEventService: services.menuEventService, voiceService: services.voiceService, + telemetryService: services.telemetryService, }; // Use the actual createOrpcServer function diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 336ae9fac6..045a6d8741 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -70,6 +70,7 @@ async function createTestServer(): Promise { serverService: services.serverService, menuEventService: services.menuEventService, voiceService: services.voiceService, + telemetryService: services.telemetryService, }; // Use the actual createOrpcServer function diff --git a/src/cli/server.ts b/src/cli/server.ts index cdd1624be1..04d824f01c 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -78,6 +78,7 @@ const mockWindow: BrowserWindow = { serverService: serviceContainer.serverService, menuEventService: serviceContainer.menuEventService, voiceService: serviceContainer.voiceService, + telemetryService: serviceContainer.telemetryService, }; const server = await createOrpcServer({ diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index d9fec88dc7..ef56937083 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -98,6 +98,8 @@ export { providers, ProvidersConfigMapSchema, server, + telemetry, + TelemetryEventSchema, terminal, tokenizer, update, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index b0f21ea049..ea4f48899f 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -16,6 +16,9 @@ import { import { BashToolResultSchema, FileTreeNodeSchema } from "./tools"; import { FrontendWorkspaceMetadataSchema, WorkspaceActivitySnapshotSchema } from "./workspace"; +// Re-export telemetry schemas +export { telemetry, TelemetryEventSchema } from "./telemetry"; + // --- API Router Schemas --- // Tokenizer diff --git a/src/common/orpc/schemas/telemetry.ts b/src/common/orpc/schemas/telemetry.ts new file mode 100644 index 0000000000..3a5edae19f --- /dev/null +++ b/src/common/orpc/schemas/telemetry.ts @@ -0,0 +1,77 @@ +/** + * Telemetry ORPC schemas + * + * Defines input/output schemas for backend telemetry endpoints. + * Telemetry is controlled by MUX_DISABLE_TELEMETRY env var on the backend. + */ + +import { z } from "zod"; + +// Error context enum (matches payload.ts) +const ErrorContextSchema = z.enum([ + "workspace-creation", + "workspace-deletion", + "workspace-switch", + "message-send", + "message-stream", + "project-add", + "project-remove", + "git-operation", +]); + +// Individual event payload schemas +const AppStartedPropertiesSchema = z.object({ + isFirstLaunch: z.boolean(), +}); + +const WorkspaceCreatedPropertiesSchema = z.object({ + workspaceId: z.string(), +}); + +const WorkspaceSwitchedPropertiesSchema = z.object({ + fromWorkspaceId: z.string(), + toWorkspaceId: z.string(), +}); + +const MessageSentPropertiesSchema = z.object({ + model: z.string(), + mode: z.string(), + message_length_b2: z.number(), +}); + +const ErrorOccurredPropertiesSchema = z.object({ + errorType: z.string(), + context: ErrorContextSchema, +}); + +// Union of all telemetry events +export const TelemetryEventSchema = z.discriminatedUnion("event", [ + z.object({ + event: z.literal("app_started"), + properties: AppStartedPropertiesSchema, + }), + z.object({ + event: z.literal("workspace_created"), + properties: WorkspaceCreatedPropertiesSchema, + }), + z.object({ + event: z.literal("workspace_switched"), + properties: WorkspaceSwitchedPropertiesSchema, + }), + z.object({ + event: z.literal("message_sent"), + properties: MessageSentPropertiesSchema, + }), + z.object({ + event: z.literal("error_occurred"), + properties: ErrorOccurredPropertiesSchema, + }), +]); + +// API schemas - only track endpoint, enabled state controlled by env var +export const telemetry = { + track: { + input: TelemetryEventSchema, + output: z.void(), + }, +}; diff --git a/src/common/telemetry/client.test.ts b/src/common/telemetry/client.test.ts index 06af9afc05..311ca86a40 100644 --- a/src/common/telemetry/client.test.ts +++ b/src/common/telemetry/client.test.ts @@ -1,49 +1,22 @@ -// Mock posthog-js to avoid import issues in test environment -jest.mock("posthog-js", () => ({ - __esModule: true, - default: { - init: jest.fn(), - capture: jest.fn(), - reset: jest.fn(), - }, -})); +import { initTelemetry, trackEvent, shutdownTelemetry } from "./client"; -// Ensure NODE_ENV is set to test for telemetry detection -// Must be set before importing the client module -process.env.NODE_ENV = "test"; - -import { initTelemetry, trackEvent, isTelemetryInitialized } from "./client"; - -describe("Telemetry", () => { - describe("in test environment", () => { - beforeAll(() => { - process.env.NODE_ENV = "test"; - }); - - it("should not initialize PostHog", () => { - initTelemetry(); - expect(isTelemetryInitialized()).toBe(false); - }); - - it("should silently ignore track events", () => { - // Should not throw even though not initialized - expect(() => { - trackEvent({ - event: "workspace_switched", - properties: { - version: "1.0.0", - platform: "darwin", - electronVersion: "28.0.0", - fromWorkspaceId: "test-from", - toWorkspaceId: "test-to", - }, - }); - }).not.toThrow(); - }); +describe("Telemetry client", () => { + it("initTelemetry and shutdownTelemetry are no-ops", () => { + // These are kept for API compatibility but do nothing + expect(() => initTelemetry()).not.toThrow(); + expect(() => shutdownTelemetry()).not.toThrow(); + }); - it("should correctly detect test environment", () => { - // Verify NODE_ENV is set to test (we set it above for telemetry detection) - expect(process.env.NODE_ENV).toBe("test"); - }); + it("trackEvent silently forwards to backend without throwing", () => { + // In test environment, ORPC is not available, but trackEvent should not throw + expect(() => { + trackEvent({ + event: "workspace_switched", + properties: { + fromWorkspaceId: "test-from", + toWorkspaceId: "test-to", + }, + }); + }).not.toThrow(); }); }); diff --git a/src/common/telemetry/client.ts b/src/common/telemetry/client.ts index 8b42021dbd..8bb4f4851a 100644 --- a/src/common/telemetry/client.ts +++ b/src/common/telemetry/client.ts @@ -1,92 +1,20 @@ /** - * PostHog Telemetry Client + * PostHog Telemetry Client (Frontend) * - * Provides a type-safe interface for sending telemetry events to PostHog. + * Forwards telemetry events to the backend via ORPC. + * The backend decides whether to actually send events to PostHog + * (controlled by MUX_DISABLE_TELEMETRY environment variable). + * + * This design avoids ad-blocker issues and centralizes control. * All payloads are defined in ./payload.ts for transparency. */ -import posthog from "posthog-js"; import type { TelemetryEventPayload } from "./payload"; -// Default configuration (public keys, safe to commit) -const DEFAULT_POSTHOG_KEY = "phc_vF1bLfiD5MXEJkxojjsmV5wgpLffp678yhJd3w9Sl4G"; -const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; - -// Get PostHog configuration from environment variables with fallback to defaults -// Note: Vite injects import.meta.env at build time, so this is safe in the browser -// In test environments, we never call this function (see isTestEnvironment check) -function getPosthogConfig(): { key: string; host: string } { - // Use indirect access to avoid Jest parsing issues with import.meta - // This works because Vite transforms import.meta.env at build time - try { - // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call - const meta = new Function("return import.meta")() as - | { - env?: { VITE_PUBLIC_POSTHOG_KEY?: string; VITE_PUBLIC_POSTHOG_HOST?: string }; - } - | undefined; - if (meta?.env) { - return { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - key: meta.env.VITE_PUBLIC_POSTHOG_KEY || DEFAULT_POSTHOG_KEY, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - host: meta.env.VITE_PUBLIC_POSTHOG_HOST || DEFAULT_POSTHOG_HOST, - }; - } - } catch { - // import.meta not available (e.g., in test environment) - } - return { - key: DEFAULT_POSTHOG_KEY, - host: DEFAULT_POSTHOG_HOST, - }; -} - -let isInitialized = false; - -// Storage key for telemetry enabled preference -const TELEMETRY_ENABLED_KEY = "mux_telemetry_enabled"; -const LEGACY_TELEMETRY_KEY = "mux_telemetry_enabled"; - -/** - * Check if telemetry is enabled by user preference - * Default is true (opt-out model) - * Checks legacy key for backward compatibility - */ -export function isTelemetryEnabled(): boolean { - if (typeof window === "undefined") return true; - - // Try new key first, then legacy key - const stored = localStorage.getItem(TELEMETRY_ENABLED_KEY); - if (stored !== null) { - return stored === "true"; - } - - // Migrate from legacy key if it exists - const legacy = localStorage.getItem(LEGACY_TELEMETRY_KEY); - if (legacy !== null) { - localStorage.setItem(TELEMETRY_ENABLED_KEY, legacy); - localStorage.removeItem(LEGACY_TELEMETRY_KEY); - return legacy === "true"; - } - - return true; // Default to enabled -} - -/** - * Set telemetry enabled preference - */ -export function setTelemetryEnabled(enabled: boolean): void { - if (typeof window === "undefined") return; - localStorage.setItem(TELEMETRY_ENABLED_KEY, enabled.toString()); - console.log(`[Telemetry] ${enabled ? "Enabled" : "Disabled"}`); -} - /** * Check if we're running in a test environment */ function isTestEnvironment(): boolean { - // Check various test environment indicators return ( typeof process !== "undefined" && (process.env.NODE_ENV === "test" || @@ -97,94 +25,37 @@ function isTestEnvironment(): boolean { } /** - * Initialize the PostHog client - * Should be called once on app startup - * - * Note: Telemetry is automatically disabled in test environments or when user has opted out + * Initialize telemetry (no-op, kept for API compatibility) */ export function initTelemetry(): void { - if (isTestEnvironment()) { - return; - } - - if (!isTelemetryEnabled()) { - console.debug("[Telemetry] Disabled by user preference, skipping initialization"); - return; - } - - if (isInitialized) { - console.warn("Telemetry already initialized"); - return; - } - - const config = getPosthogConfig(); - posthog.init(config.key, { - api_host: config.host, - // Disable all automatic tracking - we only send explicit events - autocapture: false, - capture_pageview: false, - capture_pageleave: false, - capture_performance: false, // Disables web vitals - disable_session_recording: true, - // Note: We still want error tracking to work through our explicit error_occurred event - loaded: (ph) => { - // Identify user with a stable anonymous ID based on machine - // This allows us to track usage patterns without PII - ph.identify(); - }, - }); - - isInitialized = true; - console.debug("[Telemetry] PostHog initialized", { host: config.host }); + // No-op - backend handles initialization } /** - * Send a telemetry event to PostHog + * Send a telemetry event via the backend * Events are type-safe and must match definitions in payload.ts * - * Note: Events are silently ignored in test environments or when disabled by user + * The backend decides whether to actually send to PostHog. */ export function trackEvent(payload: TelemetryEventPayload): void { if (isTestEnvironment()) { - // Silently ignore telemetry in tests return; } - if (!isTelemetryEnabled()) { - // Silently ignore when user has disabled telemetry + const client = window.__ORPC_CLIENT__; + if (!client) { return; } - if (!isInitialized) { - console.debug("[Telemetry] Not initialized, skipping event:", payload.event); - return; - } - - // Debug log to verify events are being sent - console.debug("[Telemetry] Sending event:", { - event: payload.event, - properties: payload.properties, + // Fire and forget - don't block on telemetry + client.telemetry.track(payload).catch(() => { + // Silently ignore errors }); - - posthog.capture(payload.event, payload.properties); } /** - * Shutdown telemetry and flush any pending events - * Should be called on app close + * Shutdown telemetry (no-op, kept for API compatibility) */ export function shutdownTelemetry(): void { - if (!isInitialized) { - return; - } - - posthog.reset(); - isInitialized = false; -} - -/** - * Check if telemetry is initialized - */ -export function isTelemetryInitialized(): boolean { - return isInitialized; + // No-op - backend handles shutdown } diff --git a/src/common/telemetry/index.ts b/src/common/telemetry/index.ts index 204695831b..c35e6f317f 100644 --- a/src/common/telemetry/index.ts +++ b/src/common/telemetry/index.ts @@ -2,17 +2,12 @@ * Telemetry module public API * * This module provides telemetry tracking via PostHog. + * Events are forwarded to the backend via ORPC to avoid ad-blocker issues. + * Backend controls whether telemetry is enabled (MUX_DISABLE_TELEMETRY env var). * See payload.ts for all data structures sent to PostHog. */ -export { - initTelemetry, - trackEvent, - shutdownTelemetry, - isTelemetryInitialized, - isTelemetryEnabled, - setTelemetryEnabled, -} from "./client"; +export { initTelemetry, trackEvent, shutdownTelemetry } from "./client"; export { trackAppStarted } from "./lifecycle"; export type { TelemetryEventPayload, ErrorContext } from "./payload"; -export { getBaseTelemetryProperties, roundToBase2 } from "./utils"; +export { roundToBase2 } from "./utils"; diff --git a/src/common/telemetry/lifecycle.ts b/src/common/telemetry/lifecycle.ts index 875e2bacd7..86273ac0b4 100644 --- a/src/common/telemetry/lifecycle.ts +++ b/src/common/telemetry/lifecycle.ts @@ -4,33 +4,23 @@ * Handles app startup events */ -import { trackEvent, getBaseTelemetryProperties } from "./index"; +import { trackEvent } from "./index"; + +// Storage key for first launch tracking +const FIRST_LAUNCH_KEY = "mux_first_launch_complete"; /** * Check if this is the first app launch * Uses localStorage to persist flag across sessions - * Checks legacy key for backward compatibility */ function checkFirstLaunch(): boolean { - const key = "mux_first_launch_complete"; - const legacyKey = "mux_first_launch_complete"; - - // Check new key first - const hasLaunchedBefore = localStorage.getItem(key); + const hasLaunchedBefore = localStorage.getItem(FIRST_LAUNCH_KEY); if (hasLaunchedBefore) { return false; } - // Migrate from legacy key if it exists - const legacyValue = localStorage.getItem(legacyKey); - if (legacyValue) { - localStorage.setItem(key, legacyValue); - localStorage.removeItem(legacyKey); - return false; - } - // First launch - set the flag - localStorage.setItem(key, "true"); + localStorage.setItem(FIRST_LAUNCH_KEY, "true"); return true; } @@ -46,7 +36,6 @@ export function trackAppStarted(): void { trackEvent({ event: "app_started", properties: { - ...getBaseTelemetryProperties(), isFirstLaunch, }, }); diff --git a/src/common/telemetry/payload.ts b/src/common/telemetry/payload.ts index cbbfcc55d8..b95d28bdd5 100644 --- a/src/common/telemetry/payload.ts +++ b/src/common/telemetry/payload.ts @@ -14,10 +14,15 @@ * - For numerical metrics that could leak information (like message lengths), use * base-2 rounding (e.g., 128, 256, 512) to preserve privacy while enabling analysis. * - When in doubt, don't send it. Privacy is paramount. + * + * NOTE: Base properties (version, platform, electronVersion) are automatically + * added by the backend TelemetryService. Frontend code only needs to provide + * event-specific properties. */ /** * Base properties included with all telemetry events + * These are added by the backend, not the frontend */ export interface BaseTelemetryProperties { /** Application version */ @@ -31,7 +36,7 @@ export interface BaseTelemetryProperties { /** * Application lifecycle events */ -export interface AppStartedPayload extends BaseTelemetryProperties { +export interface AppStartedPayload { /** Whether this is the first app launch */ isFirstLaunch: boolean; } @@ -39,12 +44,12 @@ export interface AppStartedPayload extends BaseTelemetryProperties { /** * Workspace events */ -export interface WorkspaceCreatedPayload extends BaseTelemetryProperties { +export interface WorkspaceCreatedPayload { /** Workspace ID (randomly generated, safe to send) */ workspaceId: string; } -export interface WorkspaceSwitchedPayload extends BaseTelemetryProperties { +export interface WorkspaceSwitchedPayload { /** Previous workspace ID (randomly generated, safe to send) */ fromWorkspaceId: string; /** New workspace ID (randomly generated, safe to send) */ @@ -54,7 +59,7 @@ export interface WorkspaceSwitchedPayload extends BaseTelemetryProperties { /** * Chat/AI interaction events */ -export interface MessageSentPayload extends BaseTelemetryProperties { +export interface MessageSentPayload { /** Full model identifier (e.g., 'anthropic/claude-3-5-sonnet-20241022') */ model: string; /** UI mode (e.g., 'plan', 'exec', 'edit') */ @@ -79,7 +84,7 @@ export type ErrorContext = /** * Error tracking events */ -export interface ErrorOccurredPayload extends BaseTelemetryProperties { +export interface ErrorOccurredPayload { /** Error type/name */ errorType: string; /** Error context - where the error occurred */ @@ -88,6 +93,7 @@ export interface ErrorOccurredPayload extends BaseTelemetryProperties { /** * Union type of all telemetry event payloads + * Frontend sends these; backend adds BaseTelemetryProperties before forwarding to PostHog */ export type TelemetryEventPayload = | { event: "app_started"; properties: AppStartedPayload } diff --git a/src/common/telemetry/utils.ts b/src/common/telemetry/utils.ts index 439f7e1495..9534655fb9 100644 --- a/src/common/telemetry/utils.ts +++ b/src/common/telemetry/utils.ts @@ -2,27 +2,6 @@ * Telemetry utility functions */ -import type { BaseTelemetryProperties } from "./payload"; -import { VERSION } from "@/version"; - -/** - * Get base telemetry properties included with all events - */ -export function getBaseTelemetryProperties(): BaseTelemetryProperties { - const gitDescribe = - typeof VERSION === "object" && - VERSION !== null && - typeof (VERSION as Record).git_describe === "string" - ? (VERSION as { git_describe: string }).git_describe - : "unknown"; - - return { - version: gitDescribe, - platform: window.api?.platform ?? "unknown", - electronVersion: window.api?.versions?.electron ?? "unknown", - }; -} - /** * Round a number to the nearest power of 2 for privacy-preserving metrics * E.g., 350 -> 512, 1200 -> 2048 diff --git a/src/desktop/main.ts b/src/desktop/main.ts index b51782abcd..557fe047a7 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -332,6 +332,7 @@ async function loadServices(): Promise { serverService: services.serverService, menuEventService: services.menuEventService, voiceService: services.voiceService, + telemetryService: services.telemetryService, }; electronIpcMain.on("start-orpc-server", (event) => { @@ -568,9 +569,10 @@ if (gotTheLock) { }); app.on("before-quit", () => { - console.log(`[${timestamp()}] App before-quit - cleaning up API server...`); + console.log(`[${timestamp()}] App before-quit - cleaning up...`); if (services) { void services.serverService.stopServer(); + void services.shutdown(); } }); diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 996db5d1f8..07114cfeb2 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -9,6 +9,7 @@ import type { TokenizerService } from "@/node/services/tokenizerService"; import type { ServerService } from "@/node/services/serverService"; import type { MenuEventService } from "@/node/services/menuEventService"; import type { VoiceService } from "@/node/services/voiceService"; +import type { TelemetryService } from "@/node/services/telemetryService"; export interface ORPCContext { projectService: ProjectService; @@ -21,5 +22,6 @@ export interface ORPCContext { serverService: ServerService; menuEventService: MenuEventService; voiceService: VoiceService; + telemetryService: TelemetryService; headers?: IncomingHttpHeaders; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 87a774751e..4abe77cde6 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -742,6 +742,14 @@ export const router = (authToken?: string) => { ); }), }, + telemetry: { + track: t + .input(schemas.telemetry.track.input) + .output(schemas.telemetry.track.output) + .handler(({ context, input }) => { + context.telemetryService.capture(input); + }), + }, }); }; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index d1bcc15374..4bad951338 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -17,6 +17,7 @@ import { TokenizerService } from "@/node/services/tokenizerService"; import { ServerService } from "@/node/services/serverService"; import { MenuEventService } from "@/node/services/menuEventService"; import { VoiceService } from "@/node/services/voiceService"; +import { TelemetryService } from "@/node/services/telemetryService"; /** * ServiceContainer - Central dependency container for all backend services. @@ -39,6 +40,7 @@ export class ServiceContainer { public readonly serverService: ServerService; public readonly menuEventService: MenuEventService; public readonly voiceService: VoiceService; + public readonly telemetryService: TelemetryService; private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; private readonly ptyService: PTYService; @@ -78,10 +80,20 @@ export class ServiceContainer { this.serverService = new ServerService(); this.menuEventService = new MenuEventService(); this.voiceService = new VoiceService(config); + this.telemetryService = new TelemetryService(config.rootDir); } async initialize(): Promise { await this.extensionMetadata.initialize(); + // Initialize telemetry service + await this.telemetryService.initialize(); + } + + /** + * Shutdown services that need cleanup + */ + async shutdown(): Promise { + await this.telemetryService.shutdown(); } setProjectDirectoryPicker(picker: () => Promise): void { diff --git a/src/node/services/telemetryService.ts b/src/node/services/telemetryService.ts new file mode 100644 index 0000000000..c4da3a637f --- /dev/null +++ b/src/node/services/telemetryService.ts @@ -0,0 +1,169 @@ +/** + * Backend Telemetry Service + * + * Sends telemetry events to PostHog from the main process (Node.js). + * This avoids ad-blocker issues that affect browser-side telemetry. + * + * Telemetry can be disabled by setting the MUX_DISABLE_TELEMETRY=1 env var. + * + * Uses posthog-node which batches events and flushes asynchronously. + */ + +import { PostHog } from "posthog-node"; +import { randomUUID } from "crypto"; +import * as fs from "fs/promises"; +import * as path from "path"; +import { getMuxHome } from "@/common/constants/paths"; +import { VERSION } from "@/version"; +import type { TelemetryEventPayload, BaseTelemetryProperties } from "@/common/telemetry/payload"; + +// Default configuration (public keys, safe to commit) +const DEFAULT_POSTHOG_KEY = "phc_vF1bLfiD5MXEJkxojjsmV5wgpLffp678yhJd3w9Sl4G"; +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +// File to persist anonymous distinct ID across sessions +const TELEMETRY_ID_FILE = "telemetry_id"; + +/** + * Check if telemetry is disabled via environment variable + */ +function isTelemetryDisabled(): boolean { + return ( + process.env.MUX_DISABLE_TELEMETRY === "1" || + process.env.NODE_ENV === "test" || + process.env.JEST_WORKER_ID !== undefined || + process.env.VITEST !== undefined || + process.env.TEST_INTEGRATION === "1" + ); +} + +/** + * Get the version string for telemetry + */ +function getVersionString(): string { + if ( + typeof VERSION === "object" && + VERSION !== null && + typeof (VERSION as Record).git_describe === "string" + ) { + return (VERSION as { git_describe: string }).git_describe; + } + return "unknown"; +} + +export class TelemetryService { + private client: PostHog | null = null; + private distinctId: string | null = null; + private readonly muxHome: string; + + constructor(muxHome?: string) { + this.muxHome = muxHome ?? getMuxHome(); + } + + /** + * Initialize the PostHog client. + * Should be called once on app startup. + */ + async initialize(): Promise { + if (isTelemetryDisabled()) { + return; + } + + if (this.client) { + return; + } + + // Load or generate distinct ID + this.distinctId = await this.loadOrCreateDistinctId(); + + this.client = new PostHog(DEFAULT_POSTHOG_KEY, { + host: DEFAULT_POSTHOG_HOST, + // Disable feature flags since we don't use them + disableGeoip: true, + }); + + console.debug("[TelemetryService] Initialized", { host: DEFAULT_POSTHOG_HOST }); + } + + /** + * Load existing distinct ID or create a new one. + * Persisted in ~/.mux/telemetry_id for cross-session identity. + */ + private async loadOrCreateDistinctId(): Promise { + const idPath = path.join(this.muxHome, TELEMETRY_ID_FILE); + + try { + // Try to read existing ID + const id = (await fs.readFile(idPath, "utf-8")).trim(); + if (id) { + return id; + } + } catch { + // File doesn't exist or read error, will create new ID + } + + // Generate new ID + const newId = randomUUID(); + + try { + // Ensure directory exists + await fs.mkdir(this.muxHome, { recursive: true }); + await fs.writeFile(idPath, newId, "utf-8"); + } catch { + // Silently ignore persistence failures + } + + return newId; + } + + /** + * Get base properties included with all events + */ + private getBaseProperties(): BaseTelemetryProperties { + return { + version: getVersionString(), + platform: process.platform, + electronVersion: process.versions.electron ?? "unknown", + }; + } + + /** + * Track a telemetry event. + * Events are silently ignored when disabled. + */ + capture(payload: TelemetryEventPayload): void { + if (isTelemetryDisabled() || !this.client || !this.distinctId) { + return; + } + + // Merge base properties with event-specific properties + const properties = { + ...this.getBaseProperties(), + ...payload.properties, + }; + + this.client.capture({ + distinctId: this.distinctId, + event: payload.event, + properties, + }); + } + + /** + * Shutdown telemetry and flush any pending events. + * Should be called on app close. + */ + async shutdown(): Promise { + if (!this.client) { + return; + } + + try { + await this.client.shutdown(); + } catch { + // Silently ignore shutdown errors + } + + this.client = null; + } +} diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index 180ba8e1d4..0f2a1bc370 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -79,6 +79,7 @@ export async function createTestEnvironment(): Promise { serverService: services.serverService, menuEventService: services.menuEventService, voiceService: services.voiceService, + telemetryService: services.telemetryService, }; const orpc = createOrpcTestClient(orpcContext);