From 75b0bade55d2ed8caa0ae7de9c0c8980875ddd9f Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:20:02 -0700 Subject: [PATCH 01/12] feat: add Usage Telemetry toggle to Settings -> General telemetryEnabled config field consulted by the telemetry service (env var MUX_DISABLE_TELEMETRY remains a hard override); toggling applies live by shutting down or re-initializing the PostHog client. Co-Authored-By: Claude Fable 5 --- docs/reference/telemetry.mdx | 6 ++- .../Settings/Sections/GeneralSection.test.tsx | 51 +++++++++++++++++- .../Settings/Sections/GeneralSection.tsx | 53 +++++++++++++++++++ src/browser/stories/mocks/orpc.ts | 7 +++ src/common/config/schemas/appConfigOnDisk.ts | 6 +++ src/common/orpc/schemas/api.ts | 2 + src/common/types/project.ts | 2 + src/node/config.telemetryEnabled.test.ts | 37 +++++++++++++ src/node/config.ts | 11 ++++ src/node/orpc/router.ts | 18 +++++++ .../builtInSkillContent.generated.ts | 6 ++- src/node/services/serviceContainer.ts | 4 +- src/node/services/telemetryService.test.ts | 27 ++++++++++ src/node/services/telemetryService.ts | 28 +++++++++- 14 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 src/node/config.telemetryEnabled.test.ts diff --git a/docs/reference/telemetry.mdx b/docs/reference/telemetry.mdx index d4e44f9b4a1..d080ba5ff10 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` before starting the app: ```bash MUX_DISABLE_TELEMETRY=1 mux ``` -This disables telemetry collection at the backend level. +The environment variable is a hard override: when set, telemetry stays off regardless of the Settings toggle. 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..9d1fd49d551 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -19,6 +19,7 @@ interface MockConfig { worktreeArchiveBehavior: WorktreeArchiveBehavior; chatTranscriptFullWidth: boolean; llmDebugLogs: boolean; + telemetryEnabled: boolean; } interface MockAPIClient { @@ -30,6 +31,7 @@ interface MockAPIClient { }) => Promise; updateChatTranscriptFullWidth: (input: { enabled: boolean }) => Promise; updateLlmDebugLogs: (input: { enabled: boolean }) => Promise; + updateTelemetryEnabled: (input: { enabled: boolean }) => Promise; }; server: { getSshHost: () => Promise; @@ -171,6 +173,7 @@ interface RenderGeneralSectionOptions { coderWorkspaceArchiveBehavior?: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior?: WorktreeArchiveBehavior; chatTranscriptFullWidth?: boolean; + telemetryEnabled?: boolean; } interface MockAPISetup { @@ -187,6 +190,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 +201,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, chatTranscriptFullWidth: false, llmDebugLogs: false, + telemetryEnabled: true, ...configOverrides, }; @@ -217,6 +224,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 +241,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup return Promise.resolve(); }), + updateTelemetryEnabled: updateTelemetryEnabledMock, }, server: { getSshHost: mock(() => Promise.resolve(null)), @@ -241,6 +255,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup getConfigMock, updateCoderPrefsMock, updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, }; } @@ -263,10 +278,18 @@ 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 } + : {}), }); mockApi = api; @@ -276,7 +299,12 @@ describe("GeneralSection", () => { ); - return { updateCoderPrefsMock, updateChatTranscriptFullWidthMock, view }; + return { + updateCoderPrefsMock, + updateChatTranscriptFullWidthMock, + updateTelemetryEnabledMock, + view, + }; } function getSelectTrigger(view: ReturnType, label: string): HTMLElement { @@ -353,6 +381,25 @@ 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 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..ec7cf2d7a88 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -207,6 +207,8 @@ 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); const archiveBehaviorLoadNonceRef = useRef(0); const archiveBehaviorRef = useRef(DEFAULT_CODER_ARCHIVE_BEHAVIOR); const worktreeArchiveBehaviorRef = useRef( @@ -215,12 +217,14 @@ export function GeneralSection() { const chatTranscriptFullWidthLoadNonceRef = useRef(0); const llmDebugLogsLoadNonceRef = useRef(0); + const telemetryEnabledLoadNonceRef = useRef(0); // 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 +241,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 +277,10 @@ export function GeneralSection() { if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { setLlmDebugLogs(cfg.llmDebugLogs === true); } + + if (telemetryEnabledNonce === telemetryEnabledLoadNonceRef.current) { + setTelemetryEnabled(cfg.telemetryEnabled !== false); + } }) .catch(() => { if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) { @@ -406,6 +415,29 @@ export function GeneralSection() { }); }; + const handleTelemetryEnabledChange = (checked: boolean) => { + // Invalidate any in-flight config load so it doesn't overwrite the user's selection. + telemetryEnabledLoadNonceRef.current++; + setTelemetryEnabled(checked); + + if (!api?.config?.updateTelemetryEnabled) { + return; + } + + // 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(() => { + // Best-effort persistence. + }); + }; + // Load SSH host from server on mount (browser mode only) useEffect(() => { if (isBrowserMode && api) { @@ -694,6 +726,27 @@ export function GeneralSection() { aria-label="Toggle API Debug Logs" /> +
+
+
Usage Telemetry
+
+ Send anonymous usage events to help improve mux — no code, paths, or prompts.{" "} + + What is collected + +
+
+ +
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index 7ec8973c548..bdcf7196209 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -640,6 +640,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; let subagentAiDefaults = deriveSubagentAiDefaults(); + let telemetryEnabled = true; const mockStats: ChatStats = { consumers: [], @@ -783,6 +784,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl chatTranscriptFullWidth, muxGovernorEnrolled, llmDebugLogs: false, + telemetryEnabled, }), saveConfig: (input: { taskSettings?: unknown; @@ -842,6 +844,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..d9b19573829 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2304,6 +2304,7 @@ export const config = { muxGovernorEnrolled: z.boolean(), chatTranscriptFullWidth: z.boolean(), llmDebugLogs: z.boolean(), + telemetryEnabled: z.boolean(), heartbeatDefaultPrompt: z.string().optional(), heartbeatDefaultIntervalMs: z.number().optional(), goalDefaults: GoalDefaultsConfigSchema, @@ -2393,6 +2394,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..1be821234f2 --- /dev/null +++ b/src/node/config.telemetryEnabled.test.ts @@ -0,0 +1,37 @@ +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("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..ce1ea86daf3 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,11 @@ export class Config { return this.loadConfigOrDefault().llmDebugLogs === true; } + /** Settings → General telemetry opt-out; absent means enabled. */ + isTelemetryDisabledByConfig(): boolean { + return this.loadConfigOrDefault().telemetryEnabled === false; + } + async setUpdateChannel(channel: UpdateChannel): Promise { await this.editConfig((config) => { config.updateChannel = channel; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 1cd3cd5dd74..23f8e78c7b9 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1086,6 +1086,7 @@ export const router = (authToken?: string) => { muxGovernorEnrolled, chatTranscriptFullWidth: config.chatTranscriptFullWidth === true, llmDebugLogs: config.llmDebugLogs === true, + telemetryEnabled: config.telemetryEnabled !== false, heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined, heartbeatDefaultIntervalMs: config.heartbeatDefaultIntervalMs ?? undefined, goalDefaults: normalizeGoalDefaults(config.goalDefaults ?? DEFAULT_GOAL_DEFAULTS), @@ -1529,6 +1530,23 @@ 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; + }); + // 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..34c32b6bc18 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` before starting the app:", "", "```bash", "MUX_DISABLE_TELEMETRY=1 mux", "```", "", - "This disables telemetry collection at the backend level.", + "The environment variable is a hard override: when set, telemetry stays off regardless of the Settings toggle. 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..24b8500facd 100644 --- a/src/node/services/telemetryService.test.ts +++ b/src/node/services/telemetryService.test.ts @@ -7,6 +7,7 @@ function createContext(overrides: Partial): Telemetr env: overrides.env ?? {}, isElectron: overrides.isElectron ?? false, isPackaged: overrides.isPackaged ?? null, + disabledByConfig: overrides.disabledByConfig, }; } @@ -84,6 +85,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( diff --git a/src/node/services/telemetryService.ts b/src/node/services/telemetryService.ts index a2d0771593f..4ce09e48922 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,7 @@ export class TelemetryService { private distinctId: string | null = null; private featureFlagVariants: Record = {}; private readonly muxHome: string; + private readonly isDisabledByConfig?: () => boolean; /** * Check if telemetry is enabled. @@ -179,8 +188,22 @@ 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. + */ + async setConfigEnabled(enabled: boolean): Promise { + if (!enabled) { + await this.shutdown(); + return; + } + await this.initialize(); } /** @@ -201,8 +224,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; } From afb997e8f6d3eb3e1bb9d5aec72e4d5c6f8d52ed Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:03:58 -0700 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20harden=20telemetry?= =?UTF-8?q?=20toggle=20lifecycle=20and=20env-disabled=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Serialize setConfigEnabled applies and make initialize() re-entrant so rapid Settings toggles can't interleave PostHog shutdown/init and strand a live client after the user opted out. - Null the client before awaiting shutdown so no event can be captured into a flushing client, and re-check the config opt-out per capture() for API-server callers that bypass the toggle route. - isExplicitlyDisabled() now includes the config opt-out so features gated on explicit opt-out (e.g. link sharing) treat the Settings toggle the same as MUX_DISABLE_TELEMETRY=1. - Expose telemetryDisabledByEnv via getConfig and render the switch hard-disabled with an explanatory note when the environment override is active, instead of pretending the toggle controls anything. - Revert the optimistic switch state when persisting the change fails — a privacy control must not read "off" while collection continues. - Move the toggle into its own Privacy group and document that the env var must be exactly "1". Co-Authored-By: Claude Fable 5 --- docs/reference/telemetry.mdx | 4 +- .../Settings/Sections/GeneralSection.test.tsx | 52 +++++++++++++ .../Settings/Sections/GeneralSection.tsx | 23 +++++- src/browser/stories/mocks/orpc.ts | 6 +- src/common/orpc/schemas/api.ts | 4 + src/node/orpc/router.ts | 1 + .../builtInSkillContent.generated.ts | 4 +- src/node/services/telemetryService.test.ts | 17 ++++- src/node/services/telemetryService.ts | 74 +++++++++++++++---- 9 files changed, 162 insertions(+), 23 deletions(-) diff --git a/docs/reference/telemetry.mdx b/docs/reference/telemetry.mdx index d080ba5ff10..65dc5f3f541 100644 --- a/docs/reference/telemetry.mdx +++ b/docs/reference/telemetry.mdx @@ -40,13 +40,13 @@ All telemetry events include basic system information: 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` before starting the app: +Alternatively, set `MUX_DISABLE_TELEMETRY` to exactly `1` before starting the app (other values like `true` are ignored): ```bash MUX_DISABLE_TELEMETRY=1 mux ``` -The environment variable is a hard override: when set, telemetry stays off regardless of the Settings toggle. Both switches disable 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 9d1fd49d551..b0134a46101 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -20,6 +20,7 @@ interface MockConfig { chatTranscriptFullWidth: boolean; llmDebugLogs: boolean; telemetryEnabled: boolean; + telemetryDisabledByEnv: boolean; } interface MockAPIClient { @@ -174,6 +175,7 @@ interface RenderGeneralSectionOptions { worktreeArchiveBehavior?: WorktreeArchiveBehavior; chatTranscriptFullWidth?: boolean; telemetryEnabled?: boolean; + telemetryDisabledByEnv?: boolean; } interface MockAPISetup { @@ -202,6 +204,7 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup chatTranscriptFullWidth: false, llmDebugLogs: false, telemetryEnabled: true, + telemetryDisabledByEnv: false, ...configOverrides, }; @@ -290,6 +293,9 @@ describe("GeneralSection", () => { ...(options.telemetryEnabled !== undefined ? { telemetryEnabled: options.telemetryEnabled } : {}), + ...(options.telemetryDisabledByEnv !== undefined + ? { telemetryDisabledByEnv: options.telemetryDisabledByEnv } + : {}), }); mockApi = api; @@ -400,6 +406,52 @@ describe("GeneralSection", () => { }); }); + 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 flips the optimistic state back to enabled. + await waitFor(() => { + expect(updateTelemetryEnabledMock).toHaveBeenCalledWith({ enabled: false }); + 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 ec7cf2d7a88..56cc4ed816e 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -209,6 +209,9 @@ export function GeneralSection() { 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( @@ -280,6 +283,7 @@ export function GeneralSection() { if (telemetryEnabledNonce === telemetryEnabledLoadNonceRef.current) { setTelemetryEnabled(cfg.telemetryEnabled !== false); + setTelemetryDisabledByEnv(cfg.telemetryDisabledByEnv === true); } }) .catch(() => { @@ -434,7 +438,9 @@ export function GeneralSection() { // Coerce the chain back to Promise. }) .catch(() => { - // Best-effort persistence. + // A privacy control must never read "off" while collection continues: + // on a failed write, revert the optimistic state to the backend truth. + setTelemetryEnabled(!checked); }); }; @@ -726,6 +732,12 @@ export function GeneralSection() { aria-label="Toggle API Debug Logs" /> + + + +
+

Privacy

+
Usage Telemetry
@@ -739,11 +751,18 @@ export function GeneralSection() { > What is collected + {telemetryDisabledByEnv && ( + + Disabled by the environment (MUX_DISABLE_TELEMETRY / CI) — this switch has no + effect until that is removed. + + )}
diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index bdcf7196209..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,7 +643,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl let layoutPresets = initialLayoutPresets ?? DEFAULT_LAYOUT_PRESETS_CONFIG; let subagentAiDefaults = deriveSubagentAiDefaults(); - let telemetryEnabled = true; + let telemetryEnabled = initialTelemetryEnabled ?? true; const mockStats: ChatStats = { consumers: [], @@ -785,6 +788,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl muxGovernorEnrolled, llmDebugLogs: false, telemetryEnabled, + telemetryDisabledByEnv: false, }), saveConfig: (input: { taskSettings?: unknown; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index d9b19573829..124175d0d29 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -2305,6 +2305,10 @@ export const config = { 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, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 23f8e78c7b9..4f248d809e2 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1087,6 +1087,7 @@ export const router = (authToken?: string) => { 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), diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index 34c32b6bc18..ab799bb81ba 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -6784,13 +6784,13 @@ export const BUILTIN_SKILL_FILES: Record> = { "", "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` before starting the app:", + "Alternatively, set `MUX_DISABLE_TELEMETRY` to exactly `1` before starting the app (other values like `true` are ignored):", "", "```bash", "MUX_DISABLE_TELEMETRY=1 mux", "```", "", - "The environment variable is a hard override: when set, telemetry stays off regardless of the Settings toggle. Both switches disable 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/telemetryService.test.ts b/src/node/services/telemetryService.test.ts index 24b8500facd..444b28e3dae 100644 --- a/src/node/services/telemetryService.test.ts +++ b/src/node/services/telemetryService.test.ts @@ -1,6 +1,10 @@ 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 { @@ -135,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 4ce09e48922..ecdeb56bf65 100644 --- a/src/node/services/telemetryService.ts +++ b/src/node/services/telemetryService.ts @@ -142,6 +142,8 @@ export class TelemetryService { private featureFlagVariants: Record = {}; private readonly muxHome: string; private readonly isDisabledByConfig?: () => boolean; + private initInFlight: Promise | null = null; + private configApplyChain: Promise = Promise.resolve(); /** * Check if telemetry is enabled. @@ -152,13 +154,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); } /** @@ -197,20 +205,43 @@ export class TelemetryService { * 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 { - if (!enabled) { - await this.shutdown(); - return; - } - await this.initialize(); + 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; } @@ -292,7 +323,17 @@ 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 || + !this.client || + !this.distinctId + ) { return; } @@ -314,16 +355,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; } } From c405aeedd89f77902944595fe70c88c7d7094769 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:52:48 -0700 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify=20telemetry?= =?UTF-8?q?=20persistence=20and=20guard=20stale=20toggle=20reverts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 1: - updateTelemetryEnabled now re-reads the config from disk after editConfig and fails loudly on mismatch before touching the live client: saveConfig swallows write errors (full disk, unwritable config.json), so the route could report success for a privacy opt-out that silently reverts on next launch. Router test proves it by making the config dir read-only. - The Settings switch tags each toggle with a monotonically increasing intent id: a superseded request's failure no longer blind-flips the switch (clobbering the user's latest choice mid rapid-toggle), and the latest intent's failure reloads the backend truth instead of guessing. - Fix router.test.ts config-route tests broken by the earlier telemetryDisabledByEnv addition (partial ORPCContext now stubs telemetryService). Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.test.tsx | 53 +++++++++++++++++- .../Settings/Sections/GeneralSection.tsx | 28 ++++++++-- src/node/orpc/router.test.ts | 54 +++++++++++++++++-- src/node/orpc/router.ts | 10 ++++ 4 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index b0134a46101..954932a3f86 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -445,13 +445,64 @@ describe("GeneralSection", () => { fireEvent.click(toggle); // A privacy control must not read "off" while the backend still collects: - // the failed write flips the optimistic state back to enabled. + // the failed write reloads the backend truth (still enabled). 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 56cc4ed816e..44e6b048c4b 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -221,6 +221,9 @@ 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); // 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. @@ -428,6 +431,8 @@ export function GeneralSection() { return; } + const intent = ++telemetryEnabledIntentRef.current; + // Serialize writes so rapid toggles always persist the last user choice. telemetryEnabledUpdateChainRef.current = telemetryEnabledUpdateChainRef.current .catch(() => { @@ -437,10 +442,25 @@ export function GeneralSection() { .then(() => { // Coerce the chain back to Promise. }) - .catch(() => { - // A privacy control must never read "off" while collection continues: - // on a failed write, revert the optimistic state to the backend truth. - setTelemetryEnabled(!checked); + .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) { + setTelemetryEnabled(!checked); + } + } }); }; diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 51b1d5f7214..f20d6d58ab2 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,41 @@ 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("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 4f248d809e2..53ba91cb53f 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1544,6 +1544,16 @@ export const router = (authToken?: string) => { } 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 and fail loudly on mismatch, before touching the live client. + const persistedDisabled = context.config.isTelemetryDisabledByConfig(); + 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); From ffb5498264124a949d35693581b499951c41a77f Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:47:37 -0700 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20render=20indetermin?= =?UTF-8?q?ate=20telemetry=20state=20as=20ON?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 2: when a toggle write fails and the reconciling getConfig also fails (connection dropped after the request may have persisted and applied), the fallback rendered !checked — "off" after a failed enable while telemetry may actually be collecting. Indeterminate backend truth now always renders ON: showing "off" while collection may continue is the one lie a privacy toggle can't tell. The next successful config load reconciles the real value. Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.test.tsx | 32 +++++++++++++++++++ .../Settings/Sections/GeneralSection.tsx | 7 +++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index 954932a3f86..4aadb2baaf6 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -452,6 +452,38 @@ describe("GeneralSection", () => { }); }); + 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 }> = []; diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 44e6b048c4b..9f6c52c9212 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -458,7 +458,12 @@ export function GeneralSection() { } } catch { if (telemetryEnabledIntentRef.current === intent) { - setTelemetryEnabled(!checked); + // 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); } } }); From b481d1c0bb926515fb26abb650f8272e35622275 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 06:59:46 -0700 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20disable=20the=20tel?= =?UTF-8?q?emetry=20switch=20while=20the=20API=20is=20unavailable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 3: with a browser-mode outage (api: null, settings still mounted), clicking the switch flipped it optimistically and returned without issuing a write — rendering OFF while the backend may keep collecting, and silently discarding the intent. The switch now renders disabled without a usable API, and the handler refuses to flip before a deliverable write (covering the drop between render and click). Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.test.tsx | 22 ++++++++++++++++++- .../Settings/Sections/GeneralSection.tsx | 17 +++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index 4aadb2baaf6..755647ed27a 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -44,7 +44,7 @@ interface MockAPIClient { }; } -let mockApi: MockAPIClient; +let mockApi: MockAPIClient | null; void mock.module("@/browser/components/SelectPrimitive/SelectPrimitive", () => { const SelectContext = React.createContext<{ @@ -452,6 +452,26 @@ describe("GeneralSection", () => { }); }); + 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(() => diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 9f6c52c9212..41371fd45c3 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -423,14 +423,19 @@ export function GeneralSection() { }; const handleTelemetryEnabledChange = (checked: boolean) => { - // Invalidate any in-flight config load so it doesn't overwrite the user's selection. - telemetryEnabledLoadNonceRef.current++; - setTelemetryEnabled(checked); - + // 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; // Serialize writes so rapid toggles always persist the last user choice. @@ -787,7 +792,9 @@ export function GeneralSection() {
From 1553a5c0885cf7939b3a4ae2391be6a926ebd9e8 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:28:55 -0700 Subject: [PATCH 06/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20fail=20closed=20on?= =?UTF-8?q?=20unreadable=20telemetry=20config,=20sync=20toggle=20across=20?= =?UTF-8?q?clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 4: - isTelemetryDisabledByConfig() read through loadConfigOrDefault(), which swallows read/parse errors and returns defaults — a corrupted config.json silently re-enabled telemetry for an opted-out user at startup and per capture(). It now reads with throwOnError and fails CLOSED: an unreadable existing file reports disabled, while a missing file (fresh install) stays enabled and callers remain non-fatal. - GeneralSection consumes the config.onConfigChanged stream so a second window/tab tracks telemetry changes made elsewhere instead of showing a stale switch while collection state already changed. Refreshes are guarded by the load nonce plus a pending-writes counter so our own in-flight writes reconcile through their own settle path. Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.test.tsx | 53 +++++++++++++++ .../Settings/Sections/GeneralSection.tsx | 65 +++++++++++++++++++ src/node/config.telemetryEnabled.test.ts | 11 ++++ src/node/config.ts | 15 ++++- 4 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index 755647ed27a..ee685b1f154 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -33,6 +33,10 @@ interface MockAPIClient { 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; @@ -452,6 +456,55 @@ describe("GeneralSection", () => { }); }); + 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("disables the telemetry switch while the API is unavailable", () => { // Browser-mode outage: APIProvider keeps settings mounted with api: null. mockApi = null; diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 41371fd45c3..2373e0a5bb5 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -224,6 +224,10 @@ export function GeneralSection() { // 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 ignored while > 0: our own settled write emits a + // final notification that reconciles against the true persisted state. + const telemetryEnabledPendingWritesRef = useRef(0); // 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. @@ -437,6 +441,7 @@ export function GeneralSection() { setTelemetryEnabled(checked); const intent = ++telemetryEnabledIntentRef.current; + telemetryEnabledPendingWritesRef.current++; // Serialize writes so rapid toggles always persist the last user choice. telemetryEnabledUpdateChainRef.current = telemetryEnabledUpdateChainRef.current @@ -471,9 +476,69 @@ export function GeneralSection() { setTelemetryEnabled(true); } } + }) + .finally(() => { + telemetryEnabledPendingWritesRef.current--; }); }; + // 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 = async () => { + // Our own in-flight writes reconcile themselves; their settled write + // emits a final notification that lands here with the queue drained. + if (telemetryEnabledPendingWritesRef.current > 0) { + 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. + } + }; + + 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; + 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); + }; + }, [api]); + // Load SSH host from server on mount (browser mode only) useEffect(() => { if (isBrowserMode && api) { diff --git a/src/node/config.telemetryEnabled.test.ts b/src/node/config.telemetryEnabled.test.ts index 1be821234f2..dabd1e9be3b 100644 --- a/src/node/config.telemetryEnabled.test.ts +++ b/src/node/config.telemetryEnabled.test.ts @@ -16,6 +16,17 @@ describe("Config telemetryEnabled persistence", () => { 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("round-trips the opt-out through editConfig saves and reports it", async () => { const config = new Config(tempDir); expect(config.isTelemetryDisabledByConfig()).toBe(false); diff --git a/src/node/config.ts b/src/node/config.ts index ce1ea86daf3..aa8ce09d2c5 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1811,9 +1811,20 @@ export class Config { return this.loadConfigOrDefault().llmDebugLogs === true; } - /** Settings → General telemetry opt-out; absent means enabled. */ + /** + * Settings → General telemetry opt-out; absent means enabled. + * + * Fail CLOSED: when config.json exists but cannot be read or parsed, report + * disabled — corrupted persisted state must not silently override an + * opt-out. A missing file is not an error (fresh install ⇒ enabled), and + * callers stay non-fatal either way. + */ isTelemetryDisabledByConfig(): boolean { - return this.loadConfigOrDefault().telemetryEnabled === false; + try { + return this.loadConfigOrDefault({ throwOnError: true }).telemetryEnabled === false; + } catch { + return true; + } } async setUpdateChannel(channel: UpdateChannel): Promise { From fac87b3219e4f4b4ed1d89ad5eb503e61247975e Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:05:14 -0700 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20replay=20config=20n?= =?UTF-8?q?otifications=20deferred=20by=20in-flight=20telemetry=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 5: the pending-writes guard dropped notifications outright, and enqueueConfigEdit emits onConfigChanged before the RPC resolves — so even our own final write's notification arrives while the counter is positive, and an external change landing during the write window was lost forever (switch stuck OFF while another client enabled collection). Deferred notifications now set a flag that replays the backend refresh when the write queue drains. Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.test.tsx | 63 +++++++++++++++++++ .../Settings/Sections/GeneralSection.tsx | 56 ++++++++++++----- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index ee685b1f154..bd6dc41eb90 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -505,6 +505,69 @@ describe("GeneralSection", () => { }); }); + 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("disables the telemetry switch while the API is unavailable", () => { // Browser-mode outage: APIProvider keeps settings mounted with api: null. mockApi = null; diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 2373e0a5bb5..001cc7d8f74 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -225,9 +225,32 @@ export function GeneralSection() { // while its own intent is still the latest. const telemetryEnabledIntentRef = useRef(0); // Writes still in flight (including their failure reconciliation). Config - // change notifications are ignored while > 0: our own settled write emits a - // final notification that reconciles against the true persisted state. + // 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. @@ -479,6 +502,16 @@ export function GeneralSection() { }) .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). + if ( + telemetryEnabledPendingWritesRef.current === 0 && + telemetryEnabledMissedNotificationRef.current + ) { + telemetryEnabledMissedNotificationRef.current = false; + void refreshTelemetryFromBackend(); + } }); }; @@ -493,22 +526,14 @@ export function GeneralSection() { const signal = abortController.signal; let iterator: AsyncIterator | null = null; - const refreshTelemetry = async () => { - // Our own in-flight writes reconcile themselves; their settled write - // emits a final notification that lands here with the queue drained. + 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; } - 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. - } + void refreshTelemetryFromBackend(); }; const subscription = (async () => { @@ -537,6 +562,7 @@ export function GeneralSection() { 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) From 0de49cb13e0f93d73663a098270aa9c25ffa8d0c Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:26:25 -0700 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20verify=20telemetry?= =?UTF-8?q?=20persistence=20with=20a=20strict=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 6: the verification step reused isTelemetryDisabledByConfig(), whose fail-closed read (unreadable file => disabled) is right for enablement checks but let a failed disable write plus a failed read masquerade as a confirmed opt-out — the RPC reported success for a preference that resumes collecting on restart. The route now re-reads with throwOnError and a read failure fails the RPC with a distinct error, before touching the live client. Co-Authored-By: Claude Fable 5 --- src/node/orpc/router.test.ts | 24 ++++++++++++++++++++++++ src/node/orpc/router.ts | 16 ++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index f20d6d58ab2..2282d3c03e1 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -802,6 +802,30 @@ describe("router config.saveConfig", () => { 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 53ba91cb53f..d88c2f1c84c 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1547,8 +1547,20 @@ export const router = (authToken?: string) => { // 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 and fail loudly on mismatch, before touching the live client. - const persistedDisabled = context.config.isTelemetryDisabledByConfig(); + // 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." From 6be7519d17bd8c01e0da5ba6503ea5ef6db233a6 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:46:18 -0700 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20treat=20an=20inacce?= =?UTF-8?q?ssible=20config=20directory=20as=20a=20possible=20opt-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 7: loadConfigOrDefault guards its read with existsSync(), which reports EACCES traversal failures as "missing" — so an opted-out user whose ~/.mux becomes unreachable read as enabled-by-default despite the strict-read fix. isTelemetryDisabledByConfig now stats the file explicitly: only a genuine ENOENT (fresh install) means enabled; every other stat/read/parse failure fails closed, without crashing startup. Co-Authored-By: Claude Fable 5 --- src/node/config.telemetryEnabled.test.ts | 15 +++++++++++++++ src/node/config.ts | 15 +++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/node/config.telemetryEnabled.test.ts b/src/node/config.telemetryEnabled.test.ts index dabd1e9be3b..dd64fe030fa 100644 --- a/src/node/config.telemetryEnabled.test.ts +++ b/src/node/config.telemetryEnabled.test.ts @@ -27,6 +27,21 @@ describe("Config telemetryEnabled persistence", () => { 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); diff --git a/src/node/config.ts b/src/node/config.ts index aa8ce09d2c5..883052048e8 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -1814,12 +1814,19 @@ export class Config { /** * Settings → General telemetry opt-out; absent means enabled. * - * Fail CLOSED: when config.json exists but cannot be read or parsed, report - * disabled — corrupted persisted state must not silently override an - * opt-out. A missing file is not an error (fresh install ⇒ enabled), and - * callers stay non-fatal either way. + * 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 { From 4841abe04574453a8faea7696d61fe801e3569a3 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:58:58 -0700 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20re-sync=20telemetry?= =?UTF-8?q?=20state=20once=20the=20config=20subscription=20connects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 8: a config change landing between the initial getConfig snapshot and the onConfigChanged subscription's establishment had no listener — the switch stayed stale until the next unrelated edit. The subscription now refreshes once connected, closing the gap deterministically. Test holds the subscription unestablished while an external opt-out lands and asserts the switch syncs on connect. Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.test.tsx | 53 +++++++++++++++++++ .../Settings/Sections/GeneralSection.tsx | 4 ++ 2 files changed, 57 insertions(+) diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index bd6dc41eb90..5b59989bffb 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -505,6 +505,59 @@ describe("GeneralSection", () => { }); }); + 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; diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 001cc7d8f74..8e4b516236b 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -545,6 +545,10 @@ export function GeneralSection() { 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; From 582435800d469a09d36b8d139e21fbf24d46d7f7 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:21:20 -0700 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20revive=20telemetry?= =?UTF-8?q?=20after=20cross-process=20re-enable,=20drop=20stale=20client?= =?UTF-8?q?=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 9: - A process that started while the shared config said opted-out never creates a PostHog client, so another process's re-enable left it dead until restart. capture() now kicks a lazy serialized initialize() when config says enabled but the client is null — rate-limited (30s) because the other enablement gates may legitimately keep it null. - An API replacement (browser-mode reconnect) bumps the telemetry intent counter, so a late rejection from the superseded client cannot run failure reconciliation against state the new client has since confirmed; the config subscription re-establishes and re-syncs on the new client. Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.tsx | 9 +++++++ src/node/services/telemetryService.ts | 25 ++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 8e4b516236b..28a5ba4ee83 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -515,6 +515,15 @@ export function GeneralSection() { }); }; + // 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++; + }, [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. diff --git a/src/node/services/telemetryService.ts b/src/node/services/telemetryService.ts index ecdeb56bf65..ea32447fb24 100644 --- a/src/node/services/telemetryService.ts +++ b/src/node/services/telemetryService.ts @@ -144,6 +144,9 @@ export class TelemetryService { 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. @@ -328,12 +331,22 @@ export class TelemetryService { // 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 || - !this.client || - !this.distinctId - ) { + 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; } From a712b20e38abe9ede10432e4bfcfcd761cb35ba7 Mon Sep 17 00:00:00 2001 From: asm <113964+asm@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:53:41 -0700 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=A4=96=20fix:=20replay=20deferred?= =?UTF-8?q?=20telemetry=20sync=20through=20the=20current=20API=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 10: the settle-replay callback captured the render's refreshTelemetryFromBackend, so a write settling after an API replacement replayed the deferred notification through the disconnected client — a failed read there consumed the notification and stranded the switch stale. Replays now go through a ref the api-change effect keeps pointed at the current client generation. Co-Authored-By: Claude Fable 5 --- .../Settings/Sections/GeneralSection.test.tsx | 66 +++++++++++++++++++ .../Settings/Sections/GeneralSection.tsx | 16 ++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index 5b59989bffb..62cf59f40c2 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -621,6 +621,72 @@ describe("GeneralSection", () => { }); }); + 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; diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 28a5ba4ee83..123ad10b2c0 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -504,17 +504,27 @@ export function GeneralSection() { 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). + // 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; - void refreshTelemetryFromBackend(); + 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 @@ -522,6 +532,8 @@ export function GeneralSection() { // 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