Compare commits

...
Author SHA1 Message Date
NightTrek f07fefeb07 feat(telemetry): add policy-based telemetry control with env override
Implement TelemetrySettingsPolicyManager to centrally manage telemetry
state with support for environment-level overrides. This allows
enterprises to completely disable all telemetry via
OTEL_TELEMETRY_ENABLED=0 environment variable, regardless of user
preferences or IDE settings.

Key changes:
- Add policy manager that evaluates telemetry state from multiple sources
- Support OTEL_TELEMETRY_ENABLED=0 to force-disable all telemetry
- Dynamically switch between active and NoOp telemetry providers
- Update .env.example with clearer documentation about override behavior
- Make telemetry state updates async to properly await policy changes
- Subscribe to policy changes for runtime provider switching

This ensures enterprise environments have a reliable kill switch for
all telemetry that cannot be overridden by user settings.
2025-10-28 10:59:04 -07:00
5 changed files with 340 additions and 17 deletions
+4 -1
View File
@@ -30,7 +30,10 @@ POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: tru
# Can run alongside PostHog or independently
# Primary focus: Logs (events), with optional metrics support
# Enable OpenTelemetry (set to 1 to enable)
# Enable/Disable OpenTelemetry (set to 1 to enable, 0 to completely disable ALL telemetry)
# IMPORTANT: Setting OTEL_TELEMETRY_ENABLED=0 will disable ALL telemetry providers,
# regardless of user preferences or IDE settings. Use this for enterprise environments
# where no telemetry should leave the network.
# OTEL_TELEMETRY_ENABLED=1
# Exporters: "console" for local debugging, "otlp" for remote collector
+4 -1
View File
@@ -346,7 +346,10 @@ export class Controller {
async updateTelemetrySetting(telemetrySetting: TelemetrySetting) {
this.stateManager.setGlobalState("telemetrySetting", telemetrySetting)
const isOptedIn = telemetrySetting !== "disabled"
telemetryService.updateTelemetryState(isOptedIn)
// Update telemetry service which will notify the policy manager
await telemetryService.updateTelemetryState(isOptedIn)
await this.postStateToWebview()
}
@@ -27,9 +27,16 @@ export class TelemetryProviderFactory {
/**
* Creates multiple telemetry providers based on configuration
* Supports dual tracking during transition period
* @param forceNoOp Optional parameter to force NoOp providers regardless of configuration
* @returns Array of ITelemetryProvider instances
*/
public static async createProviders(): Promise<ITelemetryProvider[]> {
public static async createProviders(forceNoOp?: boolean): Promise<ITelemetryProvider[]> {
// If forceNoOp is true, return only NoOp provider
if (forceNoOp) {
Logger.info("TelemetryProviderFactory: Telemetry disabled - using NoOp provider")
return [new NoOpTelemetryProvider()]
}
const configs = TelemetryProviderFactory.getDefaultConfigs()
const providers: ITelemetryProvider[] = await Promise.all(configs.map((c) => TelemetryProviderFactory.createProvider(c)))
+101 -14
View File
@@ -10,6 +10,7 @@ import { version as extensionVersion } from "../../../package.json"
import { setDistinctId } from "../logging/distinctId"
import type { ITelemetryProvider, TelemetryProperties } from "./providers/ITelemetryProvider"
import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
import { type TelemetryEffectivePolicy, TelemetrySettingsPolicyManager } from "./TelemetrySettingsPolicyManager"
/**
* Represents telemetry event categories that can be individually enabled or disabled
@@ -217,8 +218,9 @@ export class TelemetryService {
},
}
private policyManager?: TelemetrySettingsPolicyManager
public static async create(): Promise<TelemetryService> {
const providers = await TelemetryProviderFactory.createProviders()
const hostVersion = await HostProvider.env.getHostVersion({})
const metadata: TelemetryMetadata = {
extension_version: extensionVersion,
@@ -228,34 +230,92 @@ export class TelemetryService {
os_version: os.version(),
is_dev: process.env.IS_DEV,
}
return new TelemetryService(providers, metadata)
// Create policy manager with environment override support
const policyManager = new TelemetrySettingsPolicyManager({
getUserOptIn: async () => {
// This will be set properly when updateTelemetryState is called
return false
},
environmentOverrides: {
telemetryDisabled: process.env.OTEL_TELEMETRY_ENABLED === "0",
},
})
// Wait for policy to be resolved before creating providers
await policyManager.load()
const policy = policyManager.getCurrentPolicy()
// Only create providers if telemetry is allowed
const providers = policy.isTelemetryAllowed
? await TelemetryProviderFactory.createProviders()
: await TelemetryProviderFactory.createProviders(true) // Force NoOp
const service = new TelemetryService(providers, metadata, policyManager)
// Subscribe to policy changes to update providers dynamically
policyManager.subscribe((newPolicy) => {
service.handlePolicyChange(newPolicy)
})
return service
}
/**
* Constructor that accepts multiple telemetry providers for dual tracking
* @param providers Array of telemetry providers for dual/multi tracking
* @param telemetryMetadata Metadata to include with all telemetry events
* @param policyManager Policy manager to control telemetry state
*/
constructor(
private providers: ITelemetryProvider[],
private telemetryMetadata: TelemetryMetadata,
policyManager?: TelemetrySettingsPolicyManager,
) {
this.capture({ event: TelemetryService.EVENTS.USER.TELEMETRY_ENABLED })
this.policyManager = policyManager
// Only capture telemetry enabled event if policy allows
if (!policyManager || policyManager.getCurrentPolicy().isTelemetryAllowed) {
this.capture({ event: TelemetryService.EVENTS.USER.TELEMETRY_ENABLED })
}
console.info(`[TelemetryService] Initialized with ${providers.length} telemetry provider(s)`)
}
/**
* Handle policy changes from the policy manager
* @param policy The new effective policy
*/
private handlePolicyChange(policy: TelemetryEffectivePolicy): void {
// Update all providers with the new opt-in state
this.providers.forEach((provider) => {
provider.setOptIn(policy.isTelemetryAllowed)
})
}
/**
* Updates the telemetry state based on user preferences and VSCode 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> {
// First check global telemetry level - telemetry should only be enabled when level is "all"
// If we have a policy manager, update it with the new user opt-in state
// This will trigger policy recalculation and notify all subscribers
if (this.policyManager) {
// Trigger policy change by manually calling handlePolicyChange with updated user opt-in
const currentPolicy = this.policyManager.getCurrentPolicy()
const newPolicy: TelemetryEffectivePolicy = {
...currentPolicy,
userOptIn: didUserOptIn,
isTelemetryAllowed:
!currentPolicy.environmentDisabled &&
currentPolicy.hostSetting !== Setting.DISABLED &&
didUserOptIn &&
currentPolicy.level !== "off",
}
this.handlePolicyChange(newPolicy)
// 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) {
// Show warning if user opted in but host telemetry is disabled
if (didUserOptIn && currentPolicy.hostSetting === Setting.DISABLED) {
void HostProvider.window
.showMessage({
type: ShowMessageType.WARNING,
@@ -273,12 +333,34 @@ export class TelemetryService {
}
})
}
}
} else {
// Legacy path: if no policy manager (for backward compatibility), directly update providers
const hostSetting = await HostProvider.env.getTelemetrySettings({})
if (hostSetting.isEnabled === Setting.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) => {
if (response.selectedOption === "Open Settings") {
void HostProvider.window.openSettings({
query: "telemetry.telemetryLevel",
})
}
})
}
}
// Update all providers
this.providers.forEach((provider) => {
provider.setOptIn(didUserOptIn)
})
this.providers.forEach((provider) => {
provider.setOptIn(didUserOptIn)
})
}
}
/**
@@ -286,6 +368,11 @@ export class TelemetryService {
* @param event The event to capture with its properties
*/
public capture(event: { event: string; properties?: TelemetryProperties }): void {
// Early return if policy manager exists and disallows telemetry
if (this.policyManager && !this.policyManager.getCurrentPolicy().isTelemetryAllowed) {
return
}
const propertiesWithMetadata: TelemetryProperties = {
...(event.properties || {}),
...this.telemetryMetadata,
@@ -0,0 +1,223 @@
import { HostProvider } from "@/hosts/host-provider"
import { Setting } from "@/shared/proto/index.host"
export type TelemetryLevel = "off" | "error" | "all"
export interface TelemetryEffectivePolicy {
hostSetting: Setting
userOptIn: boolean
environmentDisabled: boolean
isTelemetryAllowed: boolean
level: TelemetryLevel
}
export type TelemetrySettingsListener = (policy: TelemetryEffectivePolicy) => void
export interface TelemetrySettingsPolicyManagerOptions {
hostProvider?: typeof HostProvider
getUserOptIn: () => Promise<boolean>
onUserOptInChange?: (listener: (optIn: boolean) => void) => () => void
getTelemetryLevel?: () => Promise<TelemetryLevel>
environmentOverrides?: {
telemetryDisabled?: boolean
}
}
const DEFAULT_LEVEL: TelemetryLevel = "all"
/**
* Central authority for computing whether telemetry is permitted.
* Aggregates host telemetry status, user opt-in preference, and environment overrides.
*/
export class TelemetrySettingsPolicyManager {
private readonly listeners: Set<TelemetrySettingsListener> = new Set()
private readonly hostProvider: typeof HostProvider
private readonly getUserOptIn: () => Promise<boolean>
private readonly onUserOptInChange?: (listener: (optIn: boolean) => void) => () => void
private readonly getTelemetryLevel?: () => Promise<TelemetryLevel>
private readonly environmentOverrides?: TelemetrySettingsPolicyManagerOptions["environmentOverrides"]
private currentPolicy: TelemetryEffectivePolicy | null = null
private readyResolver!: () => void
private readonly readyPromise: Promise<void>
private unsubscribeHostTelemetry?: () => void
private unsubscribeUserOptIn?: () => void
constructor(options: TelemetrySettingsPolicyManagerOptions) {
this.hostProvider = options.hostProvider ?? HostProvider
this.getUserOptIn = options.getUserOptIn
this.onUserOptInChange = options.onUserOptInChange
this.getTelemetryLevel = options.getTelemetryLevel
this.environmentOverrides = options.environmentOverrides
this.readyPromise = new Promise<void>((resolve) => {
this.readyResolver = resolve
})
}
public async load(): Promise<void> {
const [hostSetting, userOptIn, level] = await Promise.all([
this.fetchHostTelemetrySetting(),
this.getUserOptIn(),
this.computeTelemetryLevel(),
])
this.currentPolicy = this.computePolicy(hostSetting, userOptIn, level)
this.readyResolver()
this.subscribeToHostTelemetry()
this.subscribeToUserOptIn()
}
public getCurrentPolicy(): TelemetryEffectivePolicy {
if (!this.currentPolicy) {
throw new Error("TelemetrySettingsPolicyManager: policy accessed before load()")
}
return this.currentPolicy
}
public waitUntilReady(): Promise<void> {
return this.readyPromise
}
public subscribe(listener: TelemetrySettingsListener): () => void {
this.listeners.add(listener)
if (this.currentPolicy) {
listener(this.currentPolicy)
}
return () => {
this.listeners.delete(listener)
}
}
public dispose(): void {
this.listeners.clear()
this.unsubscribeHostTelemetry?.()
this.unsubscribeUserOptIn?.()
}
private subscribeToHostTelemetry(): void {
this.unsubscribeHostTelemetry = this.hostProvider.env.subscribeToTelemetrySettings(
{},
{
onResponse: (event) => {
const hostSetting = event.isEnabled
this.handlePolicyChange({ hostSetting })
},
onError: (error) => {
console.error("[TelemetrySettingsPolicyManager] Host telemetry subscription error:", error)
},
},
)
}
private subscribeToUserOptIn(): void {
if (!this.onUserOptInChange) {
return
}
this.unsubscribeUserOptIn = this.onUserOptInChange((optIn) => {
this.handlePolicyChange({ userOptIn: optIn })
})
}
private async fetchHostTelemetrySetting(): Promise<Setting> {
try {
const response = await this.hostProvider.env.getTelemetrySettings({})
return response.isEnabled
} catch (error) {
console.error("[TelemetrySettingsPolicyManager] Failed to fetch host telemetry setting:", error)
return Setting.UNSUPPORTED
}
}
private async computeTelemetryLevel(): Promise<TelemetryLevel> {
if (!this.getTelemetryLevel) {
return DEFAULT_LEVEL
}
try {
return await this.getTelemetryLevel()
} catch (error) {
console.error("[TelemetrySettingsPolicyManager] Failed to resolve telemetry level:", error)
return DEFAULT_LEVEL
}
}
private computePolicy(hostSetting: Setting, userOptIn: boolean, level: TelemetryLevel): TelemetryEffectivePolicy {
const environmentDisabled = this.environmentOverrides?.telemetryDisabled === true
const isTelemetryAllowed = this.resolveTelemetryAllowed(hostSetting, userOptIn, environmentDisabled, level)
return {
hostSetting,
userOptIn,
environmentDisabled,
isTelemetryAllowed,
level: level ?? DEFAULT_LEVEL,
}
}
private resolveTelemetryAllowed(
hostSetting: Setting,
userOptIn: boolean,
environmentDisabled: boolean,
level: TelemetryLevel,
): boolean {
if (environmentDisabled) {
return false
}
if (hostSetting === Setting.DISABLED) {
return false
}
if (!userOptIn) {
return false
}
if (level === "off") {
return false
}
return true
}
public async refreshTelemetryLevel(): Promise<void> {
const nextLevel = await this.computeTelemetryLevel()
this.handlePolicyChange({ level: nextLevel })
}
public setTelemetryLevel(level: TelemetryLevel): void {
this.handlePolicyChange({ level })
}
private handlePolicyChange(partial: Partial<Pick<TelemetryEffectivePolicy, "hostSetting" | "userOptIn" | "level">>): void {
if (!this.currentPolicy) {
return
}
const nextPolicyInput: TelemetryEffectivePolicy = {
...this.currentPolicy,
...partial,
environmentDisabled: this.environmentOverrides?.telemetryDisabled === true,
isTelemetryAllowed: this.currentPolicy.isTelemetryAllowed,
}
const nextPolicy = this.computePolicy(nextPolicyInput.hostSetting, nextPolicyInput.userOptIn, nextPolicyInput.level)
if (this.didPolicyChange(this.currentPolicy, nextPolicy)) {
this.currentPolicy = nextPolicy
this.listeners.forEach((listener) => listener(nextPolicy))
}
}
private didPolicyChange(previous: TelemetryEffectivePolicy, next: TelemetryEffectivePolicy): boolean {
return (
previous.hostSetting !== next.hostSetting ||
previous.userOptIn !== next.userOptIn ||
previous.environmentDisabled !== next.environmentDisabled ||
previous.isTelemetryAllowed !== next.isTelemetryAllowed ||
previous.level !== next.level
)
}
}