diff --git a/docs/reference/telemetry.mdx b/docs/reference/telemetry.mdx index d4e44f9b4a1..65dc5f3f541 100644 --- a/docs/reference/telemetry.mdx +++ b/docs/reference/telemetry.mdx @@ -38,13 +38,15 @@ All telemetry events include basic system information: ## Disabling telemetry -To disable telemetry, set `MUX_DISABLE_TELEMETRY` before starting the app: +Toggle **Usage Telemetry** off in **Settings → General**. The change applies immediately (no restart) and persists in `~/.mux/config.json` as `telemetryEnabled: false`. + +Alternatively, set `MUX_DISABLE_TELEMETRY` to exactly `1` before starting the app (other values like `true` are ignored): ```bash MUX_DISABLE_TELEMETRY=1 mux ``` -This disables telemetry collection at the backend level. +The environment variable is a hard override: when set to `1`, telemetry stays off regardless of the Settings toggle, and the toggle renders disabled with a note saying so. Both switches disable collection at the backend level. ## Source code diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index e0fa9a38cb7..62cf59f40c2 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -19,6 +19,8 @@ interface MockConfig { worktreeArchiveBehavior: WorktreeArchiveBehavior; chatTranscriptFullWidth: boolean; llmDebugLogs: boolean; + telemetryEnabled: boolean; + telemetryDisabledByEnv: boolean; } interface MockAPIClient { @@ -30,6 +32,11 @@ interface MockAPIClient { }) => Promise; updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise; updateLlmDebugLogs: (input: { enabled: boolean }) => Promise; + updateTelemetryEnabled: (input: { enabled: boolean }) => Promise; + onConfigChanged?: ( + input: undefined, + opts: { signal?: AbortSignal } + ) => Promise>; }; server: { getSshHost: () => Promise; @@ -41,7 +48,7 @@ interface MockAPIClient { }; } -let mockApi: MockAPIClient; +let mockApi: MockAPIClient | null; void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () => { const SelectContext = React.createContext<{ @@ -171,6 +178,8 @@ interface RenderGeneralSectionOptions { coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior?: WorktreeArchiveBehavior; chatTranscriptFullWidth?: boolean; + telemetryEnabled?: boolean; + telemetryDisabledByEnv?: boolean; } interface MockAPISetup { @@ -187,6 +196,9 @@ interface MockAPISetup { updateChatTranscriptFullWidthMock: ReturnType< typeof mock<(input: { enabled: boolean }) => Promise> >; + updateTelemetryEnabledMock: ReturnType< + typeof mock<(input: { enabled: boolean }) => Promise> + >; } function createMockAPI(configOverrides: Partial = {}): MockAPISetup { @@ -195,6 +207,8 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, chatTranscriptFullWidth: false, llmDebugLogs: false, + telemetryEnabled: true, + telemetryDisabledByEnv: false, ...configOverrides, }; @@ -217,6 +231,12 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup return Promise.resolve(); }); + const updateTelemetryEnabledMock = mock(({ enabled }: { enabled: boolean }) => { + config.telemetryEnabled = enabled; + + return Promise.resolve(); + }); + return { api: { config: { @@ -228,6 +248,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup return Promise.resolve(); }), + updateTelemetryEnabled: updateTelemetryEnabledMock, }, server: { getSshHost: mock(() => Promise.resolve(null)), @@ -241,6 +262,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup getConfigMock, updateCoderPrefsMock, updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, }; } @@ -263,10 +285,21 @@ describe("GeneralSection", () => { }); function renderGeneralSection(options: RenderGeneralSectionOptions = {}) { - const { api, updateCoderPrefsMock, updateChatTranscriptFullWidthMock } = createMockAPI({ + const { + api, + updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, + } = createMockAPI({ chatTranscriptFullWidth: options.chatTranscriptFullWidth, coderWorkspaceArchiveBehavior: options.coderWorkspaceArchiveBehavior, worktreeArchiveBehavior: options.worktreeArchiveBehavior, + ...(options.telemetryEnabled !== undefined + ? { telemetryEnabled: options.telemetryEnabled } + : {}), + ...(options.telemetryDisabledByEnv !== undefined + ? { telemetryDisabledByEnv: options.telemetryDisabledByEnv } + : {}), }); mockApi = api; @@ -276,7 +309,12 @@ describe("GeneralSection", () => { ); - return { updateCoderPrefsMock, updateChatTranscriptFullWidthMock, view }; + return { + updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, + view, + }; } function getSelectTrigger(view: ReturnType, label: string): HTMLElement { @@ -353,6 +391,405 @@ describe("GeneralSection", () => { }); }); + test("loads the telemetry opt-out and persists re-enabling it", async () => { + const { updateTelemetryEnabledMock, view } = renderGeneralSection({ + telemetryEnabled: false, + }); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + // A persisted opt-out must render unchecked (default is enabled). + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(updateTelemetryEnabledMock).toHaveBeenCalledWith({ enabled: true }); + }); + }); + + test("renders the telemetry switch hard-disabled when the environment overrides it", async () => { + const { updateTelemetryEnabledMock, view } = renderGeneralSection({ + telemetryEnabled: true, + telemetryDisabledByEnv: true, + }); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + // Env override wins over the config value: switch shows off and cannot be flipped. + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + expect(toggle.hasAttribute("disabled")).toBe(true); + }); + expect(view.getByText(/Disabled by the environment/i)).toBeTruthy(); + + fireEvent.click(toggle); + expect(updateTelemetryEnabledMock).not.toHaveBeenCalled(); + }); + + test("reverts the telemetry switch when persisting the change fails", async () => { + const { api, updateTelemetryEnabledMock } = createMockAPI({ telemetryEnabled: true }); + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation(() => + Promise.reject(new Error("write failed")) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + fireEvent.click(toggle); + + // A privacy control must not read "off" while the backend still collects: + // the failed write reloads the backend truth (still enabled). + await waitFor(() => { + expect(updateTelemetryEnabledMock).toHaveBeenCalledWith({ enabled: false }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("syncs the telemetry switch when another client changes the config", async () => { + const setup = createMockAPI({ telemetryEnabled: true }); + const { api } = setup; + + // Drivable config-change stream: pushEvent() delivers one notification. + let pushEvent: (() => void) | undefined; + api.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => { + const generator = (async function* () { + for (;;) { + await new Promise((resolve) => { + pushEvent = resolve; + }); + yield {}; + } + })(); + return Promise.resolve(generator); + }; + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(pushEvent).toBeDefined(); + }); + + // Another window persists an opt-out; this pane only learns via the stream. + api.config.getConfig = mock(() => + Promise.resolve({ + coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, + worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, + chatTranscriptFullWidth: false, + llmDebugLogs: false, + telemetryEnabled: false, + telemetryDisabledByEnv: false, + }) + ); + pushEvent?.(); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + }); + + test("re-syncs telemetry state for changes that land before the subscription connects", async () => { + const setup = createMockAPI({ telemetryEnabled: true }); + const { api } = setup; + + // Hold the subscription unestablished so a config change can land in the + // gap between the initial snapshot and the listener coming online. + let resolveSubscribe: ((generator: AsyncGenerator) => void) | undefined; + api.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => + new Promise>((resolve) => { + resolveSubscribe = resolve; + }); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(resolveSubscribe).toBeDefined(); + }); + + // Another client opts out while this pane has no listener yet. + api.config.getConfig = mock(() => + Promise.resolve({ + coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, + worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, + chatTranscriptFullWidth: false, + llmDebugLogs: false, + telemetryEnabled: false, + telemetryDisabledByEnv: false, + }) + ); + + // Connecting the subscription must trigger a re-sync — no event is ever + // pushed for the change that already happened. + resolveSubscribe?.( + (async function* () { + await new Promise(() => { + // Never yields; the post-connect refresh is what syncs. + }); + yield {}; + })() + ); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + }); + + test("replays a config notification that arrived while a local write was in flight", async () => { + const setup = createMockAPI({ telemetryEnabled: true }); + const { api, updateTelemetryEnabledMock } = setup; + + let pushEvent: (() => void) | undefined; + api.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => { + const generator = (async function* () { + for (;;) { + await new Promise((resolve) => { + pushEvent = resolve; + }); + yield {}; + } + })(); + return Promise.resolve(generator); + }; + + let resolveUpdate: (() => void) | undefined; + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation( + () => + new Promise((resolve) => { + resolveUpdate = resolve; + }) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(pushEvent).toBeDefined(); + }); + + // Local opt-out is in flight when another client re-enables telemetry. + fireEvent.click(toggle); + await waitFor(() => { + expect(resolveUpdate).toBeDefined(); + }); + api.config.getConfig = mock(() => + Promise.resolve({ + coderWorkspaceArchiveBehavior: DEFAULT_CODER_ARCHIVE_BEHAVIOR, + worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, + chatTranscriptFullWidth: false, + llmDebugLogs: false, + telemetryEnabled: true, + telemetryDisabledByEnv: false, + }) + ); + pushEvent?.(); + + // The notification must not be dropped: once the write settles, the pane + // reconciles against the shared config (the other client's enable won). + resolveUpdate?.(); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("replays a deferred notification through the replacement API client", async () => { + const setupA = createMockAPI({ telemetryEnabled: true }); + const apiA = setupA.api; + + let pushEventA: (() => void) | undefined; + apiA.config.onConfigChanged = (_input: undefined, _opts: { signal?: AbortSignal }) => { + const generator = (async function* () { + for (;;) { + await new Promise((resolve) => { + pushEventA = resolve; + }); + yield {}; + } + })(); + return Promise.resolve(generator); + }; + + let rejectWriteA: ((error: Error) => void) | undefined; + apiA.config.updateTelemetryEnabled = setupA.updateTelemetryEnabledMock.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectWriteA = reject; + }) + ); + mockApi = apiA; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(pushEventA).toBeDefined(); + }); + + // Local opt-out in flight on client A; a change notification arrives and + // is deferred behind the pending write. + fireEvent.click(toggle); + await waitFor(() => { + expect(rejectWriteA).toBeDefined(); + }); + pushEventA?.(); + + // APIProvider replaces the client while the old write is still pending. + // The replacement's config says telemetry is enabled (the other client's + // enable won). + const setupB = createMockAPI({ telemetryEnabled: true }); + mockApi = setupB.api; + view.rerender( + + + + ); + + // The old write settles AFTER the replacement: the deferred notification + // must replay through client B, not the disconnected client A. + rejectWriteA?.(new Error("connection dropped")); + + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("disables the telemetry switch while the API is unavailable", () => { + // Browser-mode outage: APIProvider keeps settings mounted with api: null. + mockApi = null; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + // A privacy toggle must not accept a change it cannot deliver: the switch + // is disabled and a click leaves the conservative ON state untouched. + expect(toggle.hasAttribute("disabled")).toBe(true); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + test("renders the telemetry switch ON when backend truth is unreachable after a failed write", async () => { + const { api, updateTelemetryEnabledMock } = createMockAPI({ telemetryEnabled: true }); + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation(() => + Promise.reject(new Error("connection dropped")) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + + // After the initial load, make the reconciliation getConfig fail too, so + // the disable attempt ends with no confirmed backend state. + api.config.getConfig = mock(() => Promise.reject(new Error("connection dropped"))); + + fireEvent.click(toggle); + + // Indeterminate outcome must render ON: the disable may not have landed, + // and a privacy switch must not read "off" while collection may continue. + await waitFor(() => { + expect(updateTelemetryEnabledMock).toHaveBeenCalledWith({ enabled: false }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + + test("a superseded telemetry write failure does not clobber the latest choice", async () => { + const { api, updateTelemetryEnabledMock } = createMockAPI({ telemetryEnabled: false }); + const deferred: Array<{ resolve: () => void; reject: (error: Error) => void }> = []; + api.config.updateTelemetryEnabled = updateTelemetryEnabledMock.mockImplementation( + () => + new Promise((resolve, reject) => { + deferred.push({ resolve, reject }); + }) + ); + mockApi = api; + + const view = render( + + + + ); + + const toggle = view.getByRole("switch", { name: "Toggle Usage Telemetry" }); + await waitFor(() => { + expect(toggle.getAttribute("aria-checked")).toBe("false"); + }); + + // Rapid on → off → on; writes are serialized so only the first is in flight. + fireEvent.click(toggle); + fireEvent.click(toggle); + fireEvent.click(toggle); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + await waitFor(() => { + expect(deferred.length).toBe(1); + }); + + // The first write fails only after later intents were queued: its failure + // handling is superseded and must not touch the switch. + deferred[0].reject(new Error("write failed")); + + await waitFor(() => { + expect(deferred.length).toBe(2); + }); + deferred[1].resolve(); + await waitFor(() => { + expect(deferred.length).toBe(3); + }); + deferred[2].resolve(); + + await waitFor(() => { + expect(updateTelemetryEnabledMock).toHaveBeenCalledTimes(3); + expect(updateTelemetryEnabledMock).toHaveBeenLastCalledWith({ enabled: true }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + }); + }); + test("renders the worktree archive behavior copy and loads the saved value", async () => { const { view } = renderGeneralSection({ coderWorkspaceArchiveBehavior: "delete", diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 3e5aa216d2c..123ad10b2c0 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -207,6 +207,11 @@ export function GeneralSection() { const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false); const [chatTranscriptFullWidth, setChatTranscriptFullWidth] = useState(false); const [llmDebugLogs, setLlmDebugLogs] = useState(false); + // Optimistic default: telemetry is on unless config says otherwise. + const [telemetryEnabled, setTelemetryEnabled] = useState(true); + // Env hard-off (MUX_DISABLE_TELEMETRY, CI): the switch renders disabled + // instead of pretending the config toggle controls anything. + const [telemetryDisabledByEnv, setTelemetryDisabledByEnv] = useState(false); const archiveBehaviorLoadNonceRef = useRef(0); const archiveBehaviorRef = useRef(DEFAULT_CODER_ARCHIVE_BEHAVIOR); const worktreeArchiveBehaviorRef = useRef( @@ -215,12 +220,44 @@ export function GeneralSection() { const chatTranscriptFullWidthLoadNonceRef = useRef(0); const llmDebugLogsLoadNonceRef = useRef(0); + const telemetryEnabledLoadNonceRef = useRef(0); + // Monotonic id per telemetry toggle; failure handling may only touch state + // while its own intent is still the latest. + const telemetryEnabledIntentRef = useRef(0); + // Writes still in flight (including their failure reconciliation). Config + // change notifications are deferred while > 0 — NOT dropped: the backend + // emits onConfigChanged before the RPC resolves, so even our own final + // write's notification can arrive while this counter is positive, and an + // external change during the write window would otherwise be lost. + const telemetryEnabledPendingWritesRef = useRef(0); + // Set when a notification was deferred; drained (with a refresh) when the + // pending-writes counter reaches zero. + const telemetryEnabledMissedNotificationRef = useRef(false); + + // Re-read the persisted telemetry state and apply it unless a newer local + // action (toggle or later refresh) superseded this read. + const refreshTelemetryFromBackend = async () => { + if (!api?.config?.getConfig) { + return; + } + const nonce = ++telemetryEnabledLoadNonceRef.current; + try { + const cfg = await api.config.getConfig(); + if (nonce === telemetryEnabledLoadNonceRef.current) { + setTelemetryEnabled(cfg.telemetryEnabled !== false); + setTelemetryDisabledByEnv(cfg.telemetryDisabledByEnv === true); + } + } catch { + // Keep the current state; the next notification retries. + } + }; // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid // selections can't race and persist a stale value via out-of-order writes. const archiveBehaviorUpdateChainRef = useRef>(Promise.resolve()); const chatTranscriptFullWidthUpdateChainRef = useRef>(Promise.resolve()); const llmDebugLogsUpdateChainRef = useRef>(Promise.resolve()); + const telemetryEnabledUpdateChainRef = useRef>(Promise.resolve()); const archiveBehaviorPendingUpdateRef = useRef( undefined ); @@ -237,6 +274,7 @@ export function GeneralSection() { const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; const chatTranscriptFullWidthNonce = ++chatTranscriptFullWidthLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; + const telemetryEnabledNonce = ++telemetryEnabledLoadNonceRef.current; void api.config .getConfig() @@ -272,6 +310,11 @@ export function GeneralSection() { if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { setLlmDebugLogs(cfg.llmDebugLogs === true); } + + if (telemetryEnabledNonce === telemetryEnabledLoadNonceRef.current) { + setTelemetryEnabled(cfg.telemetryEnabled !== false); + setTelemetryDisabledByEnv(cfg.telemetryDisabledByEnv === true); + } }) .catch(() => { if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) { @@ -406,6 +449,147 @@ export function GeneralSection() { }); }; + const handleTelemetryEnabledChange = (checked: boolean) => { + // No usable API (browser-mode outage): don't flip optimistically — the + // switch would render OFF with no write ever issued while the backend may + // keep collecting, silently discarding the intent. The switch itself is + // also disabled while api is null; this guard covers the race where the + // connection drops between render and click. + if (!api?.config?.updateTelemetryEnabled) { + return; + } + + // Invalidate any in-flight config load so it doesn't overwrite the user's selection. + telemetryEnabledLoadNonceRef.current++; + setTelemetryEnabled(checked); + + const intent = ++telemetryEnabledIntentRef.current; + telemetryEnabledPendingWritesRef.current++; + + // Serialize writes so rapid toggles always persist the last user choice. + telemetryEnabledUpdateChainRef.current = telemetryEnabledUpdateChainRef.current + .catch(() => { + // Best-effort only. + }) + .then(() => api.config.updateTelemetryEnabled({ enabled: checked })) + .then(() => { + // Coerce the chain back to Promise. + }) + .catch(async () => { + // A privacy control must never read "off" while collection continues. + // A superseded request's failure is not ours to handle — a later write + // in the chain carries the newest choice and its own handling. For the + // latest intent, reload the backend truth rather than guessing with a + // blind flip (earlier writes in the chain may themselves have failed). + if (telemetryEnabledIntentRef.current !== intent) { + return; + } + try { + const cfg = await api.config.getConfig(); + if (telemetryEnabledIntentRef.current === intent) { + setTelemetryEnabled(cfg.telemetryEnabled !== false); + } + } catch { + if (telemetryEnabledIntentRef.current === intent) { + // Backend truth is unreachable (e.g. the connection dropped after + // the request may already have persisted and applied). Indeterminate + // state must render as ON: showing "off" while telemetry might be + // collecting is the one lie a privacy toggle can't tell. The next + // successful config load reconciles the real value. + setTelemetryEnabled(true); + } + } + }) + .finally(() => { + telemetryEnabledPendingWritesRef.current--; + // Replay a notification that arrived during the write window: the + // backend may have changed under us (another client, or our own write + // whose notification fired before the RPC resolved). Replays go + // through the ref so they use the CURRENT api generation — this + // callback can outlive an API replacement. + if ( + telemetryEnabledPendingWritesRef.current === 0 && + telemetryEnabledMissedNotificationRef.current + ) { + telemetryEnabledMissedNotificationRef.current = false; + refreshTelemetryRef.current(); + } + }); + }; + + // Always points at the CURRENT api generation's refresh: settle-replay + // callbacks from old writes outlive an API replacement and must not replay + // through the disconnected client they captured (a failed read there would + // consume the deferred notification and strand the switch stale). + const refreshTelemetryRef = useRef<() => void>(() => { + // No-op until the api effect installs the real refresh. + }); + + // An API replacement (browser-mode reconnect) obsoletes in-flight telemetry + // writes made through the previous client: invalidate their pending intents + // so a late rejection from the old client can't run failure reconciliation + // against state the new client has since confirmed. The subscription effect + // below re-establishes on the new client and re-syncs on connect. + useEffect(() => { + telemetryEnabledIntentRef.current++; + refreshTelemetryRef.current = () => void refreshTelemetryFromBackend(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTelemetryFromBackend only closes over `api` (the dep) and stable refs/setters. + }, [api]); + + // Cross-client telemetry sync: another window/tab (or the API server) can + // flip the toggle; consume the config-change stream so this pane's switch + // tracks the true collection state instead of showing a stale value. + useEffect(() => { + if (!api?.config?.onConfigChanged) { + return; + } + const abortController = new AbortController(); + const signal = abortController.signal; + let iterator: AsyncIterator | null = null; + + const refreshTelemetry = () => { + // Defer (never drop) while our own writes are in flight: the settle + // handler replays the refresh once the queue drains. + if (telemetryEnabledPendingWritesRef.current > 0) { + telemetryEnabledMissedNotificationRef.current = true; + return; + } + void refreshTelemetryFromBackend(); + }; + + const subscription = (async () => { + try { + const subscribedIterator = await api.config.onConfigChanged(undefined, { signal }); + if (signal.aborted) { + const cleanup = subscribedIterator.return?.(); + cleanup?.catch(() => undefined); + return; + } + iterator = subscribedIterator; + // The initial config snapshot raced this subscription's establishment: + // a change landing in that gap had no listener and would leave the + // switch stale until the next unrelated edit. Re-sync once connected. + refreshTelemetry(); + for await (const _ of subscribedIterator) { + if (signal.aborted) { + break; + } + void refreshTelemetry(); + } + } catch { + // Config subscriptions are cancelled during unmounts and API reconnects. + } + })(); + subscription.catch(() => undefined); + + return () => { + abortController.abort(); + const cleanup = iterator?.return?.(undefined); + cleanup?.catch(() => undefined); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- refreshTelemetryFromBackend only closes over `api` (already a dep) and stable refs/setters. + }, [api]); + // Load SSH host from server on mount (browser mode only) useEffect(() => { if (isBrowserMode && api) { @@ -697,6 +881,42 @@ export function GeneralSection() { +
+

Privacy

+
+
+
+
Usage Telemetry
+
+ Send anonymous usage events to help improve mux — no code, paths, or prompts.{" "} + + What is collected + + {telemetryDisabledByEnv && ( + + Disabled by the environment (MUX_DISABLE_TELEMETRY / CI) — this switch has no + effect until that is removed. + + )} +
+
+ +
+
+
+
Editor
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 7ec8973c548..7597f806b6a 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -145,6 +145,8 @@ export interface MockORPCClientOptions { agentDefinitions?: AgentDefinitionDescriptor[]; /** Initial per-subagent AI defaults for config.getConfig (e.g., Settings → Tasks section) */ subagentAiDefaults?: SubagentAiDefaults; + /** Initial telemetry opt-in state for config.getConfig (Settings → General → Privacy) */ + telemetryEnabled?: boolean; /** Coder lifecycle preferences for config.getConfig (e.g., Settings → Coder section) */ coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; /** What to do with mux-managed worktrees when archiving a chat. */ @@ -391,6 +393,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl taskSettings: initialTaskSettings, subagentAiDefaults: initialSubagentAiDefaults, agentAiDefaults: initialAgentAiDefaults, + telemetryEnabled: initialTelemetryEnabled, coderWorkspaceArchiveBehavior: initialCoderWorkspaceArchiveBehavior = "stop", worktreeArchiveBehavior: initialWorktreeArchiveBehavior = "keep", chatTranscriptFullWidth: initialChatTranscriptFullWidth = false, @@ -640,6 +643,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; let subagentAiDefaults = deriveSubagentAiDefaults(); + let telemetryEnabled = initialTelemetryEnabled ?? true; const mockStats: ChatStats = { consumers: [], @@ -783,6 +787,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl chatTranscriptFullWidth, muxGovernorEnrolled, llmDebugLogs: false, + telemetryEnabled, + telemetryDisabledByEnv: false, }), saveConfig: (input: { taskSettings?: unknown; @@ -842,6 +848,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl notifyConfigChanged(); return Promise.resolve(undefined); }, + updateTelemetryEnabled: (input: { enabled: boolean }) => { + telemetryEnabled = input.enabled; + notifyConfigChanged(); + return Promise.resolve(undefined); + }, updateMuxGatewayPrefs: (input: { muxGatewayEnabled: boolean; muxGatewayModels: string[]; diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 33ebbf24f40..b9e65f84ec3 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -105,6 +105,12 @@ export const AppConfigOnDiskSchema = z chatTranscriptFullWidth: z.boolean().optional(), muxGatewayEnabled: z.boolean().optional(), llmDebugLogs: z.boolean().optional(), + /** + * Anonymous usage telemetry opt-out (Settings → General). Absent/true = + * enabled; false = disabled. MUX_DISABLE_TELEMETRY=1 also hard-disables + * regardless of this field. + */ + telemetryEnabled: z.boolean().optional(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z .number() diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 95bda834e53..124175d0d29 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2304,6 +2304,11 @@ export const config = { muxGovernorEnrolled: z.boolean(), chatTranscriptFullWidth: z.boolean(), llmDebugLogs: z.boolean(), + telemetryEnabled: z.boolean(), + // True when the environment (MUX_DISABLE_TELEMETRY, CI, tests) hard-disables + // telemetry regardless of the config toggle — the UI renders the switch + // disabled instead of pretending it controls anything. + telemetryDisabledByEnv: z.boolean(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z.number().optional(), goalDefaults: GoalDefaultsConfigSchema, @@ -2393,6 +2398,7 @@ export const config = { }, updateChatTranscriptFullWidth: booleanToggleRoute, updateLlmDebugLogs: booleanToggleRoute, + updateTelemetryEnabled: booleanToggleRoute, updateHeartbeatDefaultPrompt: { input: z .object({ diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 1940733d152..4928dfa863d 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -92,6 +92,8 @@ export interface ProjectsConfig { muxGatewayEnabled?: boolean; /** Enable recording AI SDK devtools logs to ~/.mux/sessions//devtools.jsonl */ llmDebugLogs?: boolean; + /** Anonymous usage telemetry opt-out: absent/true = enabled, false = disabled. */ + telemetryEnabled?: boolean; /** Default heartbeat prompt used when a workspace heartbeat does not set its own message. */ heartbeatDefaultPrompt?: string; /** Default heartbeat interval used when a workspace heartbeat does not set its own cadence. */ diff --git a/src/node/config.telemetryEnabled.test.ts b/src/node/config.telemetryEnabled.test.ts new file mode 100644 index 00000000000..dd64fe030fa --- /dev/null +++ b/src/node/config.telemetryEnabled.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { Config } from "@/node/config"; + +describe("Config telemetryEnabled persistence", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-telemetry-enabled-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("fails closed when config.json exists but cannot be parsed", async () => { + const config = new Config(tempDir); + // Fresh install (no file) is not an error: telemetry stays enabled. + expect(config.isTelemetryDisabledByConfig()).toBe(false); + + // A corrupted file must not silently override a possible opt-out: + // unreadable persisted state reports disabled. + await fs.writeFile(path.join(tempDir, "config.json"), "{ not json", "utf-8"); + expect(config.isTelemetryDisabledByConfig()).toBe(true); + }); + + it("fails closed when the config directory is inaccessible", async () => { + const config = new Config(tempDir); + await fs.writeFile(path.join(tempDir, "config.json"), JSON.stringify({}), "utf-8"); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + + // existsSync() masks EACCES as "missing"; the stat-based check must treat + // an unreachable ~/.mux as a possible opt-out, not as enabled-by-default. + await fs.chmod(tempDir, 0o000); + try { + expect(config.isTelemetryDisabledByConfig()).toBe(true); + } finally { + await fs.chmod(tempDir, 0o700); + } + }); + + it("round-trips the opt-out through editConfig saves and reports it", async () => { + const config = new Config(tempDir); + expect(config.isTelemetryDisabledByConfig()).toBe(false); + + await config.editConfig((cfg) => ({ ...cfg, telemetryEnabled: false })); + + // A fresh instance re-reads from disk: the field must survive the + // whitelist-based saveConfig serialization. + const reloaded = new Config(tempDir); + expect(reloaded.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(reloaded.isTelemetryDisabledByConfig()).toBe(true); + + // Clearing the field (re-enable) must persist too. + await reloaded.editConfig((cfg) => ({ ...cfg, telemetryEnabled: undefined })); + const cleared = new Config(tempDir); + expect(cleared.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(cleared.isTelemetryDisabledByConfig()).toBe(false); + }); +}); diff --git a/src/node/config.ts b/src/node/config.ts index 89ff57b5816..883052048e8 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1379,6 +1379,7 @@ export class Config { chatTranscriptFullWidth: parseOptionalBoolean(parsed.chatTranscriptFullWidth), muxGatewayEnabled, llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs), + telemetryEnabled: parseOptionalBoolean(parsed.telemetryEnabled), heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt), heartbeatDefaultIntervalMs: parseOptionalHeartbeatIntervalMs( parsed.heartbeatDefaultIntervalMs @@ -1502,6 +1503,11 @@ export class Config { data.llmDebugLogs = llmDebugLogs; } + const telemetryEnabled = parseOptionalBoolean(config.telemetryEnabled); + if (telemetryEnabled !== undefined) { + data.telemetryEnabled = telemetryEnabled; + } + const heartbeatDefaultPrompt = parseOptionalNonEmptyString(config.heartbeatDefaultPrompt); if (heartbeatDefaultPrompt) { data.heartbeatDefaultPrompt = heartbeatDefaultPrompt; @@ -1805,6 +1811,29 @@ export class Config { return this.loadConfigOrDefault().llmDebugLogs === true; } + /** + * Settings → General telemetry opt-out; absent means enabled. + * + * Fail CLOSED: when the persisted state cannot be read, report disabled — + * corrupted or inaccessible state must not silently override an opt-out. A + * genuinely missing file is not an error (fresh install ⇒ enabled), but + * existsSync() masks traversal failures (EACCES on ~/.mux) as "missing", so + * stat explicitly to tell ENOENT apart from every other failure. Callers + * stay non-fatal either way. + */ + isTelemetryDisabledByConfig(): boolean { + try { + fs.statSync(this.configFile); + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ENOENT"; + } + try { + return this.loadConfigOrDefault({ throwOnError: true }).telemetryEnabled === false; + } catch { + return true; + } + } + async setUpdateChannel(channel: UpdateChannel): Promise { await this.editConfig((config) => { config.updateChannel = channel; diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 51b1d5f7214..2282d3c03e1 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -683,26 +683,37 @@ export default function workflow() { return { reportMarkdown: "should not run" } describe("router config.saveConfig", () => { let tempDir: string; let config: Config; + let setConfigEnabledMock: ReturnType Promise>>; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-router-test-")); config = new Config(tempDir); + setConfigEnabledMock = mock((_enabled: boolean) => Promise.resolve()); }); afterEach(() => { + // The write-failure test locks the dir; restore perms so cleanup succeeds. + try { + fs.chmodSync(tempDir, 0o700); + } catch { + // Already removed or never locked. + } fs.rmSync(tempDir, { recursive: true, force: true }); }); function createContext(): ORPCContext { - // saveConfig only touches Config and TaskService, so this partial context keeps the - // router-level test focused on the config mutation under test. - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Other services are not used by saveConfig. + // These config-route tests touch Config, TaskService, and (via getConfig / + // updateTelemetryEnabled) TelemetryService; stub the rest of the container. return { config, taskService: { maybeStartQueuedTasks: () => Promise.resolve(undefined), }, - } as ORPCContext; + telemetryService: { + isDisabledByEnv: () => false, + setConfigEnabled: setConfigEnabledMock, + }, + } as unknown as ORPCContext; } test("preserves agent enable flags when a mirrored legacy subagent entry is removed", async () => { @@ -756,6 +767,65 @@ describe("router config.saveConfig", () => { expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); }); + test("updateTelemetryEnabled persists sparsely and applies the toggle to the live service", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + await client.config.updateTelemetryEnabled({ enabled: false }); + + expect(config.loadConfigOrDefault().telemetryEnabled).toBe(false); + expect(setConfigEnabledMock).toHaveBeenLastCalledWith(false); + + await client.config.updateTelemetryEnabled({ enabled: true }); + + // Enabled is the default: re-enabling clears the key instead of storing true. + expect(config.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(setConfigEnabledMock).toHaveBeenLastCalledWith(true); + }); + + test("updateTelemetryEnabled fails loudly when the config write does not land", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + // saveConfig writes atomically (temp file + rename in the config dir), so a + // read-only dir makes the write fail. saveConfig swallows that error; the + // route must detect it anyway rather than report success for a privacy + // setting that will silently revert on next launch. + fs.chmodSync(tempDir, 0o500); + try { + await expect(client.config.updateTelemetryEnabled({ enabled: false })).rejects.toThrow( + /persist the telemetry preference/ + ); + } finally { + fs.chmodSync(tempDir, 0o700); + } + + expect(config.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(setConfigEnabledMock).not.toHaveBeenCalled(); + }); + + test("updateTelemetryEnabled fails when persistence cannot be verified", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + // Materialize config.json, then make it unreadable AND the dir unwritable: + // the disable write is swallowed and the verification read fails. A read + // failure must fail the RPC — it must not masquerade as a confirmed + // opt-out (the fail-closed enablement read would report disabled here). + await client.config.updateChatTranscriptFullWidth({ enabled: true }); + const configFile = path.join(tempDir, "config.json"); + fs.chmodSync(configFile, 0o000); + fs.chmodSync(tempDir, 0o500); + try { + await expect(client.config.updateTelemetryEnabled({ enabled: false })).rejects.toThrow( + /telemetry preference/ + ); + } finally { + fs.chmodSync(tempDir, 0o700); + fs.chmodSync(configFile, 0o600); + } + + expect(config.loadConfigOrDefault().telemetryEnabled).toBeUndefined(); + expect(setConfigEnabledMock).not.toHaveBeenCalled(); + }); + test("getConfig and saveConfig round trip user preferences", async () => { const client = createRouterClient(router(), { context: createContext() }); diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 1cd3cd5dd74..d88c2f1c84c 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1086,6 +1086,8 @@ export const router = (authToken?: string) => { muxGovernorEnrolled, chatTranscriptFullWidth: config.chatTranscriptFullWidth === true, llmDebugLogs: config.llmDebugLogs === true, + telemetryEnabled: config.telemetryEnabled !== false, + telemetryDisabledByEnv: context.telemetryService.isDisabledByEnv(), heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined, heartbeatDefaultIntervalMs: config.heartbeatDefaultIntervalMs ?? undefined, goalDefaults: normalizeGoalDefaults(config.goalDefaults ?? DEFAULT_GOAL_DEFAULTS), @@ -1529,6 +1531,45 @@ export const router = (authToken?: string) => { return config; }); }), + updateTelemetryEnabled: t + .input(schemas.config.updateTelemetryEnabled.input) + .output(schemas.config.updateTelemetryEnabled.output) + .handler(async ({ context, input }) => { + await context.config.editConfig((config) => { + // Keep the stored config sparse: enabled is the default. + if (input.enabled) { + delete config.telemetryEnabled; + } else { + config.telemetryEnabled = false; + } + return config; + }); + // saveConfig swallows write errors (a full disk still resolves), but a + // privacy opt-out must not report success while the persisted state says + // "enabled" — the choice would silently un-apply on next launch. Re-read + // the disk STRICTLY and fail loudly, before touching the live client. + // isTelemetryDisabledByConfig() is deliberately not used here: its + // fail-closed read (unreadable ⇒ disabled) is right for enablement + // checks but would let a failed write + failed read masquerade as a + // confirmed opt-out. + let persistedDisabled: boolean; + try { + persistedDisabled = + context.config.loadConfigOrDefault({ throwOnError: true }).telemetryEnabled === false; + } catch { + throw new Error( + "Could not verify the telemetry preference was persisted to config.json; the setting was not changed." + ); + } + if (persistedDisabled !== !input.enabled) { + throw new Error( + "Failed to persist the telemetry preference to config.json; the setting was not changed." + ); + } + // Apply immediately: disabling shuts the client down mid-session, + // enabling re-runs the full enablement check (env vars still win). + await context.telemetryService.setConfigEnabled(input.enabled); + }), updateHeartbeatDefaultPrompt: t .input(schemas.config.updateHeartbeatDefaultPrompt.input) .output(schemas.config.updateHeartbeatDefaultPrompt.output) diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 0fb29db5fec..ab799bb81ba 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6782,13 +6782,15 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "## Disabling telemetry", "", - "To disable telemetry, set `MUX_DISABLE_TELEMETRY` before starting the app:", + "Toggle **Usage Telemetry** off in **Settings → General**. The change applies immediately (no restart) and persists in `~/.mux/config.json` as `telemetryEnabled: false`.", + "", + "Alternatively, set `MUX_DISABLE_TELEMETRY` to exactly `1` before starting the app (other values like `true` are ignored):", "", "```bash", "MUX_DISABLE_TELEMETRY=1 mux", "```", "", - "This disables telemetry collection at the backend level.", + "The environment variable is a hard override: when set to `1`, telemetry stays off regardless of the Settings toggle, and the toggle renders disabled with a note saying so. Both switches disable collection at the backend level.", "", "## Source code", "", diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 6e9dd78d081..5bc16546e84 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -150,7 +150,9 @@ export class ServiceContainer { // Cross-cutting services: created first so they can be passed to core // services via constructor params (no setter injection needed). this.policyService = new PolicyService(config); - this.telemetryService = new TelemetryService(config.rootDir); + this.telemetryService = new TelemetryService(config.rootDir, () => + config.isTelemetryDisabledByConfig() + ); this.experimentsService = new ExperimentsService({ telemetryService: this.telemetryService, muxHome: config.rootDir, diff --git a/src/node/services/telemetryService.test.ts b/src/node/services/telemetryService.test.ts index b5e1ea9030a..444b28e3dae 100644 --- a/src/node/services/telemetryService.test.ts +++ b/src/node/services/telemetryService.test.ts @@ -1,12 +1,17 @@ import { describe, expect, test } from "bun:test"; -import { shouldEnableTelemetry, type TelemetryEnablementContext } from "./telemetryService"; +import { + shouldEnableTelemetry, + TelemetryService, + type TelemetryEnablementContext, +} from "./telemetryService"; function createContext(overrides: Partial): TelemetryEnablementContext { return { env: overrides.env ?? {}, isElectron: overrides.isElectron ?? false, isPackaged: overrides.isPackaged ?? null, + disabledByConfig: overrides.disabledByConfig, }; } @@ -84,6 +89,32 @@ describe("TelemetryService enablement", () => { expect(enabled).toBe(true); }); + test("disables telemetry when the config opt-out is set", () => { + const enabled = shouldEnableTelemetry( + createContext({ + env: {}, + isElectron: true, + isPackaged: true, + disabledByConfig: true, + }) + ); + + expect(enabled).toBe(false); + }); + + test("the env var hard-off wins even when config says enabled", () => { + const enabled = shouldEnableTelemetry( + createContext({ + env: { MUX_DISABLE_TELEMETRY: "1" }, + isElectron: true, + isPackaged: true, + disabledByConfig: false, + }) + ); + + expect(enabled).toBe(false); + }); + test("enables telemetry in NODE_ENV=development by default", () => { // Telemetry is now enabled by default in dev mode const enabled = shouldEnableTelemetry( @@ -108,6 +139,17 @@ describe("TelemetryService enablement", () => { expect(enabled).toBe(true); }); + test("isExplicitlyDisabled reflects the config opt-out like the env var", () => { + // Features gated on explicit opt-out (e.g. link sharing) must treat the + // Settings toggle the same as MUX_DISABLE_TELEMETRY=1. + let disabled = false; + const service = new TelemetryService(undefined, () => disabled); + + expect(service.isExplicitlyDisabled()).toBe(false); + disabled = true; + expect(service.isExplicitlyDisabled()).toBe(true); + }); + test("dev opt-in does not bypass test env disable", () => { const enabled = shouldEnableTelemetry( createContext({ diff --git a/src/node/services/telemetryService.ts b/src/node/services/telemetryService.ts index a2d0771593f..ea32447fb24 100644 --- a/src/node/services/telemetryService.ts +++ b/src/node/services/telemetryService.ts @@ -87,6 +87,8 @@ export interface TelemetryEnablementContext { env: NodeJS.ProcessEnv; isElectron: boolean; isPackaged: boolean | null; + /** User opt-out persisted in config.json (Settings → General). */ + disabledByConfig?: boolean; } export function shouldEnableTelemetry(context: TelemetryEnablementContext): boolean { @@ -95,6 +97,12 @@ export function shouldEnableTelemetry(context: TelemetryEnablementContext): bool return false; } + // User opt-out via config.json (telemetryEnabled: false). The env var and + // config switch are both hard-off; absence of both means enabled. + if (context.disabledByConfig === true) { + return false; + } + // Otherwise, telemetry is enabled (including dev mode) return true; } @@ -133,6 +141,12 @@ export class TelemetryService { private distinctId: string | null = null; private featureFlagVariants: Record = {}; private readonly muxHome: string; + private readonly isDisabledByConfig?: () => boolean; + private initInFlight: Promise | null = null; + private configApplyChain: Promise = Promise.resolve(); + /** Rate limit for capture()'s lazy cross-process re-enable initialization. */ + private static readonly LAZY_INIT_RETRY_MS = 30_000; + private lastLazyInitAttemptMs = 0; /** * Check if telemetry is enabled. @@ -143,13 +157,19 @@ export class TelemetryService { } /** - * Check if telemetry was explicitly disabled by the user via MUX_DISABLE_TELEMETRY=1. - * This is different from isEnabled() which also returns false in dev mode. - * Used to gate features like link sharing that should only be hidden when - * the user explicitly opts out of mux services. + * Check if telemetry was explicitly disabled by the user — either via + * MUX_DISABLE_TELEMETRY=1 or the Settings → General opt-out. This is + * different from isEnabled() which also returns false in test/CI contexts. + * Consumers gating on explicit opt-out must treat both switches the same; + * the docs present them as equivalent. */ isExplicitlyDisabled(): boolean { - return process.env.MUX_DISABLE_TELEMETRY === "1"; + return process.env.MUX_DISABLE_TELEMETRY === "1" || this.isDisabledByConfig?.() === true; + } + + /** The environment gate alone (env var, CI, tests) — surfaced to the UI so the Settings toggle can render as hard-disabled. */ + isDisabledByEnv(): boolean { + return isTelemetryDisabledByEnv(process.env); } /** @@ -179,15 +199,52 @@ export class TelemetryService { this.featureFlagVariants[key] = variant; } - constructor(muxHome?: string) { + constructor(muxHome?: string, isDisabledByConfig?: () => boolean) { this.muxHome = muxHome ?? getMuxHome(); + this.isDisabledByConfig = isDisabledByConfig; + } + + /** + * Apply the Settings → General telemetry toggle at runtime: disabling shuts + * the PostHog client down (capture() no-ops on a null client), enabling + * re-runs initialize(), which re-checks every enablement gate. + * + * Applies are serialized across ALL callers: the desktop Settings pane and + * API-server clients drive the same router in one process with no shared + * frontend chain, and an unserialized shutdown/initialize interleaving can + * resurrect a capturing client after an opt-out, kill telemetry while the + * switch shows on, or orphan an unflushed client. + */ + async setConfigEnabled(enabled: boolean): Promise { + const next = this.configApplyChain.then(() => (enabled ? this.initialize() : this.shutdown())); + // Keep the chain usable after a failed apply. + this.configApplyChain = next.then( + () => undefined, + () => undefined + ); + return next; } /** * Initialize the PostHog client. * Should be called once on app startup. + * + * Re-entrancy-safe: the null-client guard and the client assignment are + * separated by awaits, so two concurrent initializes would otherwise both + * pass the guard and orphan a live client. */ async initialize(): Promise { + if (this.initInFlight) { + return this.initInFlight; + } + const run = this.initializeOnce().finally(() => { + this.initInFlight = null; + }); + this.initInFlight = run; + return run; + } + + private async initializeOnce(): Promise { if (this.client) { return; } @@ -201,8 +258,9 @@ export class TelemetryService { const isElectron = typeof process.versions.electron === "string"; const isPackaged = await getElectronIsPackaged(isElectron); + const disabledByConfig = this.isDisabledByConfig?.() === true; - if (!shouldEnableTelemetry({ env, isElectron, isPackaged })) { + if (!shouldEnableTelemetry({ env, isElectron, isPackaged, disabledByConfig })) { return; } @@ -268,7 +326,27 @@ export class TelemetryService { * Events are silently ignored when disabled. */ capture(payload: TelemetryEventPayload): void { - if (isTelemetryDisabledByEnv(process.env) || !this.client || !this.distinctId) { + // The config opt-out is re-checked per event, not just at initialize(): + // a second mux process sharing ~/.mux/config.json (mux server alongside + // the desktop app) must stop capturing when the user opts out in the + // other process. Event volume is low (discrete user actions), so the + // config read is acceptable here for a privacy control. + if (isTelemetryDisabledByEnv(process.env) || this.isDisabledByConfig?.() === true) { + return; + } + + if (!this.client || !this.distinctId) { + // Cross-process re-enable: this process may have started while the + // shared config said opted-out (client never created) and another + // process has since re-enabled. Kick a lazy, serialized initialize — + // rate-limited because every enablement gate (dev mode, packaging) + // still applies and may legitimately keep the client null. The current + // event is dropped; the process converges for subsequent ones. + const now = Date.now(); + if (now - this.lastLazyInitAttemptMs > TelemetryService.LAZY_INIT_RETRY_MS) { + this.lastLazyInitAttemptMs = now; + void this.initialize().catch(() => undefined); + } return; } @@ -290,16 +368,19 @@ export class TelemetryService { * Should be called on app close. */ async shutdown(): Promise { - if (!this.client) { + // Null BEFORE flushing: capture() must no-op the instant a shutdown + // begins, and a concurrent initialize() must never observe the stale + // client and skip re-initialization. + const client = this.client; + this.client = null; + if (!client) { return; } try { - await this.client.shutdown(); + await client.shutdown(); } catch { // Silently ignore shutdown errors } - - this.client = null; } }