Compare commits

...
Author SHA1 Message Date
abeatrix a645c6a981 refactor: centralize telemetry state management in TelemetryService
Refactor the telemetry update logic by moving state management and opt-in/opt-out event capturing from the Controller into the TelemetryService. This simplifies the Controller's `updateTelemetrySetting` method and ensures consistent telemetry initialization.

- Move opt-in/opt-out event logic to `TelemetryService.updateTelemetryState`
- Remove redundant telemetry initialization from `initializeWebview`
- Update `updateTelemetryState` to accept the full `TelemetrySetting` type
- Ensure telemetry state is correctly synchronized with global settings and host IDE preferences
2026-02-03 16:08:31 +08:00
abeatrix f873b6194a fix: ensure telemetry state updates are properly awaited and synced
- Add `await` to `telemetryService.updateTelemetryState` in the main controller to ensure state consistency before event capture.
- Update `NoOpTelemetryProvider` to respect global telemetry settings and log opt-in/out transitions.
- Add `setOptIn` method to `ITelemetryProvider` interface and implement it across providers to allow dynamic state updates.
- Improve test assertions in `TelemetryService.test.ts` to verify PostHog provider settings and enabled status.
- Ensure all telemetry providers are notified when a user changes their opt-in preference.
2026-02-03 15:51:48 +08:00
8 changed files with 119 additions and 104 deletions
+1 -17
View File
@@ -345,23 +345,7 @@ export class Controller {
}
async updateTelemetrySetting(telemetrySetting: TelemetrySetting) {
// Get previous setting to detect state changes
const previousSetting = this.stateManager.getGlobalSettingsKey("telemetrySetting")
const wasOptedIn = previousSetting !== "disabled"
const isOptedIn = telemetrySetting !== "disabled"
// Capture opt-out event BEFORE updating (so it gets sent while telemetry is still enabled)
if (wasOptedIn && !isOptedIn) {
telemetryService.captureUserOptOut()
}
this.stateManager.setGlobalState("telemetrySetting", telemetrySetting)
telemetryService.updateTelemetryState(isOptedIn)
// Capture opt-in event AFTER updating (so telemetry is enabled to receive it)
if (!wasOptedIn && isOptedIn) {
telemetryService.captureUserOptIn()
}
await telemetryService.updateTelemetryState(telemetrySetting)
await this.postStateToWebview()
}
@@ -1,7 +1,6 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import { readMcpMarketplaceCatalogFromCache } from "@/core/storage/disk"
import { telemetryService } from "@/services/telemetry"
import { Logger } from "@/shared/services/Logger"
import { GlobalStateAndSettings } from "@/shared/storage/state-keys"
import type { Controller } from "../index"
@@ -217,13 +216,6 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
// Silently refresh MCP marketplace catalog
controller.refreshMcpMarketplace(true /* sendCatalogEvent */)
// Initialize telemetry service with user's current setting
controller.getStateToPostToWebview().then((state) => {
const { telemetrySetting } = state
const isOptedIn = telemetrySetting !== "disabled"
telemetryService.updateTelemetryState(isOptedIn)
})
return Empty.create({})
} catch (error) {
Logger.error("Failed to initialize webview:", error)
@@ -1,4 +1,5 @@
import { ClineEndpoint } from "@/config"
import { StateManager } from "@/core/storage/StateManager"
import {
getValidOpenTelemetryConfig,
getValidRuntimeOpenTelemetryConfig,
@@ -146,16 +147,34 @@ export class NoOpTelemetryProvider implements ITelemetryProvider {
private isOptIn = true
log(_event: string, _properties?: TelemetryProperties): void {
Logger.log(`[NoOpTelemetryProvider] ${_event}: ${JSON.stringify(_properties)}`)
if (this.isEnabled()) {
Logger.log(`[NoOpTelemetryProvider] ${_event}: ${JSON.stringify(_properties)}`)
}
}
logRequired(_event: string, _properties?: TelemetryProperties): void {
Logger.log(`[NoOpTelemetryProvider] REQUIRED ${_event}: ${JSON.stringify(_properties)}`)
}
identifyUser(_userInfo: any, _properties?: TelemetryProperties): void {
Logger.info(`[NoOpTelemetryProvider] identifyUser - ${JSON.stringify(_userInfo)} - ${JSON.stringify(_properties)}`)
if (this.isEnabled()) {
Logger.info(`[NoOpTelemetryProvider] identifyUser - ${JSON.stringify(_userInfo)} - ${JSON.stringify(_properties)}`)
}
}
isEnabled(): boolean {
return false
const isOptedIn = StateManager.get().getGlobalSettingsKey("telemetrySetting") !== "disabled"
const wasOptedIn = this.isOptIn
try {
if (isOptedIn && !wasOptedIn) {
Logger.info("User opted in to PostHog telemetry")
}
if (!isOptedIn && wasOptedIn) {
Logger.info("User opted out to PostHog telemetry")
}
} catch (err) {
Logger.error("Failed to update the PostHog telemetry state", err)
}
this.isOptIn = isOptedIn
return isOptedIn
}
getSettings(): TelemetrySettings {
return {
@@ -176,8 +176,16 @@ describe("Telemetry system is abstracted and can easily switch between providers
const isEnabled = posthogProvider.isEnabled()
const settings = posthogProvider.getSettings()
console.log("PostHog Provider enabled:", isEnabled)
console.log("PostHog Provider settings:", settings)
// Check that PostHog provider is enabled based on configuration
assert.strictEqual(isEnabled, true, "PostHog provider should be enabled based on configuration")
assert.deepStrictEqual(
settings,
{
hostEnabled: true,
level: "info",
},
"PostHog provider should return correct settings",
)
await posthogProvider.dispose()
})
+49 -23
View File
@@ -3,10 +3,12 @@ import type { BrowserSettings } from "@shared/BrowserSettings"
import { ShowMessageType } from "@shared/proto/host/window"
import type { TaskFeedbackType } from "@shared/WebviewMessage"
import * as os from "os"
import { StateManager } from "@/core/storage/StateManager"
import { ClineAccountUserInfo } from "@/services/auth/AuthService"
import { Setting } from "@/shared/proto/index.host"
import { Logger } from "@/shared/services/Logger"
import { Mode } from "@/shared/storage/types"
import { TelemetrySetting } from "@/shared/TelemetrySetting"
import { version as extensionVersion } from "../../../package.json"
import { setDistinctId } from "../logging/distinctId"
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
@@ -365,35 +367,53 @@ export class TelemetryService {
}
/**
* Updates the telemetry state based on user preferences and VSCode settings
* Updates the telemetry state based on user preferences and client settings
* Only enables telemetry if both VSCode global telemetry is enabled and user has opted in
* @param didUserOptIn Whether the user has explicitly opted into telemetry
*/
public async updateTelemetryState(didUserOptIn: boolean): Promise<void> {
public async updateTelemetryState(telemetrySetting: TelemetrySetting): Promise<void> {
// First check global telemetry level - telemetry should only be enabled when level is "all"
// We only enable telemetry if global host telemetry is enabled
const hostSetting = await HostProvider.env.getTelemetrySettings({})
if (hostSetting.isEnabled === Setting.DISABLED) {
// Only show warning if user has opted in to Cline telemetry but host telemetry is disabled
if (didUserOptIn) {
void HostProvider.window
.showMessage({
type: ShowMessageType.WARNING,
message:
"Anonymous Cline error and usage reporting is enabled, but IDE telemetry is disabled. To enable error and usage reporting for this extension, enable telemetry in IDE settings.",
options: {
items: ["Open Settings"],
},
})
.then((response: { selectedOption?: string }) => {
if (response.selectedOption === "Open Settings") {
void HostProvider.window.openSettings({
query: "telemetry.telemetryLevel",
})
}
})
}
const isHostTelemetryDisabled = hostSetting.isEnabled === Setting.DISABLED
const stateManager = StateManager.get()
// Get previous setting to detect state changes
const previousSetting = stateManager.getGlobalSettingsKey("telemetrySetting")
const wasOptedIn = previousSetting !== "disabled"
const didUserOptIn = telemetrySetting !== "disabled"
// Capture opt-in/out events based on changes
if (wasOptedIn && !didUserOptIn) {
this.captureUserOptOut()
} else if (!wasOptedIn && didUserOptIn) {
this.captureUserOptIn()
}
// Update storage only if setting has changed
if (previousSetting !== telemetrySetting) {
stateManager.setGlobalState("telemetrySetting", telemetrySetting)
}
// Only show warning if user has opted in to Cline telemetry but host telemetry is disabled
if (isHostTelemetryDisabled && didUserOptIn) {
void HostProvider.window
.showMessage({
type: ShowMessageType.WARNING,
message:
"Anonymous Cline error and usage reporting is enabled, but IDE telemetry is disabled. To enable error and usage reporting for this extension, enable telemetry in IDE settings.",
options: {
items: ["Open Settings"],
},
})
.then((response: { selectedOption?: string }) => {
if (response.selectedOption === "Open Settings") {
void HostProvider.window.openSettings({
query: "telemetry.telemetryLevel",
})
}
})
}
}
@@ -404,6 +424,9 @@ export class TelemetryService {
*/
public captureUserOptOut(): void {
this.captureRequired(TelemetryService.EVENTS.USER.OPT_OUT, {})
this.providers.forEach((provider) => {
provider.setOptIn(false)
})
}
/**
@@ -411,7 +434,10 @@ export class TelemetryService {
* Should only be called on explicit user action, not on init/sync.
*/
public captureUserOptIn(): void {
this.capture({ event: TelemetryService.EVENTS.USER.OPT_IN })
this.captureRequired(TelemetryService.EVENTS.USER.OPT_IN, {})
this.providers.forEach(async (provider) => {
provider.setOptIn(true)
})
}
/**
@@ -73,6 +73,8 @@ export interface ITelemetryProvider {
*/
isEnabled(): boolean
setOptIn(optIn: boolean): Promise<void>
/**
* Get current telemetry settings
*/
@@ -19,6 +19,7 @@ export class OpenTelemetryTelemetryProvider implements ITelemetryProvider {
private meter: Meter | null = null
private logger: OTELLogger | null = null
private telemetrySettings: TelemetrySettings
private optInCache: boolean
private userAttributes: Record<string, string> = {}
// Lazy instrument caches for metrics
private counters = new Map<string, ReturnType<Meter["createCounter"]>>()
@@ -43,6 +44,8 @@ export class OpenTelemetryTelemetryProvider implements ITelemetryProvider {
level: "all",
}
this.optInCache = StateManager.get().getGlobalSettingsKey("telemetrySetting") !== "disabled"
if (meterProvider) {
this.meter = meterProvider.getMeter("cline")
}
@@ -71,17 +74,16 @@ export class OpenTelemetryTelemetryProvider implements ITelemetryProvider {
onResponse: (event: { isEnabled: Setting }) => {
const hostEnabled = event.isEnabled === Setting.ENABLED || event.isEnabled === Setting.UNSUPPORTED
this.telemetrySettings.hostEnabled = hostEnabled
this.telemetrySettings.level = hostEnabled ? this.telemetrySettings.level : "off"
},
},
)
// Check host-specific telemetry setting (e.g. VS Code setting)
const hostSettings = await HostProvider.env.getTelemetrySettings({})
if (hostSettings.isEnabled === Setting.DISABLED) {
this.telemetrySettings.hostEnabled = false
}
this.telemetrySettings.hostEnabled = hostSettings.isEnabled !== Setting.DISABLED
this.telemetrySettings.level = getErrorLevelFromString(hostSettings.errorLevel)
this.telemetrySettings.level = await this.getTelemetryLevel()
return this
}
@@ -166,10 +168,18 @@ export class OpenTelemetryTelemetryProvider implements ITelemetryProvider {
}
public isEnabled(): boolean {
return (
this.bypassUserSettings ||
(StateManager.get().getGlobalSettingsKey("telemetrySetting") !== "disabled" && this.telemetrySettings.hostEnabled)
)
if (this.telemetrySettings.hostEnabled === false || this.telemetrySettings.level === "off") {
return false
}
return this.bypassUserSettings || this.optInCache
}
public async setOptIn(optIn: boolean): Promise<void> {
this.optInCache = optIn
// OpenTelemetry does not have built-in opt-in/out handling.
// This method is a no-op to satisfy the interface.
return Promise.resolve()
}
public getSettings(): TelemetrySettings {
@@ -305,21 +315,6 @@ export class OpenTelemetryTelemetryProvider implements ITelemetryProvider {
// Individual providers don't need to do anything
}
/**
* Get the current telemetry level from VS Code settings
*/
private async getTelemetryLevel(): Promise<TelemetrySettings["level"]> {
if (this.bypassUserSettings) {
return "all"
}
const hostSettings = await HostProvider.env.getTelemetrySettings({})
if (hostSettings.isEnabled === Setting.DISABLED) {
return "off"
}
return getErrorLevelFromString(hostSettings.errorLevel)
}
/**
* Flatten nested properties into dot-notation strings for OpenTelemetry attributes.
* OpenTelemetry attributes must be primitives (string, number, boolean).
@@ -16,7 +16,6 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
private client: PostHog
private telemetrySettings: TelemetrySettings
private isSharedClient: boolean
private optInCache: boolean
readonly name = "PostHogTelemetryProvider"
@@ -33,11 +32,11 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
}
this.client = new PostHog(posthogConfig.apiKey, {
host: posthogConfig.host,
defaultOptIn: StateManager.get().getGlobalSettingsKey("telemetrySetting") !== "disabled",
})
}
// Initialize telemetry settings
this.optInCache = true
this.telemetrySettings = {
hostEnabled: true,
level: "all",
@@ -51,17 +50,16 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
onResponse: (event: { isEnabled: Setting }) => {
const hostEnabled = event.isEnabled === Setting.ENABLED || event.isEnabled === Setting.UNSUPPORTED
this.telemetrySettings.hostEnabled = hostEnabled
this.telemetrySettings.level = hostEnabled ? this.telemetrySettings.level : "off"
},
},
)
// Check host-specific telemetry setting (e.g. VS Code setting)
const hostSettings = await HostProvider.env.getTelemetrySettings({})
if (hostSettings.isEnabled === Setting.DISABLED) {
this.telemetrySettings.hostEnabled = false
}
this.telemetrySettings.hostEnabled = hostSettings.isEnabled !== Setting.DISABLED
this.telemetrySettings.level = getErrorLevelFromString(hostSettings.errorLevel)
this.telemetrySettings.level = await this.getTelemetryLevel()
return this
}
@@ -113,22 +111,24 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
}
}
public isEnabled(): boolean {
const isOptedIn = StateManager.get().getGlobalSettingsKey("telemetrySetting") !== "disabled"
const wasOptedIn = this.optInCache
public async setOptIn(optIn: boolean): Promise<void> {
try {
if (isOptedIn && !wasOptedIn) {
this.client.optIn()
const wasOptedIn = !this.client.optedOut
if (wasOptedIn && !optIn) {
await this.client.optIn()
Logger.info("[PostHogTelemetryProvider] Client opted in")
}
if (!isOptedIn && wasOptedIn) {
this.client.optOut()
if (!wasOptedIn && optIn) {
await this.client.optOut()
Logger.info("[PostHogTelemetryProvider] Client opted out")
}
} catch (err) {
Logger.error("Failed to update the PostHog telemetry state", err)
}
this.optInCache = isOptedIn
}
return isOptedIn && this.telemetrySettings.hostEnabled
public isEnabled(): boolean {
return !this.client.optedOut && this.telemetrySettings.hostEnabled && this.telemetrySettings.level !== "off"
}
public getSettings(): TelemetrySettings {
@@ -141,8 +141,8 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
*/
public recordCounter(
name: string,
value: number,
attributes?: TelemetryProperties,
_value: number,
_attributes?: TelemetryProperties,
_description?: string,
required = false,
): void {
@@ -205,15 +205,4 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
}
}
}
/**
* Get the current telemetry level from VS Code settings
*/
private async getTelemetryLevel(): Promise<TelemetrySettings["level"]> {
const hostSettings = await HostProvider.env.getTelemetrySettings({})
if (hostSettings.isEnabled === Setting.DISABLED) {
return "off"
}
return getErrorLevelFromString(hostSettings.errorLevel)
}
}