Skip to content

Commit 059c86e

Browse files
committed
fix(telemetry): make telemetry opt-out by disclosed default
1 parent 55d6182 commit 059c86e

8 files changed

Lines changed: 55 additions & 31 deletions

File tree

PRIVACY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ go—and, importantly, where they don't.
5656
## **Your Choices & Control**
5757

5858
- You can run models locally to prevent data being sent to third-parties.
59-
- Telemetry collection is off by default and requires your explicit opt-in.
60-
You can change your choice at any time through the settings.
59+
- Telemetry collection is enabled by default to help us improve Zoo Code, but
60+
you can opt out at any time through the settings.
6161
- You can delete Zoo Code to stop all data collection.
6262

6363
## **Security & Updates**

packages/types/src/__tests__/telemetry.isTelemetryOptedIn.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@
33
import { isTelemetryOptedIn } from "../telemetry.js"
44

55
describe("isTelemetryOptedIn", () => {
6-
it("returns true only for an explicit 'enabled' setting", () => {
6+
it("returns true for an explicit 'enabled' setting", () => {
77
expect(isTelemetryOptedIn("enabled")).toBe(true)
88
})
99

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

14-
it("returns false for 'unset' (no explicit consent yet)", () => {
15-
expect(isTelemetryOptedIn("unset")).toBe(false)
14+
it("returns true for 'unset' (disclosed opt-out default applies)", () => {
15+
expect(isTelemetryOptedIn("unset")).toBe(true)
1616
})
1717

18-
it("returns false for undefined (treated the same as unset)", () => {
19-
expect(isTelemetryOptedIn(undefined)).toBe(false)
18+
it("returns true for undefined (treated the same as unset)", () => {
19+
expect(isTelemetryOptedIn(undefined)).toBe(true)
2020
})
2121
})

packages/types/src/telemetry.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ export const telemetrySettingsSchema = z.enum(telemetrySettings)
1414
export type TelemetrySetting = z.infer<typeof telemetrySettingsSchema>
1515

1616
/**
17-
* Whether the user has explicitly opted into telemetry.
17+
* Whether telemetry should be captured for this install.
1818
*
19-
* Only an explicit "enabled" counts as consent. "unset" (no choice made yet) and
20-
* "disabled" both mean telemetry must not be captured -- this is what makes a
21-
* neutral dismiss of the consent banner (which leaves the setting "unset")
22-
* actually neutral, rather than silently opting the user in.
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.
2323
*/
2424
export function isTelemetryOptedIn(telemetrySetting: TelemetrySetting | undefined): boolean {
25-
return telemetrySetting === "enabled"
25+
return telemetrySetting !== "disabled"
2626
}
2727

2828
/**

src/core/webview/__tests__/telemetrySettingsTracking.spec.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe("Telemetry Settings Tracking", () => {
5050
expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(false)
5151
})
5252

53-
it("should not fire an opt-out event when going from unset to disabled (was never opted in)", () => {
53+
it("should fire an opt-out event when going from unset to disabled (explicit Decline)", () => {
5454
const previousSetting = "unset" as TelemetrySetting
5555
const newSetting = "disabled" as TelemetrySetting
5656

@@ -63,8 +63,9 @@ describe("Telemetry Settings Tracking", () => {
6363

6464
TelemetryService.instance.updateTelemetryState(isOptedIn)
6565

66-
// "unset" was never opted in, so there is no opt-out transition to report.
67-
expect(mockTelemetryService.captureTelemetrySettingsChanged).not.toHaveBeenCalled()
66+
// "unset" is opted in under the disclosed opt-out default, so unset -> disabled
67+
// is a genuine opt-out transition.
68+
expect(mockTelemetryService.captureTelemetrySettingsChanged).toHaveBeenCalledWith("unset", "disabled")
6869
expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(false)
6970
})
7071
})
@@ -116,7 +117,7 @@ describe("Telemetry Settings Tracking", () => {
116117
expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(true)
117118
})
118119

119-
it("should fire an opt-in event when going from unset to enabled (explicit Accept)", () => {
120+
it("should not fire an event when going from unset to enabled (already opted in by default)", () => {
120121
const previousSetting = "unset" as TelemetrySetting
121122
const newSetting = "enabled" as TelemetrySetting
122123

@@ -133,18 +134,21 @@ describe("Telemetry Settings Tracking", () => {
133134
TelemetryService.instance.captureTelemetrySettingsChanged(previousSetting, newSetting)
134135
}
135136

136-
// "unset" is not opted in, so unset -> enabled is a genuine opt-in transition.
137-
expect(mockTelemetryService.captureTelemetrySettingsChanged).toHaveBeenCalledWith("unset", "enabled")
137+
// "unset" is already opted in under the disclosed opt-out default, so explicit
138+
// Accept (unset -> enabled) is a no-op transition, not a new opt-in.
139+
expect(mockTelemetryService.captureTelemetrySettingsChanged).not.toHaveBeenCalled()
138140
expect(mockTelemetryService.updateTelemetryState).toHaveBeenCalledWith(true)
139141
})
140142
})
141143

142144
describe("neutral banner dismiss ('unset' left as-is)", () => {
143-
it("does not report telemetry as opted in while the setting remains unset", () => {
145+
it("leaves the disclosed opt-out default in effect while the setting remains unset", () => {
144146
// A neutral dismiss of the consent banner sends no telemetrySetting message at
145147
// all, so the stored setting stays "unset". Confirm "unset" alone -- with no
146-
// transition -- is not treated as consent.
147-
expect(isTelemetryOptedIn("unset" as TelemetrySetting)).toBe(false)
148+
// transition, and no affirmative choice recorded either way -- resolves to the
149+
// disclosed default (telemetry on) rather than silently opting the user in via
150+
// dismissal itself.
151+
expect(isTelemetryOptedIn("unset" as TelemetrySetting)).toBe(true)
148152
})
149153
})
150154

src/core/webview/webviewMessageHandler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -629,8 +629,8 @@ export const webviewMessageHandler = async (
629629
),
630630
)
631631

632-
// Only capture telemetry once the user has explicitly opted in; "unset" (no
633-
// choice made yet) and "disabled" are both treated as not opted in.
632+
// Telemetry is on by disclosed default: "unset" (no choice made yet) leaves that
633+
// default in effect, same as "enabled". Only an explicit "disabled" opts out.
634634
provider.getStateToPostToWebview().then((state) => {
635635
const { telemetrySetting } = state
636636
TelemetryService.instance.updateTelemetryState(isTelemetryOptedIn(telemetrySetting))

webview-ui/src/__tests__/TelemetryClient.spec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,26 @@ describe("TelemetryClient", () => {
7777
expect(posthog.init).not.toHaveBeenCalled()
7878
})
7979

80-
it("doesn't initialize PostHog when telemetry is unset", () => {
80+
it("initializes PostHog when telemetry is unset and credentials are present (disclosed opt-out default)", () => {
81+
// Arrange
82+
const API_KEY = "test-api-key"
83+
const DISTINCT_ID = "test-user-id"
84+
85+
// Act
86+
telemetryClient.updateTelemetryState("unset", API_KEY, DISTINCT_ID)
87+
88+
// Assert
89+
expect(posthog.init).toHaveBeenCalledWith(
90+
API_KEY,
91+
expect.objectContaining({
92+
api_host: "https://us.i.posthog.com",
93+
persistence: "localStorage",
94+
loaded: expect.any(Function),
95+
}),
96+
)
97+
})
98+
99+
it("doesn't initialize PostHog when telemetry is unset but credentials are missing", () => {
81100
// Act
82101
telemetryClient.updateTelemetryState("unset")
83102

webview-ui/src/components/common/TelemetryBanner.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ const TelemetryBanner = () => {
1111
const { t } = useAppTranslation()
1212
const [isDismissed, setIsDismissed] = useState(false)
1313

14-
// A neutral dismiss ("x") intentionally sends no message, leaving the
15-
// setting as "unset" (routed to disabled for actual capture). Only the
16-
// explicit Accept/Decline actions record the user's choice.
14+
// A neutral dismiss ("x") intentionally sends no message, leaving the setting
15+
// "unset" so the disclosed opt-out default (telemetry on) stays in effect.
16+
// Only an explicit Decline (-> "disabled") opts out. Only the explicit
17+
// Accept/Decline actions record the user's choice.
1718
const handleClose = () => {
1819
setIsDismissed(true)
1920
}
@@ -42,7 +43,7 @@ const TelemetryBanner = () => {
4243

4344
return (
4445
<div className="relative px-4 py-2.5 pr-10 bg-vscode-banner-background border-b border-vscode-panel-border text-sm leading-normal text-vscode-foreground">
45-
{/* Close button (X) - neutral dismiss, does not opt in */}
46+
{/* Close button (X) - neutral dismiss, does not record a choice */}
4647
<button
4748
onClick={handleClose}
4849
className="absolute top-1.5 right-2 bg-transparent border-none text-vscode-foreground cursor-pointer text-2xl p-1 opacity-70 hover:opacity-100 transition-opacity duration-200 leading-none"

webview-ui/src/components/settings/__tests__/About.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,14 @@ describe("About", () => {
161161
expect(screen.getByRole("checkbox", { name: /telemetry/i })).toBeChecked()
162162
})
163163

164-
it("does not show the telemetry checkbox as checked when the setting is unset (no consent given yet)", () => {
164+
it("shows the telemetry checkbox as checked when the setting is unset (disclosed opt-out default)", () => {
165165
render(
166166
<TranslationProvider>
167167
<About {...defaultProps} telemetrySetting="unset" />
168168
</TranslationProvider>,
169169
)
170170

171-
expect(screen.getByRole("checkbox", { name: /telemetry/i })).not.toBeChecked()
171+
expect(screen.getByRole("checkbox", { name: /telemetry/i })).toBeChecked()
172172
})
173173

174174
it("does not show the telemetry checkbox as checked when the setting is disabled", () => {

0 commit comments

Comments
 (0)