Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ go—and, importantly, where they don't.
- **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI
model), it is stored locally on your device and never sent to us or any third
party, except the provider you have chosen.
- **Telemetry (Usage Data)**: We collect feature usage and error data to help
us improve Zoo Code. This telemetry is powered by PostHog and includes your
VS Code machine ID, feature usage patterns, and exception reports. The VS Code
- **Telemetry (Usage Data)**: We collect feature usage and error data to help us
improve Zoo Code. This telemetry is powered by PostHog and includes your VS
Code machine ID, feature usage patterns, and exception reports. The VS Code
machine ID is a persistent identifier and may be considered personal data in
some jurisdictions; we use it only for product analytics and error grouping.
We retain telemetry only as long as needed for product analytics and debugging.
Telemetry does **not** collect your code or AI prompts, and you can opt out at
any time through the settings.
We retain telemetry only as long as needed for product analytics and
debugging. This PostHog-based telemetry does **not** collect your code or AI
prompts, and you can opt out at any time through the settings.
Comment thread
edelauna marked this conversation as resolved.
- **Marketplace Requests**: When you browse or search the Marketplace for Model
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
call to Zoo Code's backend servers to retrieve listing information. These
Expand Down
8 changes: 8 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ flag_management:
- packages/core/src/
carryforward: true

ignore:
# Playwright CT-only fixtures/helpers: exercised by the webview-ui-ct flag's browser run, not
# Vitest, and excluded from that flag's own lcov by playwright-ct.config.ts's sourceFilter (same
# ".visual." match). Without this, patch coverage sees 0% for these paths on any PR that adds or
# touches one, since no flag's lcov contains them.
- "webview-ui/src/**/*.visual.fixture.tsx"
- "webview-ui/src/**/*.visual.i18n.ts"

component_management:
individual_components:
- component_id: webview_components
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// pnpm --filter @roo-code/types test src/__tests__/telemetry.isTelemetryOptedIn.test.ts

import { isTelemetryOptedIn } from "../telemetry.js"

describe("isTelemetryOptedIn", () => {
it("returns true for an explicit 'enabled' setting", () => {
expect(isTelemetryOptedIn("enabled")).toBe(true)
})

it("returns false for an explicit 'disabled' setting", () => {
expect(isTelemetryOptedIn("disabled")).toBe(false)
})

it("returns true for 'unset' (disclosed opt-out default applies)", () => {
expect(isTelemetryOptedIn("unset")).toBe(true)
})

it("returns true for undefined (treated the same as unset)", () => {
expect(isTelemetryOptedIn(undefined)).toBe(true)
})
})
12 changes: 12 additions & 0 deletions packages/types/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ export const telemetrySettingsSchema = z.enum(telemetrySettings)

export type TelemetrySetting = z.infer<typeof telemetrySettingsSchema>

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

/**
* TelemetryEventName
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,11 @@ export type ExtensionState = Pick<
telemetrySetting: TelemetrySetting
telemetryKey?: string
machineId?: string
// Live vscode.env.isTelemetryEnabled, so the webview's own PostHog client can respect
// the VS Code global telemetry toggle the same way the extension-side gate does --
// without this, an explicit user Accept can still send events while VS Code's global
// telemetry is disabled.
vscodeTelemetryEnabled?: boolean

renderContext: "sidebar" | "editor"
settingsImportedAt?: number
Expand Down
169 changes: 157 additions & 12 deletions src/__tests__/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ vi.mock("vscode", () => ({
},
env: {
language: "en",
isTelemetryEnabled: true,
onDidChangeTelemetryEnabled: vi.fn(),
},
ExtensionMode: {
Production: 1,
Expand Down Expand Up @@ -72,19 +74,18 @@ vi.mock("@roo-code/cloud", () => ({
getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
}))

const mockTelemetryServiceInstance = {
register: vi.fn(),
setProvider: vi.fn(),
shutdown: vi.fn(),
updateTelemetryState: vi.fn(),
}

vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
createInstance: vi.fn().mockReturnValue({
register: vi.fn(),
setProvider: vi.fn(),
shutdown: vi.fn(),
}),
createInstance: vi.fn().mockReturnValue(mockTelemetryServiceInstance),
get instance() {
return {
register: vi.fn(),
setProvider: vi.fn(),
shutdown: vi.fn(),
}
return mockTelemetryServiceInstance
},
},
PostHogTelemetryClient: vi.fn(),
Expand Down Expand Up @@ -114,6 +115,7 @@ vi.mock("../core/config/ContextProxy", () => ({
setValue: vi.fn(),
getValues: vi.fn().mockReturnValue({}),
getProviderSettings: vi.fn().mockReturnValue({}),
getGlobalState: vi.fn().mockReturnValue("enabled"),
}),
},
}))
Expand Down Expand Up @@ -286,7 +288,7 @@ describe("extension.ts", () => {
telemetryClient: null,
authService: null,
hasActiveSession: vi.fn().mockReturnValue(false),
} as any
} as unknown as never
})

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

const provider = (ClineProvider as any).getVisibleInstance()
const provider = (
ClineProvider as unknown as {
getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType<typeof vi.fn> }
}
).getVisibleInstance()
provider.postStateToWebviewWithoutClineMessages.mockClear()

await authStateChangedHandler!({
Expand All @@ -317,4 +323,143 @@ describe("extension.ts", () => {
await expect(activate(mockContext)).resolves.toBeDefined()
})
})

describe("telemetry level reactivity", () => {
beforeEach(async () => {
vi.resetModules()
const vscode = await import("vscode")
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
})

test("registers a listener for vscode.env.onDidChangeTelemetryEnabled", async () => {
const vscode = await import("vscode")

const { activate } = await import("../extension")
await activate(mockContext)

expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledTimes(1)
expect(vscode.env.onDidChangeTelemetryEnabled).toHaveBeenCalledWith(expect.any(Function))
})

test("re-evaluates telemetry state from stored settings when VS Code's global toggle changes", async () => {
const vscode = await import("vscode")
const { TelemetryService } = await import("@roo-code/telemetry")
const { ContextProxy } = await import("../core/config/ContextProxy")

const mockContextProxyInstance = await (
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
)()
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled")
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true

const { activate } = await import("../extension")
await activate(mockContext)

const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState)
updateTelemetryStateMock.mockClear()

// The real vscode.env.onDidChangeTelemetryEnabled event carries no payload; the handler
// must read the current vscode.env.isTelemetryEnabled value, not any argument it's called with.
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
onDidChangeHandler(undefined as never)

expect(updateTelemetryStateMock).toHaveBeenCalledWith(true)
})

test("treats a disabled stored setting as opted out even when VS Code telemetry is enabled", async () => {
const vscode = await import("vscode")
const { TelemetryService } = await import("@roo-code/telemetry")
const { ContextProxy } = await import("../core/config/ContextProxy")

const mockContextProxyInstance = await (
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
)()
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("disabled")
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true

const { activate } = await import("../extension")
await activate(mockContext)

const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState)
updateTelemetryStateMock.mockClear()

const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
onDidChangeHandler(undefined as never)

expect(updateTelemetryStateMock).toHaveBeenCalledWith(false)
})

test("treats VS Code's live telemetry-disabled signal as opted out even when the stored setting is enabled", async () => {
const vscode = await import("vscode")
const { TelemetryService } = await import("@roo-code/telemetry")
const { ContextProxy } = await import("../core/config/ContextProxy")

const mockContextProxyInstance = await (
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
)()
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled")
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true

const { activate } = await import("../extension")
await activate(mockContext)

const updateTelemetryStateMock = vi.mocked(TelemetryService.instance.updateTelemetryState)
updateTelemetryStateMock.mockClear()

// Simulate the user turning off VS Code's global telemetry toggle: the live env value
// flips before the event fires, and the handler must honor it rather than only the
// stored extension setting.
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = false

const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
onDidChangeHandler(undefined as never)

expect(updateTelemetryStateMock).toHaveBeenCalledWith(false)
})

test("pushes a state update to the webview so its own PostHog client picks up the new vscode.env.isTelemetryEnabled value", async () => {
const vscode = await import("vscode")
const { ClineProvider } = await import("../core/webview/ClineProvider")

const { activate } = await import("../extension")
await activate(mockContext)

const visibleInstance = (
ClineProvider as unknown as {
getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType<typeof vi.fn> }
}
).getVisibleInstance()
vi.mocked(visibleInstance.postStateToWebviewWithoutClineMessages).mockClear()

const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
onDidChangeHandler(undefined as never)

expect(visibleInstance.postStateToWebviewWithoutClineMessages).toHaveBeenCalled()
})
})

describe("deactivate", () => {
beforeEach(() => {
vi.resetModules()
})

test("still runs terminal cleanup when telemetry shutdown rejects", async () => {
const { TelemetryService } = await import("@roo-code/telemetry")
const { Terminal } = await import("../integrations/terminal/Terminal")
const { TerminalRegistry } = await import("../integrations/terminal/TerminalRegistry")

vi.mocked(TelemetryService.instance.shutdown).mockRejectedValue(new Error("shutdown failed"))
const setTerminalProfileSpy = vi.spyOn(Terminal, "setTerminalProfile")

const { activate, deactivate } = await import("../extension")
await activate(mockContext)

await expect(deactivate()).resolves.toBeUndefined()

expect(setTerminalProfileSpy).toHaveBeenCalledWith(undefined)
expect(TerminalRegistry.cleanup).toHaveBeenCalledTimes(1)

setTerminalProfileSpy.mockRestore()
})
})
})
2 changes: 2 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2413,6 +2413,7 @@ export class ClineProvider

const telemetryKey = process.env.POSTHOG_API_KEY
const machineId = vscode.env.machineId
const vscodeTelemetryEnabled = vscode.env.isTelemetryEnabled
const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands)
const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands)
const cwd = this.cwd
Expand Down Expand Up @@ -2513,6 +2514,7 @@ export class ClineProvider
telemetrySetting,
telemetryKey,
machineId,
vscodeTelemetryEnabled,
showRooIgnoredFiles: showRooIgnoredFiles ?? false,
enableSubfolderRules: enableSubfolderRules ?? false,
language: language ?? formatLanguage(vscode.env.language),
Expand Down
Loading
Loading