Skip to content

Commit 2637e20

Browse files
committed
fix(telemetry): default telemetry to opt-out with explicit consent UI
1 parent cd29243 commit 2637e20

54 files changed

Lines changed: 841 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

PRIVACY.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ go—and, importantly, where they don't.
3232
- **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI
3333
model), it is stored locally on your device and never sent to us or any third
3434
party, except the provider you have chosen.
35-
- **Telemetry (Usage Data)**: We collect feature usage and error data to help
36-
us improve Zoo Code. This telemetry is powered by PostHog and includes your
37-
VS Code machine ID, feature usage patterns, and exception reports. The VS Code
35+
- **Telemetry (Usage Data)**: We collect feature usage and error data to help us
36+
improve Zoo Code. This telemetry is powered by PostHog and includes your VS
37+
Code machine ID, feature usage patterns, and exception reports. The VS Code
3838
machine ID is a persistent identifier and may be considered personal data in
3939
some jurisdictions; we use it only for product analytics and error grouping.
40-
We retain telemetry only as long as needed for product analytics and debugging.
41-
Telemetry does **not** collect your code or AI prompts, and you can opt out at
42-
any time through the settings.
40+
We retain telemetry only as long as needed for product analytics and
41+
debugging. This PostHog-based telemetry does **not** collect your code or AI
42+
prompts, and you can opt out at any time through the settings.
4343
- **Marketplace Requests**: When you browse or search the Marketplace for Model
4444
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
4545
call to Zoo Code's backend servers to retrieve listing information. These
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// pnpm --filter @roo-code/types test src/__tests__/telemetry.isTelemetryOptedIn.test.ts
2+
3+
import { isTelemetryOptedIn } from "../telemetry.js"
4+
5+
describe("isTelemetryOptedIn", () => {
6+
it("returns true for an explicit 'enabled' setting", () => {
7+
expect(isTelemetryOptedIn("enabled")).toBe(true)
8+
})
9+
10+
it("returns false for an explicit 'disabled' setting", () => {
11+
expect(isTelemetryOptedIn("disabled")).toBe(false)
12+
})
13+
14+
it("returns true for 'unset' (disclosed opt-out default applies)", () => {
15+
expect(isTelemetryOptedIn("unset")).toBe(true)
16+
})
17+
18+
it("returns true for undefined (treated the same as unset)", () => {
19+
expect(isTelemetryOptedIn(undefined)).toBe(true)
20+
})
21+
})

packages/types/src/telemetry.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ export const telemetrySettingsSchema = z.enum(telemetrySettings)
1313

1414
export type TelemetrySetting = z.infer<typeof telemetrySettingsSchema>
1515

16+
/**
17+
* Whether telemetry should be captured for this install.
18+
*
19+
* Telemetry is on by default (disclosed opt-out): "unset" (no choice made yet) and
20+
* "enabled" both mean telemetry may be captured. Only an explicit "disabled" opts out.
21+
* The consent banner's dismiss/close action never writes a setting, so it stays neutral --
22+
* it just leaves the default in effect rather than recording an affirmative choice either way.
23+
*/
24+
export function isTelemetryOptedIn(telemetrySetting: TelemetrySetting | undefined): boolean {
25+
return telemetrySetting !== "disabled"
26+
}
27+
1628
/**
1729
* TelemetryEventName
1830
*/

packages/types/src/vscode-extension-host.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,11 @@ export type ExtensionState = Pick<
359359
telemetrySetting: TelemetrySetting
360360
telemetryKey?: string
361361
machineId?: string
362+
// Live vscode.env.isTelemetryEnabled, so the webview's own PostHog client can respect
363+
// the VS Code global telemetry toggle the same way the extension-side gate does --
364+
// without this, an explicit user Accept can still send events while VS Code's global
365+
// telemetry is disabled.
366+
vscodeTelemetryEnabled?: boolean
362367

363368
renderContext: "sidebar" | "editor"
364369
settingsImportedAt?: number

src/__tests__/extension.spec.ts

Lines changed: 157 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ vi.mock("vscode", () => ({
3636
},
3737
env: {
3838
language: "en",
39+
isTelemetryEnabled: true,
40+
onDidChangeTelemetryEnabled: vi.fn(),
3941
},
4042
ExtensionMode: {
4143
Production: 1,
@@ -72,19 +74,18 @@ vi.mock("@roo-code/cloud", () => ({
7274
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
7375
}))
7476

77+
const mockTelemetryServiceInstance = {
78+
register: vi.fn(),
79+
setProvider: vi.fn(),
80+
shutdown: vi.fn(),
81+
updateTelemetryState: vi.fn(),
82+
}
83+
7584
vi.mock("@roo-code/telemetry", () => ({
7685
TelemetryService: {
77-
createInstance: vi.fn().mockReturnValue({
78-
register: vi.fn(),
79-
setProvider: vi.fn(),
80-
shutdown: vi.fn(),
81-
}),
86+
createInstance: vi.fn().mockReturnValue(mockTelemetryServiceInstance),
8287
get instance() {
83-
return {
84-
register: vi.fn(),
85-
setProvider: vi.fn(),
86-
shutdown: vi.fn(),
87-
}
88+
return mockTelemetryServiceInstance
8889
},
8990
},
9091
PostHogTelemetryClient: vi.fn(),
@@ -114,6 +115,7 @@ vi.mock("../core/config/ContextProxy", () => ({
114115
setValue: vi.fn(),
115116
getValues: vi.fn().mockReturnValue({}),
116117
getProviderSettings: vi.fn().mockReturnValue({}),
118+
getGlobalState: vi.fn().mockReturnValue("enabled"),
117119
}),
118120
},
119121
}))
@@ -286,7 +288,7 @@ describe("extension.ts", () => {
286288
telemetryClient: null,
287289
authService: null,
288290
hasActiveSession: vi.fn().mockReturnValue(false),
289-
} as any
291+
} as unknown as never
290292
})
291293

292294
vi.mocked(CloudService.hasInstance).mockReturnValue(true)
@@ -295,7 +297,11 @@ describe("extension.ts", () => {
295297
const { activate } = await import("../extension")
296298
await activate(mockContext)
297299

298-
const provider = (ClineProvider as any).getVisibleInstance()
300+
const provider = (
301+
ClineProvider as unknown as {
302+
getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType<typeof vi.fn> }
303+
}
304+
).getVisibleInstance()
299305
provider.postStateToWebviewWithoutClineMessages.mockClear()
300306

301307
await authStateChangedHandler!({
@@ -317,4 +323,143 @@ describe("extension.ts", () => {
317323
await expect(activate(mockContext)).resolves.toBeDefined()
318324
})
319325
})
326+
327+
describe("telemetry level reactivity", () => {
328+
beforeEach(async () => {
329+
vi.resetModules()
330+
const vscode = await import("vscode")
331+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
332+
})
333+
334+
test("registers a listener for vscode.env.onDidChangeTelemetryEnabled", async () => {
335+
const vscode = await import("vscode")
336+
337+
const { activate } = await import("../extension")
338+
await activate(mockContext)
339+
340+
expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledTimes(1)
341+
expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledWith(expect.any(Function))
342+
})
343+
344+
test("re-evaluates telemetry state from stored settings when VS Code's global toggle changes", async () => {
345+
const vscode = await import("vscode")
346+
const { TelemetryService } = await import("@roo-code/telemetry")
347+
const { ContextProxy } = await import("../core/config/ContextProxy")
348+
349+
const mockContextProxyInstance = await (
350+
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
351+
)()
352+
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled")
353+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
354+
355+
const { activate } = await import("../extension")
356+
await activate(mockContext)
357+
358+
const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState)
359+
updateTelemetryStateMock.mockClear()
360+
361+
// The real vscode.env.onDidChangeTelemetryEnabled event carries no payload; the handler
362+
// must read the current vscode.env.isTelemetryEnabled value, not any argument it's called with.
363+
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
364+
onDidChangeHandler(undefined as never)
365+
366+
expect(updateTelemetryStateMock).toHaveBeenCalledWith(true)
367+
})
368+
369+
test("treats a disabled stored setting as opted out even when VS Code telemetry is enabled", async () => {
370+
const vscode = await import("vscode")
371+
const { TelemetryService } = await import("@roo-code/telemetry")
372+
const { ContextProxy } = await import("../core/config/ContextProxy")
373+
374+
const mockContextProxyInstance = await (
375+
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
376+
)()
377+
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("disabled")
378+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
379+
380+
const { activate } = await import("../extension")
381+
await activate(mockContext)
382+
383+
const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState)
384+
updateTelemetryStateMock.mockClear()
385+
386+
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
387+
onDidChangeHandler(undefined as never)
388+
389+
expect(updateTelemetryStateMock).toHaveBeenCalledWith(false)
390+
})
391+
392+
test("treats VS Code's live telemetry-disabled signal as opted out even when the stored setting is enabled", async () => {
393+
const vscode = await import("vscode")
394+
const { TelemetryService } = await import("@roo-code/telemetry")
395+
const { ContextProxy } = await import("../core/config/ContextProxy")
396+
397+
const mockContextProxyInstance = await (
398+
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
399+
)()
400+
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled")
401+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
402+
403+
const { activate } = await import("../extension")
404+
await activate(mockContext)
405+
406+
const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState)
407+
updateTelemetryStateMock.mockClear()
408+
409+
// Simulate the user turning off VS Code's global telemetry toggle: the live env value
410+
// flips before the event fires, and the handler must honor it rather than only the
411+
// stored extension setting.
412+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = false
413+
414+
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
415+
onDidChangeHandler(undefined as never)
416+
417+
expect(updateTelemetryStateMock).toHaveBeenCalledWith(false)
418+
})
419+
420+
test("pushes a state update to the webview so its own PostHog client picks up the new vscode.env.isTelemetryEnabled value", async () => {
421+
const vscode = await import("vscode")
422+
const { ClineProvider } = await import("../core/webview/ClineProvider")
423+
424+
const { activate } = await import("../extension")
425+
await activate(mockContext)
426+
427+
const visibleInstance = (
428+
ClineProvider as unknown as {
429+
getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType<typeof vi.fn> }
430+
}
431+
).getVisibleInstance()
432+
vi.mocked(visibleInstance.postStateToWebviewWithoutClineMessages).mockClear()
433+
434+
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
435+
onDidChangeHandler(undefined as never)
436+
437+
expect(visibleInstance.postStateToWebviewWithoutClineMessages).toHaveBeenCalled()
438+
})
439+
})
440+
441+
describe("deactivate", () => {
442+
beforeEach(() => {
443+
vi.resetModules()
444+
})
445+
446+
test("still runs terminal cleanup when telemetry shutdown rejects", async () => {
447+
const { TelemetryService } = await import("@roo-code/telemetry")
448+
const { Terminal } = await import("../integrations/terminal/Terminal")
449+
const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry")
450+
451+
vi.mocked(TelemetryService.instance.shutdown).mockRejectedValue(new Error("shutdown failed"))
452+
const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile")
453+
454+
const { activate, deactivate } = await import("../extension")
455+
await activate(mockContext)
456+
457+
await expect(deactivate()).resolves.toBeUndefined()
458+
459+
expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined)
460+
expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1)
461+
462+
setTerminalProfileSpy.mockRestore()
463+
})
464+
})
320465
})

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2413,6 +2413,7 @@ export class ClineProvider
24132413

24142414
const telemetryKey = process.env.POSTHOG_API_KEY
24152415
const machineId = vscode.env.machineId
2416+
const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled
24162417
const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands)
24172418
const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands)
24182419
const cwd = this.cwd
@@ -2513,6 +2514,7 @@ export class ClineProvider
25132514
telemetrySetting,
25142515
telemetryKey,
25152516
machineId,
2517+
vscodeTelemetryEnabled,
25162518
showRooIgnoredFiles: showRooIgnoredFiles ?? false,
25172519
enableSubfolderRules: enableSubfolderRules ?? false,
25182520
language: language ?? formatLanguage(vscode.env.language),

0 commit comments

Comments
 (0)