diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index 8377336272b..e8722a98869 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -15,6 +15,7 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use crate::auth::Auth; use crate::constants::{self, AGENT_HOST_PORT}; use crate::log; +use crate::options::TelemetryLevel; use crate::state::LauncherPaths; use crate::tunnels::agent_host::{ classify_agent_host, serve_agent_host_tunnel_connection, AgentHostConfig, AgentHostManager, @@ -288,6 +289,11 @@ async fn run_supervisor(mut ctx: CommandContext, mut args: AgentHostArgs) -> Res Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())), AgentHostConfig { server_data_dir: args.server_data_dir.clone(), + telemetry_level: if ctx.args.global_options.disable_telemetry { + Some(TelemetryLevel::Off) + } else { + ctx.args.global_options.telemetry_level + }, // The AH backend runs on an internal-only unix socket / named // pipe between this supervisor and its child, so we // deliberately disable the backend's token check; this diff --git a/cli/src/tunnels/agent_host.rs b/cli/src/tunnels/agent_host.rs index 74e8b970a9f..dbd54c1c363 100644 --- a/cli/src/tunnels/agent_host.rs +++ b/cli/src/tunnels/agent_host.rs @@ -31,7 +31,7 @@ use crate::async_pipe::{ use crate::constants::VSCODE_CLI_QUALITY; use crate::download_cache::DownloadCache; use crate::log; -use crate::options::Quality; +use crate::options::{Quality, TelemetryLevel}; use crate::state::LauncherPaths; use crate::update_service::{ unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, @@ -98,6 +98,7 @@ const UPGRADE_KILL_DELAY: Duration = Duration::from_secs(3); #[derive(Clone, Debug)] pub struct AgentHostConfig { pub server_data_dir: Option, + pub telemetry_level: Option, pub without_connection_token: bool, pub connection_token: Option, pub connection_token_file: Option, @@ -262,6 +263,10 @@ impl AgentHostManager { cmd.arg("--server-data-dir"); cmd.arg(a); } + if let Some(level) = self.config.telemetry_level { + cmd.arg("--telemetry-level"); + cmd.arg(level.to_string()); + } if self.config.without_connection_token { cmd.arg("--without-connection-token"); } @@ -2255,6 +2260,7 @@ mod tests { Arc::new(ReqwestSimpleHttp::new()), AgentHostConfig { server_data_dir: None, + telemetry_level: None, without_connection_token: true, connection_token: None, connection_token_file: None, diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 43762892b7d..bd841eb310e 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -37,12 +37,12 @@ import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type Completion import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; -import { ITelemetryService, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID, TelemetryLevel, telemetryLevelEnabled } from '../../telemetry/common/telemetry.js'; +import { ITelemetryService, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, getAgentHostTerminalAutoApproveRulesConfig, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, DISABLE_REPO_INFO_TELEMETRY_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { getAgentHostConfigurationSyncEntries, resolveAgentHostConfigurationSyncPatch, resolveAgentHostConfigurationSyncValue } from '../common/agentHostConfigurationSync.js'; import { managedPermissionsConfigurationIds, resolveManagedSettingsPermissions, type IAgentHostManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; -import { AgentHostClientConnectionKind, toClientTelemetryMeta } from '../common/agentHostTelemetry.js'; +import { AgentHostClientConnectionKind, toAgentHostClientMeta } from '../common/agentHostTelemetry.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -455,7 +455,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], clientId: this._clientId, clientInfo: this._clientInfo, - ...this._clientConnectionTelemetryMeta(), + _meta: this._clientMeta(), initialSubscriptions: [ROOT_STATE_URI], }, { bypassInitializeQueue: true }); this._applyInitializeResult(result); @@ -705,7 +705,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC clientId: this._clientId, lastSeenServerSeq, subscriptions, - ...this._clientConnectionTelemetryMeta(), + _meta: this._clientMeta(), }, { bypassReconnectGate: true }); return { result, freshInitialize: false }; } catch (error) { @@ -720,7 +720,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], clientId: this._clientId, clientInfo: this._clientInfo, - ...this._clientConnectionTelemetryMeta(), + _meta: this._clientMeta(), initialSubscriptions: subscriptions, }, { bypassReconnectGate: true }); this._applyInitializeResult(initializeResult, false); @@ -774,12 +774,15 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }, { bypassReconnectGate: true }))); } - private _clientConnectionTelemetryMeta(): { _meta: Record } | Record { - const sendIdentity = telemetryLevelEnabled(this._telemetryService, TelemetryLevel.USAGE); - const machineId = sendIdentity ? this._telemetryService.machineId : undefined; - const devDeviceId = sendIdentity ? this._telemetryService.devDeviceId : undefined; - const meta = toClientTelemetryMeta(this._transport.clientConnectionKind, machineId, devDeviceId); - return meta ? { _meta: meta } : {}; + private _clientMeta(): Record { + const telemetryLevel = this._effectiveTelemetryLevel(); + const sendIdentity = telemetryLevel >= TelemetryLevel.USAGE; + return toAgentHostClientMeta( + this._transport.clientConnectionKind, + telemetryLevel, + sendIdentity ? this._telemetryService.machineId : undefined, + sendIdentity ? this._telemetryService.devDeviceId : undefined, + ); } private _applyInitializeResult(result: CommandMap['initialize']['result'], forwardClientConfig = true): void { @@ -1616,7 +1619,11 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } private _updateTelemetryLevel(): void { - this._dispatchRootConfig({ [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(getTelemetryLevel(this._configurationService)) }); + this._dispatchRootConfig({ [AgentHostTelemetryLevelConfigKey]: telemetryLevelToAgentHostConfigValue(this._effectiveTelemetryLevel()) }); + } + + private _effectiveTelemetryLevel(): TelemetryLevel { + return Math.min(getTelemetryLevel(this._configurationService), this._telemetryService.telemetryLevel); } /** Merge a patch into the agent host's root configuration. */ diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index ff6de4a49ac..f849be88090 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -9,6 +9,7 @@ import { ConfigurationTarget, type IConfigurationService, type IConfigurationVal import { DEFAULT_EDIT_AUTO_APPROVE_PATTERNS, type ChatEditAutoApprovePatterns } from '../../chat/common/chatSettings.js'; import type { IMcpServerConfiguration } from '../../mcp/common/mcpPlatformTypes.js'; import { TelemetryConfiguration, TelemetryLevel } from '../../telemetry/common/telemetry.js'; +import { telemetryLevelToAgentHostValue } from './agentHostTelemetry.js'; import { SessionConfigKey } from './sessionConfigKeys.js'; import type { SessionConfigPropertySchema, SessionConfigSchema } from './state/protocol/commands.js'; import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js'; @@ -593,16 +594,7 @@ export const AgentHostMcpServersConfigKey = 'mcpServers'; export type AgentHostMcpServers = Record; export function telemetryLevelToAgentHostConfigValue(telemetryLevel: TelemetryLevel): TelemetryConfiguration { - switch (telemetryLevel) { - case TelemetryLevel.NONE: - return TelemetryConfiguration.OFF; - case TelemetryLevel.CRASH: - return TelemetryConfiguration.CRASH; - case TelemetryLevel.ERROR: - return TelemetryConfiguration.ERROR; - case TelemetryLevel.USAGE: - return TelemetryConfiguration.ON; - } + return telemetryLevelToAgentHostValue(telemetryLevel); } export function agentHostConfigValueToTelemetryLevel(value: unknown): TelemetryLevel | undefined { diff --git a/src/vs/platform/agentHost/common/agentHostTelemetry.ts b/src/vs/platform/agentHost/common/agentHostTelemetry.ts index 828a1561a12..220568eacb5 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetry.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetry.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { TelemetryConfiguration, TelemetryLevel } from '../../telemetry/common/telemetry.js'; import type { AgentHostClientType } from './agentHostClientInfo.js'; export const enum AgentHostLaunchKind { @@ -49,11 +50,14 @@ export function createUnknownAgentHostClientTelemetryContext(clientType: AgentHo } const CLIENT_CONNECTION_KIND_META_KEY = 'vscode.clientConnectionKind'; +const CLIENT_TELEMETRY_LEVEL_META_KEY = 'vscode.telemetryLevel'; const CLIENT_MACHINE_ID_META_KEY = 'vscode.clientMachineId'; const CLIENT_DEV_DEVICE_ID_META_KEY = 'vscode.clientDevDeviceId'; -export function toClientTelemetryMeta(connectionKind: AgentHostClientConnectionKind | undefined, machineId: string | undefined, devDeviceId: string | undefined): Record | undefined { - const meta: Record = {}; +export function toAgentHostClientMeta(connectionKind: AgentHostClientConnectionKind | undefined, telemetryLevel: TelemetryLevel, machineId: string | undefined, devDeviceId: string | undefined): Record { + const meta: Record = { + [CLIENT_TELEMETRY_LEVEL_META_KEY]: telemetryLevelToAgentHostValue(telemetryLevel), + }; if (connectionKind !== undefined && connectionKind !== AgentHostClientConnectionKind.Unknown) { meta[CLIENT_CONNECTION_KIND_META_KEY] = connectionKind; } @@ -63,7 +67,7 @@ export function toClientTelemetryMeta(connectionKind: AgentHostClientConnectionK if (devDeviceId) { meta[CLIENT_DEV_DEVICE_ID_META_KEY] = devDeviceId; } - return Object.keys(meta).length > 0 ? meta : undefined; + return meta; } export function readClientConnectionKind(meta: Record | undefined): AgentHostClientConnectionKind { @@ -82,6 +86,35 @@ export function readClientConnectionKind(meta: Record | undefin } } +export function readClientTelemetryLevel(meta: Record | undefined): TelemetryLevel | undefined { + const value = meta?.[CLIENT_TELEMETRY_LEVEL_META_KEY]; + switch (value) { + case TelemetryConfiguration.OFF: + return TelemetryLevel.NONE; + case TelemetryConfiguration.CRASH: + return TelemetryLevel.CRASH; + case TelemetryConfiguration.ERROR: + return TelemetryLevel.ERROR; + case TelemetryConfiguration.ON: + return TelemetryLevel.USAGE; + default: + return value === undefined ? undefined : TelemetryLevel.NONE; + } +} + +export function telemetryLevelToAgentHostValue(telemetryLevel: TelemetryLevel): TelemetryConfiguration { + switch (telemetryLevel) { + case TelemetryLevel.NONE: + return TelemetryConfiguration.OFF; + case TelemetryLevel.CRASH: + return TelemetryConfiguration.CRASH; + case TelemetryLevel.ERROR: + return TelemetryConfiguration.ERROR; + case TelemetryLevel.USAGE: + return TelemetryConfiguration.ON; + } +} + export function readClientMachineId(meta: Record | undefined): string | undefined { return readClientTelemetryIdentity(meta, CLIENT_MACHINE_ID_META_KEY); } diff --git a/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts index d55d5e33af6..705f1703833 100644 --- a/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts +++ b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts @@ -21,6 +21,7 @@ export const AgentHostMachineIdEnvKey = 'VSCODE_AGENT_HOST_MACHINE_ID'; export const AgentHostSqmIdEnvKey = 'VSCODE_AGENT_HOST_SQM_ID'; export const AgentHostDevDeviceIdEnvKey = 'VSCODE_AGENT_HOST_DEV_DEVICE_ID'; +export const AgentHostTelemetryLevelEnvKey = 'VSCODE_AGENT_HOST_TELEMETRY_LEVEL'; export interface IAgentHostForwardedTelemetryIds { readonly machineId: string; diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index 65196556c5f..0b6f6dece3e 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -17,11 +17,12 @@ import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMa import { ILogService } from '../../log/common/log.js'; import { Schemas } from '../../../base/common/network.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { AgentHostStartError, IAgentHostConnection, IAgentHostShutdownRequest, IAgentHostStarter, IAgentHostStartRequest } from '../common/agent.js'; import { buildAgentHostTelemetryIdEnv, IAgentHostForwardedTelemetryIds } from '../common/agentHostTelemetryEnv.js'; -import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar } from '../common/agentHostTelemetry.js'; +import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar, telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostIpcChannels, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, AgentHostRestartIpcChannel, AgentHostWillRestartIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostManagementService, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; import '../common/agentHostStarter.config.contribution.js'; @@ -54,6 +55,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, @ILifecycleMainService private readonly _lifecycleMainService: ILifecycleMainService, @ILogService private readonly _logService: ILogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -158,10 +160,8 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt const args = [ '--logsPath', this._environmentMainService.logsHome.with({ scheme: Schemas.file }).fsPath, '--user-data-dir', this._environmentMainService.userDataPath, + '--telemetry-level', telemetryLevelToAgentHostValue(this._telemetryService.telemetryLevel), ]; - if (this._environmentMainService.disableTelemetry) { - args.push('--disable-telemetry'); - } // Forward the host's resolved telemetry identifiers so the agent host // reuses the same persisted machineId/sqmId/devDeviceId instead of diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 73d5af4f449..0614f8686f9 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -23,7 +23,7 @@ import { TelemetryLogAppender } from '../../telemetry/common/telemetryLogAppende import { TelemetryService } from '../../telemetry/common/telemetryService.js'; import { getPiiPathsFromEnvironment, isInternalTelemetry, isLoggingOnly, NullTelemetryService, supportsTelemetry, type ITelemetryAppender } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel } from '../common/agentHostSchema.js'; -import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey } from '../common/agentHostTelemetryEnv.js'; +import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey, AgentHostTelemetryLevelEnvKey } from '../common/agentHostTelemetryEnv.js'; import { AgentHostRestrictedTelemetrySender, IAgentHostRestrictedTelemetry, IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetryContext, TelemetryMeasurements, TelemetryProps } from './agentHostRestrictedTelemetry.js'; import { AgentHostInternalTelemetrySender } from './agentHostMicrosoftTelemetry.js'; @@ -37,6 +37,7 @@ export interface IAgentHostTelemetryServiceOptions { readonly disableTelemetry?: boolean; readonly fetchFn?: typeof globalThis.fetch; readonly requestService?: IRequestService; + readonly readTelemetryLevelEnvironment?: () => string | undefined; } export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHostRestrictedTelemetry { @@ -46,7 +47,7 @@ export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHos export class AgentHostTelemetryService extends Disposable implements IAgentHostTelemetryService { declare readonly _serviceBrand: undefined; - private _telemetryLevel = TelemetryLevel.USAGE; + private _telemetryLevel: TelemetryLevel; /** * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Defaults @@ -61,8 +62,10 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT private readonly _restricted?: IAgentHostRestrictedTelemetry, copilotSdkVersion?: string, copilotRuntimeVersion?: string, + initialTelemetryLevel: TelemetryLevel = TelemetryLevel.USAGE, ) { super(); + this._telemetryLevel = initialTelemetryLevel; if (isDisposable(_delegate)) { this._register(_delegate); } @@ -274,5 +277,15 @@ export async function createAgentHostTelemetryService(options: IAgentHostTelemet const internalSender = loggingOnly ? undefined : disposables.add(new AgentHostInternalTelemetrySender({ requestService: options.requestService, commonProperties, extensionVersion })); const restricted = loggingOnly ? undefined : new AgentHostRestrictedTelemetrySender(commonProperties, logService, undefined, internalSender, options.fetchFn); - return disposables.add(new AgentHostTelemetryService(telemetryService, restricted, productService.copilotVersions?.sdk, productService.copilotVersions?.runtime)); + const initialTelemetryLevel = Math.min( + parseLaunchTelemetryLevel(environmentService.args?.['telemetry-level']), + parseLaunchTelemetryLevel((options.readTelemetryLevelEnvironment ?? (() => process.env[AgentHostTelemetryLevelEnvKey]))()), + ); + return disposables.add(new AgentHostTelemetryService(telemetryService, restricted, productService.copilotVersions?.sdk, productService.copilotVersions?.runtime, initialTelemetryLevel)); +} + +function parseLaunchTelemetryLevel(value: string | undefined): TelemetryLevel { + return value === undefined + ? TelemetryLevel.USAGE + : agentHostConfigValueToTelemetryLevel(value) ?? TelemetryLevel.NONE; } diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index e0a97d122af..e1db0113534 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -14,8 +14,9 @@ import { IEnvironmentService, INativeEnvironmentService } from '../../environmen import { parseAgentHostDebugPort } from '../../environment/node/environmentService.js'; import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar } from '../common/agentHostTelemetry.js'; +import { AgentHostLaunchKind, AgentHostLaunchKindEnvVar, telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostIpcChannels, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostManagementService } from '../common/agentService.js'; import '../common/agentHostStarter.config.contribution.js'; @@ -46,6 +47,7 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService private readonly _environmentService: INativeEnvironmentService, @ILogService private readonly _logService: ILogService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); } @@ -132,10 +134,8 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte '--type=agentHost', '--logsPath', this._environmentService.logsHome.with({ scheme: Schemas.file }).fsPath, '--user-data-dir', this._environmentService.userDataPath, + '--telemetry-level', telemetryLevelToAgentHostValue(this._telemetryService.telemetryLevel), ]; - if (this._environmentService.disableTelemetry) { - args.push('--disable-telemetry'); - } const opts: IIPCOptions = { serverName: 'Agent Host', diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 79c8593079a..baecb6a3488 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -15,7 +15,7 @@ import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; import { getAgentHostClientType } from '../common/agentHostClientInfo.js'; -import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, readClientDevDeviceId, readClientMachineId, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportKind, readClientConnectionKind, readClientDevDeviceId, readClientMachineId, readClientTelemetryLevel, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { AgentSession, type IAgentCreateChatOptions, type IMcpNotification } from '../common/agent.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { type IAgentService } from '../common/agentService.js'; @@ -66,6 +66,7 @@ import { isFileResourceRead } from '../common/resourceReadLogging.js'; import type { Implementation } from '../common/state/protocol/common/commands.js'; import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, AgentHostClientConnectionTelemetryTracker } from './agentHostClientConnectionTelemetry.js'; import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; +import { isAgentHostTelemetryService } from './agentHostTelemetryService.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; @@ -371,11 +372,11 @@ export class ProtocolServerHandler extends Disposable { private readonly _config: IProtocolServerConfig, private readonly _clientFileSystemProvider: AHPFileSystemProvider, @ILogService private readonly _logService: ILogService, - @ITelemetryService telemetryService: ITelemetryService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, @IAgentHostManagedSettingsService private readonly _managedSettingsService: IAgentHostManagedSettingsService, ) { super(); - this._telemetryReporter = new AgentHostTelemetryReporter(telemetryService); + this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._connectionTelemetryTracker = this._config.connectionTelemetryTracker ?? this._register(new AgentHostClientConnectionTelemetryTracker()); this._register(this._server.onConnection(transport => { @@ -596,6 +597,7 @@ export class ProtocolServerHandler extends Disposable { } const previousRecord = this._clients.get(params.clientId); + this._applyClientTelemetryLevel(params._meta); const telemetryTransportToken = {}; const initializationDisposables = disposables.add(new DisposableStore()); const telemetryContext = this._createClientTelemetryContext(params.clientInfo, params._meta, transport); @@ -736,6 +738,7 @@ export class ProtocolServerHandler extends Disposable { if (!existingRecord) { throw new ProtocolError(AhpErrorCodes.NotFound, `Reconnect client not found: ${params.clientId}`); } + this._applyClientTelemetryLevel(params._meta); // Synchronously install the client so messages arriving on this transport // while we restore subscriptions can find a valid client object. The @@ -1194,6 +1197,13 @@ export class ProtocolServerHandler extends Disposable { }; } + private _applyClientTelemetryLevel(meta: Record | undefined): void { + const telemetryLevel = readClientTelemetryLevel(meta); + if (telemetryLevel !== undefined && isAgentHostTelemetryService(this._telemetryService)) { + this._telemetryService.updateTelemetryLevel(telemetryLevel); + } + } + private _reportClientDisconnected(client: IConnectedClient, subscriptionCount: number): void { if (!client.telemetryConnectionActive) { return; diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts index e4d814c25ec..0cb0a6b1dd3 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostHelpers.ts @@ -6,6 +6,7 @@ import { timeout } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { vArray, vObj, vString, vUnknown } from '../../../base/common/validation.js'; +import { TelemetryConfiguration } from '../../telemetry/common/telemetry.js'; import { getAgentHostEndpointIdentityKey, IAgentHostEndpointMetadata, parseAgentHostEndpointRegistry } from '../common/agentHostEndpointRegistry.js'; /** @@ -21,6 +22,18 @@ export function validateShellToken(value: string, label: string): string { return value; } +export function validateAgentHostTelemetryLevel(value: unknown): TelemetryConfiguration { + switch (value) { + case TelemetryConfiguration.OFF: + case TelemetryConfiguration.CRASH: + case TelemetryConfiguration.ERROR: + case TelemetryConfiguration.ON: + return value; + default: + throw new Error(`Unsafe telemetry level for shell interpolation: ${JSON.stringify(value)}`); + } +} + /** * Validate and normalize a commit SHA. Returns the lowercase form. * @@ -127,8 +140,8 @@ export function shellEscape(s: string): string { * build them via {@link getRemoteCLIBin} / {@link getRemoteCLIDataDir} * which validate their components. */ -export function buildAgentHostBaseCommand(cliBin: string, cliDataDir: string): string { - return `${cliBin} --cli-data-dir ${cliDataDir} agent host --port 0`; +export function buildAgentHostBaseCommand(cliBin: string, cliDataDir: string, telemetryLevel: TelemetryConfiguration): string { + return `${cliBin} --cli-data-dir ${cliDataDir} --telemetry-level ${validateAgentHostTelemetryLevel(telemetryLevel)} agent host --port 0`; } export function resolveRemotePlatform(unameS: string, unameM: string): { os: string; arch: string } | undefined { @@ -345,11 +358,11 @@ export function buildAgentEndpointsCommand(cliBin: string, cliDataDir: string, u * genuinely new process/registry entry every time this command runs, * leaving all existing standalone/editor entries untouched. */ -export function buildAgentHostSpawnCommand(cliBin: string, cliDataDir: string, userDataPath: string, idleTimeoutSec = 300): string { +export function buildAgentHostSpawnCommand(cliBin: string, cliDataDir: string, userDataPath: string, telemetryLevel: TelemetryConfiguration, idleTimeoutSec = 300): string { if (!Number.isSafeInteger(idleTimeoutSec) || idleTimeoutSec <= 0) { throw new Error(`Unsafe idle timeout value for shell interpolation: ${JSON.stringify(idleTimeoutSec)}`); } - return `${buildAgentHostBaseCommand(cliBin, cliDataDir)} --new-instance --user-data-dir ${shellEscape(userDataPath)} --idle-timeout ${idleTimeoutSec}`; + return `${buildAgentHostBaseCommand(cliBin, cliDataDir, telemetryLevel)} --new-instance --user-data-dir ${shellEscape(userDataPath)} --idle-timeout ${idleTimeoutSec}`; } /** diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 6b85586cb3e..6442994a2e6 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -17,6 +17,7 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; +import { ITelemetryService, TelemetryConfiguration } from '../../telemetry/common/telemetry.js'; import { ISSHRemoteAgentHostMainService, SSHAuthMethod, @@ -46,6 +47,8 @@ import { } from './sshKnownHosts.js'; import type { RemoteAgentHostLocationPreference } from '../common/remoteAgentHostLocationPreference.js'; import type { IRelayMessage } from '../common/relayTransport.js'; +import { AgentHostTelemetryLevelEnvKey } from '../common/agentHostTelemetryEnv.js'; +import { telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; import { type AgentHostEndpointAddress, type AgentHostServerType, @@ -69,6 +72,7 @@ import { resolveRemotePlatform, runAgentEndpoints, shellEscape, + validateAgentHostTelemetryLevel, waitForNewStandaloneEndpoint, } from './sshRemoteAgentHostHelpers.js'; import { parseSSHConfigHostEntries, parseSSHGOutput, stripSSHComment } from '../common/sshConfigParsing.js'; @@ -372,18 +376,20 @@ function startRemoteAgentHost( cliBin: string | undefined, cliDataDir: string | undefined, commandOverride?: string, + telemetryLevel = TelemetryConfiguration.OFF, ): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> { return new Promise((resolve, reject) => { if (!commandOverride && (!cliBin || !cliDataDir)) { reject(new Error(`${LOG_PREFIX} startRemoteAgentHost requires either a cliBin+cliDataDir pair or a commandOverride`)); return; } - const baseCmd = commandOverride ?? buildAgentHostBaseCommand(cliBin!, cliDataDir!); + const validatedTelemetryLevel = validateAgentHostTelemetryLevel(telemetryLevel); + const baseCmd = commandOverride ?? buildAgentHostBaseCommand(cliBin!, cliDataDir!, validatedTelemetryLevel); // Wrap in a login shell so the agent host process inherits the // user's PATH and environment from ~/.bash_profile / ~/.bashrc // (ssh2 exec runs a non-interactive non-login shell by default). // Echo the PID so we can record it for process reuse detection. - const cmd = `bash -l -c ${shellEscape(`echo VSCODE_PID=$$ && exec ${baseCmd}`)}`; + const cmd = `bash -l -c ${shellEscape(`echo VSCODE_PID=$$ && export ${AgentHostTelemetryLevelEnvKey}=${validatedTelemetryLevel} && exec ${baseCmd}`)}`; logService.info(`${LOG_PREFIX} Starting remote agent host: ${cmd}`); client.exec(cmd, (err: Error | undefined, stream: SSHChannel) => { @@ -768,6 +774,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem constructor( @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); } @@ -917,7 +924,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem // picker over. Always start a fresh process (requirement 6). this._logService.info(`${LOG_PREFIX} Using custom agent host command: ${config.remoteAgentHostCommand}; skipping endpoint discovery/selection`); reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); - const result = await this._startRemoteAgentHost(sshClient, undefined, undefined, config.remoteAgentHostCommand); + const result = await this._startRemoteAgentHost(sshClient, undefined, undefined, config.remoteAgentHostCommand, this._effectiveTelemetryLevel); endpoint = { type: 'tcp', host: '127.0.0.1', port: result.port }; connectionToken = result.connectionToken; agentStream = result.stream; @@ -948,7 +955,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem const standalones = live.filter(e => e.type === 'standalone'); const spawnDedicated = async (): Promise => { - const spawnCommand = buildAgentHostSpawnCommand(cliBin, cliDataDir, userDataPath); + const spawnCommand = buildAgentHostSpawnCommand(cliBin, cliDataDir, userDataPath, this._effectiveTelemetryLevel); reportProgress(localize('sshProgressStartingAgent', "Starting remote agent host...")); this._logService.info(`${LOG_PREFIX} Spawning dedicated standalone agent host: ${spawnCommand}`); // Fire-and-forget: the spawned process is self-managed via @@ -2032,10 +2039,14 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem return this._productService.commit; } + private get _effectiveTelemetryLevel(): TelemetryConfiguration { + return telemetryLevelToAgentHostValue(this._telemetryService.telemetryLevel); + } + protected _startRemoteAgentHost( - client: SSHClient, cliBin: string | undefined, cliDataDir: string | undefined, commandOverride?: string, + client: SSHClient, cliBin: string | undefined, cliDataDir: string | undefined, commandOverride?: string, telemetryLevel?: TelemetryConfiguration, ): Promise<{ port: number; connectionToken: string | undefined; pid: number | undefined; stream: SSHChannel }> { - return startRemoteAgentHost(client, this._logService, cliBin, cliDataDir, commandOverride); + return startRemoteAgentHost(client, this._logService, cliBin, cliDataDir, commandOverride, telemetryLevel); } protected async _createWebSocketRelay( diff --git a/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts b/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts index 861e70b8955..c63d24300fd 100644 --- a/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts +++ b/src/vs/platform/agentHost/node/wslRemoteAgentHostHelpers.ts @@ -5,6 +5,8 @@ import * as cp from 'child_process'; import { join } from '../../../base/common/path.js'; +import { TelemetryConfiguration } from '../../telemetry/common/telemetry.js'; +import { AgentHostTelemetryLevelEnvKey } from '../common/agentHostTelemetryEnv.js'; import type { IWSLDistro } from '../common/wslRemoteAgentHost.js'; import { buildAgentHostBaseCommand, @@ -15,6 +17,7 @@ import { getRemoteCLIDataDir, getRemoteCLIInstallRoot, shellEscape, + validateAgentHostTelemetryLevel, validateShellToken, } from './sshRemoteAgentHostHelpers.js'; @@ -250,6 +253,7 @@ export interface IComposeAgentHostBootstrapScriptArgs { readonly commit: string | undefined; readonly os: string; readonly arch: string; + readonly telemetryLevel?: TelemetryConfiguration; /** Dev override; when set, returned verbatim and all CLI bootstrap is skipped. */ readonly remoteAgentHostCommand?: string; } @@ -270,14 +274,15 @@ export interface IComposeAgentHostBootstrapScriptArgs { * lives in the helper functions above, not in the composition itself. */ export function composeAgentHostBootstrapScript(args: IComposeAgentHostBootstrapScriptArgs): string { + const telemetryLevel = validateAgentHostTelemetryLevel(args.telemetryLevel ?? TelemetryConfiguration.OFF); if (args.remoteAgentHostCommand) { - return args.remoteAgentHostCommand; + return `export ${AgentHostTelemetryLevelEnvKey}=${telemetryLevel} && ${args.remoteAgentHostCommand}`; } const installRoot = getRemoteCLIInstallRoot(args.serverDataFolderName); const cliBin = getRemoteCLIBin(args.serverDataFolderName, args.quality, args.commit); const cliDataDir = getRemoteCLIDataDir(args.serverDataFolderName); const url = buildCLIDownloadUrl(args.os, args.arch, args.quality, args.commit); - const launch = `exec ${buildAgentHostBaseCommand(cliBin, cliDataDir)}`; + const launch = `exec ${buildAgentHostBaseCommand(cliBin, cliDataDir, telemetryLevel)}`; if (args.commit) { // Pinned-install path. Mirrors SSH's _ensureCLIInstalledPinned: stage diff --git a/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts index 32f01db5807..0f6d052bd2d 100644 --- a/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/wslRemoteAgentHostService.ts @@ -12,6 +12,8 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { telemetryLevelToAgentHostValue } from '../common/agentHostTelemetry.js'; import type { IRelayMessage } from '../common/relayTransport.js'; import { IWSLRemoteAgentHostMainService, @@ -80,6 +82,7 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem constructor( @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._register(toDisposable(() => { @@ -193,6 +196,7 @@ export class WSLRemoteAgentHostMainService extends Disposable implements IWSLRem commit: this._commit, os: targetOs, arch: targetArch, + telemetryLevel: telemetryLevelToAgentHostValue(this._telemetryService.telemetryLevel), remoteAgentHostCommand: config.remoteAgentHostCommand, }); diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 5d5b1de8187..faf4bc7dbfd 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -63,7 +63,7 @@ const syncTestConfigurationNode = { }, }; import type { Implementation } from '../../common/state/protocol/common/commands.js'; -import { agentsWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; +import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; type ProtocolTransportMessage = ProtocolMessage | AhpServerNotification | JsonRpcNotification | JsonRpcResponse | JsonRpcRequest; @@ -318,6 +318,7 @@ suite('RemoteAgentHostProtocolClient', () => { assert.deepStrictEqual((initialize.params as { _meta?: Record })._meta, { 'vscode.clientConnectionKind': AgentHostClientConnectionKind.RemoteExtensionHost, + 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', 'vscode.clientDevDeviceId': 'client-dev-device-id', }); @@ -333,7 +334,9 @@ suite('RemoteAgentHostProtocolClient', () => { const noTelemetryClient = createClient(noTelemetryTransport).client; const noTelemetryConnectPromise = noTelemetryClient.connect(); const noTelemetryInitialize = noTelemetryTransport.sentMessages[0] as JsonRpcRequest; - assert.strictEqual((noTelemetryInitialize.params as { _meta?: Record })._meta, undefined); + assert.deepStrictEqual((noTelemetryInitialize.params as { _meta?: Record })._meta, { + 'vscode.telemetryLevel': 'off', + }); noTelemetryTransport.fireMessage({ jsonrpc: '2.0', id: noTelemetryInitialize.id, @@ -966,7 +969,7 @@ suite('RemoteAgentHostProtocolClient', () => { test('initialize handshake includes protocol version and client info', async () => { const transport = disposables.add(new TestClientProtocolTransport(AgentHostClientConnectionKind.DevTunnel)); const clientInfo = agentsWindowAgentHostClientInfo; - const { client } = createClient(transport, undefined, undefined, undefined, undefined, 'renderer-client-id', clientInfo); + const { client } = createClientForIdentity('test.example:1234', transport, createPermissionService(), undefined, new NullLogService(), new TestConfigurationService(), 'renderer-client-id', clientInfo, new TestClientIdentityTelemetryService()); const connectPromise = client.connect(); transport.connectDeferred.complete(); @@ -990,7 +993,12 @@ suite('RemoteAgentHostProtocolClient', () => { protocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS], clientId: 'renderer-client-id', clientInfo, - _meta: { 'vscode.clientConnectionKind': 'dev_tunnel' }, + _meta: { + 'vscode.clientConnectionKind': 'dev_tunnel', + 'vscode.telemetryLevel': 'all', + 'vscode.clientMachineId': 'client-machine-id', + 'vscode.clientDevDeviceId': 'client-dev-device-id', + }, }); assert.strictEqual(params.protocolVersions[0], PROTOCOL_VERSION); @@ -1030,6 +1038,40 @@ suite('RemoteAgentHostProtocolClient', () => { }); }); + test('forwards the actual telemetry service restriction during initialization and config sync', async () => { + const transport = disposables.add(new TestProtocolTransport(AgentHostClientConnectionKind.RemoteExtensionHost)); + const configurationService = new TestConfigurationService(); + const client = disposables.add(new RemoteAgentHostProtocolClient( + 'test.example:1234', + transport, + undefined, + 'telemetry-disabled-client', + editorWindowAgentHostClientInfo, + new NullLogService(), + createPermissionService(), + configurationService, + NullTelemetryService, + )); + + const connectPromise = client.connect(); + const initialize = transport.sentMessages[0] as JsonRpcRequest; + assert.deepStrictEqual((initialize.params as { _meta?: Record })._meta, { + 'vscode.clientConnectionKind': AgentHostClientConnectionKind.RemoteExtensionHost, + 'vscode.telemetryLevel': 'off', + }); + transport.fireMessage({ + jsonrpc: '2.0', + id: initialize.id, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [] }, + }); + await connectPromise; + + assert.strictEqual( + findRootConfigValue(transport.sentMessages, AgentHostTelemetryLevelConfigKey), + 'off', + ); + }); + test('forwards every setting declaring `agentHost` on connect and when one changes', async () => { const configurationService = new TestConfigurationService({ [SYNC_SETTING_A]: true, @@ -2004,6 +2046,7 @@ suite('RemoteAgentHostProtocolClient', () => { reconnectTransport.connectDeferred.complete(); const reconnect = await waitForRequest(reconnectTransport, 'reconnect'); assert.deepStrictEqual((reconnect.params as { _meta?: Record })._meta, { + 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', 'vscode.clientDevDeviceId': 'client-dev-device-id', }); @@ -2020,6 +2063,7 @@ suite('RemoteAgentHostProtocolClient', () => { }, { clientInfo: agentsWindowAgentHostClientInfo, meta: { + 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', 'vscode.clientDevDeviceId': 'client-dev-device-id', }, diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index eb4e65928b7..38870271b5b 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -17,7 +17,7 @@ import type { IProductService } from '../../../product/common/productService.js' import { ITelemetryData, ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; import { AgentHostRestrictedTelemetrySender, IAgentHostRestrictedTelemetry, IAgentHostInternalTelemetryContext, IAgentHostRestrictedTelemetryContext, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; -import { AgentHostTelemetryService, createAgentHostTelemetryService, updateAgentHostTelemetryLevelFromConfig } from '../../node/agentHostTelemetryService.js'; +import { AgentHostTelemetryService, createAgentHostTelemetryService, type IAgentHostTelemetryService, updateAgentHostTelemetryLevelFromConfig } from '../../node/agentHostTelemetryService.js'; import { AgentHostInternalTelemetrySender } from '../../node/agentHostMicrosoftTelemetry.js'; class TestTelemetryService implements ITelemetryService { @@ -101,6 +101,54 @@ class TestRestrictedSink implements IAgentHostRestrictedTelemetry { suite('AgentHostTelemetryService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + async function createFactoryService(telemetryLevelArg: string | undefined, telemetryLevelEnvironment: string | undefined): Promise { + const localDisposables = disposables.add(new DisposableStore()); + const logService = new NullLogService(); + const fileService = localDisposables.add(new FileService(logService)); + localDisposables.add(fileService.registerProvider(Schemas.file, localDisposables.add(new InMemoryFileSystemProvider()))); + return createAgentHostTelemetryService({ + environmentService: { + args: telemetryLevelArg === undefined ? {} : { 'telemetry-level': telemetryLevelArg }, + isBuilt: true, + disableTelemetry: false, + appRoot: '/app', + extensionsPath: '/extensions', + userHome: URI.file('/home'), + tmpDir: URI.file('/tmp'), + userDataPath: '/user-data', + appSettingsHome: URI.file('/User'), + } as INativeEnvironmentService, + productService: { + _serviceBrand: undefined, + version: '1.130.0', + enableTelemetry: true, + } as IProductService, + fileService, + loggerService: localDisposables.add(new NullLoggerService()), + logService, + disposables: localDisposables, + readTelemetryLevelEnvironment: () => telemetryLevelEnvironment, + }); + } + + test('uses the most restrictive valid launch source and fails closed for malformed sources', async () => { + const services = await Promise.all([ + createFactoryService('all', 'off'), + createFactoryService('off', 'all'), + createFactoryService('invalid', 'all'), + createFactoryService('all', 'invalid'), + createFactoryService(undefined, undefined), + ]); + + assert.deepStrictEqual(services.map(service => service.telemetryLevel), [ + TelemetryLevel.NONE, + TelemetryLevel.NONE, + TelemetryLevel.NONE, + TelemetryLevel.NONE, + TelemetryLevel.USAGE, + ]); + }); + test('logging-only builds do not create restricted network senders', async () => { const localDisposables = disposables.add(new DisposableStore()); const logService = new NullLogService(); @@ -165,17 +213,18 @@ suite('AgentHostTelemetryService', () => { assert.strictEqual((internalSender as unknown as { _options: { extensionVersion: string | undefined } })._options.extensionVersion, '0.58.0'); }); - test('permanently disables usage and error telemetry after TelemetryLevel.NONE', async () => { + test('uses the launch telemetry level before a client connects and only becomes more restrictive', () => { const delegate = new TestTelemetryService(); - const service = disposables.add(new AgentHostTelemetryService(delegate)); + const service = disposables.add(new AgentHostTelemetryService(delegate, undefined, undefined, undefined, TelemetryLevel.USAGE)); - service.publicLog('beforeDisable', { count: 1 }); + service.publicLog('beforeClientLevel', { count: 1 }); + service.updateTelemetryLevel(TelemetryLevel.ERROR); + service.publicLog('afterClientLevel', { count: 2 }); + service.publicLogError('afterClientLevelError', { count: 3 }); service.updateTelemetryLevel(TelemetryLevel.NONE); service.updateTelemetryLevel(TelemetryLevel.USAGE); service.publicLog2('afterDisable'); service.publicLogError2('afterDisableError'); - service.publicLog('afterDisableAsync', { count: 4 }); - service.publicLogError('afterDisableErrorAsync', { count: 5 }); assert.deepStrictEqual({ telemetryLevel: service.telemetryLevel, @@ -185,8 +234,8 @@ suite('AgentHostTelemetryService', () => { }, { telemetryLevel: TelemetryLevel.NONE, sendErrorTelemetry: false, - events: [{ eventName: 'beforeDisable', data: { count: 1 } }], - errorEvents: [], + events: [{ eventName: 'beforeClientLevel', data: { count: 1 } }], + errorEvents: [{ eventName: 'afterClientLevelError', data: { count: 3 } }], }); }); @@ -235,6 +284,7 @@ suite('AgentHostTelemetryService', () => { test('enhanced GH telemetry is gated on the restricted (rt) opt-in; standard GH telemetry is not', () => { const restricted = new TestRestrictedSink(); const service = disposables.add(new AgentHostTelemetryService(new TestTelemetryService(), restricted)); + service.updateTelemetryLevel(TelemetryLevel.USAGE); service.sendEnhancedGHTelemetryEvent('request.options.tools'); // dropped: rt disabled by default service.sendGHTelemetryEvent('completion'); // sent: standard GH telemetry is not rt-gated @@ -264,6 +314,7 @@ suite('AgentHostTelemetryService', () => { delegate.telemetryLevel = TelemetryLevel.ERROR; // user opted below USAGE const restricted = new TestRestrictedSink(); const service = disposables.add(new AgentHostTelemetryService(delegate, restricted)); + service.updateTelemetryLevel(TelemetryLevel.USAGE); service.setRestrictedTelemetryEnabled(true); // rt=1 service.sendEnhancedGHTelemetryEvent('request.options.tools'); @@ -276,6 +327,7 @@ suite('AgentHostTelemetryService', () => { test('internal telemetry is independently gated and identity is cleared on account changes', () => { const restricted = new TestRestrictedSink(); const service = disposables.add(new AgentHostTelemetryService(new TestTelemetryService(), restricted)); + service.updateTelemetryLevel(TelemetryLevel.USAGE); const internalContext = { isInternal: true, trackingId: 'tid-1', userName: 'octocat', isVscodeTeamMember: true }; service.sendInternalMSFTTelemetryEvent('beforeIdentity'); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index f03d8b67a7a..2d2bc1ee184 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -7,12 +7,14 @@ import assert from 'assert'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { hasKey } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; -import { NullTelemetryService, NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { type IAgentCreateChatOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; @@ -33,6 +35,7 @@ import { iterateOtlpLogRecords, OtlpLogEmitter } from '../../common/otlp/otlpLog import { MessagePortProtocolServer } from '../../node/messagePortProtocolServer.js'; import { AgentHostClientConnectionTelemetryTracker } from '../../node/agentHostClientConnectionTelemetry.js'; import { AgentHostManagedSettingsService } from '../../node/agentHostManagedSettingsService.js'; +import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; // ---- Mock helpers ----------------------------------------------------------- @@ -108,14 +111,27 @@ class FailingReconnectAgentHostFileSystemProvider extends AgentHostFileSystemPro } } -class TestTelemetryService extends NullTelemetryServiceShape { +class TestTelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + readonly telemetryLevel = TelemetryLevel.USAGE; + readonly sendErrorTelemetry = true; + readonly sessionId = 'session'; + readonly machineId = 'machine'; + readonly sqmId = 'sqm'; + readonly devDeviceId = 'device'; + readonly firstSessionDate = 'first-session'; readonly events: { eventName: string; data: unknown }[] = []; - override publicLog2(eventName?: string, data?: unknown): void { + publicLog(): void { } + publicLog2(eventName?: string, data?: unknown): void { if (eventName) { this.events.push({ eventName, data }); } } + publicLogError(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } } class MockAgentService implements IAgentService { @@ -291,6 +307,7 @@ suite('ProtocolServerHandler', () => { let fileSystemProvider: AgentHostFileSystemProvider; let logService: CountingLogService; let telemetryService: TestTelemetryService; + let agentHostTelemetryService: AgentHostTelemetryService; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); const defaultChatUri = buildDefaultChatUri(sessionUri); @@ -314,7 +331,10 @@ suite('ProtocolServerHandler', () => { protocolVersions: [PROTOCOL_VERSION], clientId, clientInfo, - _meta: meta, + _meta: { + 'vscode.telemetryLevel': 'all', + ...meta, + }, initialSubscriptions, })); return transport; @@ -329,6 +349,7 @@ suite('ProtocolServerHandler', () => { managedSettingsService = disposables.add(new AgentHostManagedSettingsService()); logService = new CountingLogService(); telemetryService = new TestTelemetryService(); + agentHostTelemetryService = disposables.add(new AgentHostTelemetryService(telemetryService)); disposables.add(agentService); disposables.add(handler = new ProtocolServerHandler( agentService, @@ -337,7 +358,7 @@ suite('ProtocolServerHandler', () => { { hostLaunchKind: AgentHostLaunchKind.VSCodeMainProcess, defaultDirectory: URI.file('/home/testuser').toString() }, disposables.add(fileSystemProvider = new AgentHostFileSystemProvider()), logService, - telemetryService, + agentHostTelemetryService, managedSettingsService, )); }); @@ -352,12 +373,79 @@ suite('ProtocolServerHandler', () => { const transport = connectClient('client-1'); const resp = findResponse(transport.sent, 1); - assert.ok(resp, 'should have sent initialize response'); - const result = (resp as { result: InitializeResult }).result; + if (!resp || !hasKey(resp, { result: true })) { + assert.fail('should have sent initialize response'); + } + const result = resp.result as InitializeResult; assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); assert.strictEqual(result.serverSeq, stateManager.serverSeq); }); + test('applies telemetry disablement before reporting the client connection', () => { + const transport = new MockProtocolTransport(AgentHostTransportKind.WebSocket); + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'telemetry-disabled-client', + clientInfo: editorWindowAgentHostClientInfo, + _meta: { + 'vscode.clientConnectionKind': AgentHostClientConnectionKind.RemoteExtensionHost, + 'vscode.telemetryLevel': 'off', + }, + })); + + assert.deepStrictEqual({ + telemetryLevel: agentHostTelemetryService.telemetryLevel, + events: telemetryService.events, + }, { + telemetryLevel: TelemetryLevel.NONE, + events: [], + }); + transport.simulateClose(); + transport.dispose(); + }); + + test('uses the launch telemetry level when a legacy client omits telemetry metadata', () => { + const transport = new MockProtocolTransport(AgentHostTransportKind.WebSocket); + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'legacy-client', + clientInfo: editorWindowAgentHostClientInfo, + })); + + assert.deepStrictEqual({ + telemetryLevel: agentHostTelemetryService.telemetryLevel, + eventNames: telemetryService.events.map(event => event.eventName), + }, { + telemetryLevel: TelemetryLevel.USAGE, + eventNames: ['agentHost.clientConnection'], + }); + transport.simulateClose(); + transport.dispose(); + }); + + test('fails closed before reporting the client connection for malformed telemetry metadata', () => { + const transport = new MockProtocolTransport(AgentHostTransportKind.WebSocket); + server.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId: 'malformed-telemetry-client', + clientInfo: editorWindowAgentHostClientInfo, + _meta: { 'vscode.telemetryLevel': 'invalid' }, + })); + + assert.deepStrictEqual({ + telemetryLevel: agentHostTelemetryService.telemetryLevel, + events: telemetryService.events, + }, { + telemetryLevel: TelemetryLevel.NONE, + events: [], + }); + transport.simulateClose(); + transport.dispose(); + }); + test('handshake rejects unsupported protocol versions', () => { const transport = new MockProtocolTransport(); server.simulateConnection(transport); @@ -1292,6 +1380,86 @@ suite('ProtocolServerHandler', () => { }); }); + test('applies telemetry disablement before reporting a reconnected client', async () => { + const transport1 = connectClient('telemetry-reconnect-client'); + transport1.simulateClose(); + const eventsBeforeReconnect = [...telemetryService.events]; + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectResponse = waitForResponse(transport2, 2); + transport2.simulateMessage(request(2, 'reconnect', { + clientId: 'telemetry-reconnect-client', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [], + _meta: { 'vscode.telemetryLevel': 'off' }, + })); + await reconnectResponse; + + assert.deepStrictEqual({ + telemetryLevel: agentHostTelemetryService.telemetryLevel, + events: telemetryService.events, + }, { + telemetryLevel: TelemetryLevel.NONE, + events: eventsBeforeReconnect, + }); + transport2.simulateClose(); + transport2.dispose(); + }); + + test('fails closed before reporting a reconnected client for malformed telemetry metadata', async () => { + const transport1 = connectClient('malformed-telemetry-reconnect-client'); + transport1.simulateClose(); + const eventsBeforeReconnect = [...telemetryService.events]; + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectResponse = waitForResponse(transport2, 2); + transport2.simulateMessage(request(2, 'reconnect', { + clientId: 'malformed-telemetry-reconnect-client', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [], + _meta: { 'vscode.telemetryLevel': 'invalid' }, + })); + await reconnectResponse; + + assert.deepStrictEqual({ + telemetryLevel: agentHostTelemetryService.telemetryLevel, + events: telemetryService.events, + }, { + telemetryLevel: TelemetryLevel.NONE, + events: eventsBeforeReconnect, + }); + transport2.simulateClose(); + transport2.dispose(); + }); + + test('uses the launch telemetry level when a legacy reconnect omits telemetry metadata', async () => { + const transport1 = connectClient('legacy-reconnect-client'); + transport1.simulateClose(); + const eventCountBeforeReconnect = telemetryService.events.length; + + const transport2 = new MockProtocolTransport(); + server.simulateConnection(transport2); + const reconnectResponse = waitForResponse(transport2, 2); + transport2.simulateMessage(request(2, 'reconnect', { + clientId: 'legacy-reconnect-client', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [], + })); + await reconnectResponse; + + assert.deepStrictEqual({ + telemetryLevel: agentHostTelemetryService.telemetryLevel, + newEventNames: telemetryService.events.slice(eventCountBeforeReconnect).map(event => event.eventName), + }, { + telemetryLevel: TelemetryLevel.USAGE, + newEventNames: ['agentHost.clientConnection'], + }); + transport2.simulateClose(); + transport2.dispose(); + }); + test('does not retain client telemetry identity when reconnect omits it', async () => { const transport1 = connectClient('client-consent', undefined, agentsWindowAgentHostClientInfo, { 'vscode.clientMachineId': 'client-machine-id', @@ -1366,6 +1534,7 @@ suite('ProtocolServerHandler', () => { clientInfo: { name: 'vscode-agents-window', version: '1.2.3', title: 'VS Code Agents Window' }, _meta: { 'vscode.clientConnectionKind': AgentHostClientConnectionKind.DevTunnel, + 'vscode.telemetryLevel': 'all', 'vscode.clientMachineId': 'client-machine-id', 'vscode.clientDevDeviceId': 'client-dev-device-id', }, diff --git a/src/vs/platform/agentHost/test/node/sshHostKeyVerification.test.ts b/src/vs/platform/agentHost/test/node/sshHostKeyVerification.test.ts index a1404cce6f6..379c3d8d0f4 100644 --- a/src/vs/platform/agentHost/test/node/sshHostKeyVerification.test.ts +++ b/src/vs/platform/agentHost/test/node/sshHostKeyVerification.test.ts @@ -8,6 +8,7 @@ import type { ConnectConfig } from 'ssh2'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { isSSHHostKeyDeniedError, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHHostKeyVerificationRequest } from '../../common/sshRemoteAgentHost.js'; import { SSHRemoteAgentHostMainService, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js'; @@ -184,7 +185,7 @@ suite('SSHRemoteAgentHostMainService - host key verification', () => { quality: 'stable', dataFolderName: '.vscode-oss', }; - return disposables.add(new HostKeyTestService(new NullLogService(), productService as IProductService)); + return disposables.add(new HostKeyTestService(new NullLogService(), productService as IProductService, NullTelemetryService)); } /** Run a connect attempt, answering the verification request with `trusted`. */ diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts index b7e94a0167f..a2cdddfe4d2 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostHelpers.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TelemetryConfiguration } from '../../../telemetry/common/telemetry.js'; import { AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION, type IAgentHostEndpointMetadata } from '../../common/agentHostEndpointRegistry.js'; import { buildAgentEndpointsCommand, @@ -26,6 +27,7 @@ import { resolveRemotePlatform, runAgentEndpoints, shellEscape, + validateAgentHostTelemetryLevel, validateCommit, validateShellToken, waitForNewStandaloneEndpoint, @@ -157,9 +159,18 @@ suite('SSH Remote Agent Host Helpers', () => { }); suite('buildAgentHostBaseCommand', () => { - test('includes --cli-data-dir before the agent host subcommand', () => { - const cmd = buildAgentHostBaseCommand('~/.vscode-server/code-insiders-abc', '~/.vscode-server/cli'); - assert.strictEqual(cmd, '~/.vscode-server/code-insiders-abc --cli-data-dir ~/.vscode-server/cli agent host --port 0'); + test('includes --cli-data-dir and the default telemetry level before the agent host subcommand', () => { + const cmd = buildAgentHostBaseCommand('~/.vscode-server/code-insiders-abc', '~/.vscode-server/cli', TelemetryConfiguration.ON); + assert.strictEqual(cmd, '~/.vscode-server/code-insiders-abc --cli-data-dir ~/.vscode-server/cli --telemetry-level all agent host --port 0'); + }); + + test('includes telemetry disablement before the agent host subcommand', () => { + const cmd = buildAgentHostBaseCommand('~/.vscode-server/code-insiders-abc', '~/.vscode-server/cli', TelemetryConfiguration.OFF); + assert.strictEqual(cmd, '~/.vscode-server/code-insiders-abc --cli-data-dir ~/.vscode-server/cli --telemetry-level off agent host --port 0'); + }); + + test('rejects unsafe telemetry levels', () => { + assert.throws(() => validateAgentHostTelemetryLevel('off; touch /tmp/unsafe'), /Unsafe telemetry level/); }); }); @@ -456,26 +467,33 @@ suite('SSH Remote Agent Host Helpers', () => { suite('buildAgentHostSpawnCommand', () => { test('includes --new-instance, --user-data-dir and default --idle-timeout', () => { assert.strictEqual( - buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/home/user/.vscode-remote'), - '~/.vscode-server/code --cli-data-dir ~/.vscode-server/cli agent host --port 0 --new-instance --user-data-dir \'/home/user/.vscode-remote\' --idle-timeout 300', + buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/home/user/.vscode-remote', TelemetryConfiguration.ON), + '~/.vscode-server/code --cli-data-dir ~/.vscode-server/cli --telemetry-level all agent host --port 0 --new-instance --user-data-dir \'/home/user/.vscode-remote\' --idle-timeout 300', ); }); test('honors a custom idle timeout', () => { assert.strictEqual( - buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/home/user/.vscode-remote', 60), - '~/.vscode-server/code --cli-data-dir ~/.vscode-server/cli agent host --port 0 --new-instance --user-data-dir \'/home/user/.vscode-remote\' --idle-timeout 60', + buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/home/user/.vscode-remote', TelemetryConfiguration.ON, 60), + '~/.vscode-server/code --cli-data-dir ~/.vscode-server/cli --telemetry-level all agent host --port 0 --new-instance --user-data-dir \'/home/user/.vscode-remote\' --idle-timeout 60', + ); + }); + + test('propagates telemetry disablement to a new dedicated agent host', () => { + assert.strictEqual( + buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/home/user/.vscode-remote', TelemetryConfiguration.OFF), + '~/.vscode-server/code --cli-data-dir ~/.vscode-server/cli --telemetry-level off agent host --port 0 --new-instance --user-data-dir \'/home/user/.vscode-remote\' --idle-timeout 300', ); }); test('rejects unsafe idle timeout values', () => { - assert.throws(() => buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x', 0), /Unsafe idle timeout/); - assert.throws(() => buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x', -1), /Unsafe idle timeout/); - assert.throws(() => buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x', 1.5), /Unsafe idle timeout/); + assert.throws(() => buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x', TelemetryConfiguration.ON, 0), /Unsafe idle timeout/); + assert.throws(() => buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x', TelemetryConfiguration.ON, -1), /Unsafe idle timeout/); + assert.throws(() => buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x', TelemetryConfiguration.ON, 1.5), /Unsafe idle timeout/); }); test('always includes --new-instance so an existing standalone is never silently reused', () => { - const cmd = buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x'); + const cmd = buildAgentHostSpawnCommand('~/.vscode-server/code', '~/.vscode-server/cli', '/x', TelemetryConfiguration.ON); assert.ok(cmd.includes(' --new-instance '), 'spawn command must request a genuinely new instance, not reuse an existing standalone'); }); }); diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts index ed7da6dc3fb..9143912f2b1 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts @@ -12,6 +12,8 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; +import { TelemetryConfiguration } from '../../../telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { AGENT_HOST_ENDPOINT_REGISTRY_SCHEMA_VERSION, type AgentHostEndpointAddress, type IAgentHostEndpointMetadata } from '../../common/agentHostEndpointRegistry.js'; import { SSHAuthMethod, type ISSHAgentHostConfig, type ISSHConnectProgress, type ISSHEndpointSelection, type ISSHEndpointSelectionRequest, type ISSHKeyboardInteractivePrompt, type ISSHKeyboardInteractiveRequest } from '../../common/sshRemoteAgentHost.js'; import { SSHRemoteAgentHostMainService, makeAuthHandler, type SSHAuthAttempt } from '../../node/sshRemoteAgentHostService.js'; @@ -301,7 +303,7 @@ class TestableSSHRemoteAgentHostMainService extends SSHRemoteAgentHostMainServic } protected override async _startRemoteAgentHost( - _client: unknown, _cliBin: string | undefined, _cliDataDir: string | undefined, _commandOverride?: string, + _client: unknown, _cliBin: string | undefined, _cliDataDir: string | undefined, _commandOverride?: string, _telemetryLevel?: TelemetryConfiguration, ) { this.startCalled++; return { ...this.startResult, stream: new MockSSHChannel() as never }; @@ -463,6 +465,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { service = new TestableSSHRemoteAgentHostMainService( logService, productService as IProductService, + NullTelemetryService, ); disposables.add(service); }); @@ -588,6 +591,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { const execCalls = service.mockClients[0].execCalls; assert.ok(execCalls.some(c => c.includes('--idle-timeout 300')), `should spawn with idle timeout; saw: ${JSON.stringify(execCalls)}`); assert.ok(execCalls.some(c => c.includes('--new-instance')), `spawn must request a genuinely new instance; saw: ${JSON.stringify(execCalls)}`); + assert.ok(execCalls.some(c => c.includes('--telemetry-level off')), `spawn must apply telemetry disablement; saw: ${JSON.stringify(execCalls)}`); }); test('reuses the single live standalone deterministically without a picker', async () => { @@ -1188,6 +1192,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { quality, dataFolderName, } as IProductService, + NullTelemetryService, )); const request = new DeferredPromise(); disposables.add(kbiService.onDidRequestKeyboardInteractive(kbiRequest => request.complete(kbiRequest))); @@ -1282,6 +1287,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { const loggingService = disposables.add(new TestableSSHRemoteAgentHostMainService( logService, productService as IProductService, + NullTelemetryService, )); loggingService.execResponses = [ { stdout: 'Linux\n', code: 0 }, @@ -1309,6 +1315,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { const loggingService = disposables.add(new TestableSSHRemoteAgentHostMainService( logService, productService as IProductService, + NullTelemetryService, )); loggingService.execResponses = [ { stdout: 'Linux\n', code: 0 }, @@ -1343,6 +1350,7 @@ suite('SSHRemoteAgentHostMainService - connect flow', () => { pinnedService = new TestableSSHRemoteAgentHostMainService( logService, productService as IProductService, + NullTelemetryService, ); disposables.add(pinnedService); }); @@ -1632,6 +1640,7 @@ suite('SSHRemoteAgentHostMainService - _buildAuthAttempts', () => { service = new AuthAttemptsTestService( logService, productService as IProductService, + NullTelemetryService, ); disposables.add(service); }); diff --git a/src/vs/platform/agentHost/test/node/wslRemoteAgentHostHelpers.test.ts b/src/vs/platform/agentHost/test/node/wslRemoteAgentHostHelpers.test.ts index 46804334868..600eee006a8 100644 --- a/src/vs/platform/agentHost/test/node/wslRemoteAgentHostHelpers.test.ts +++ b/src/vs/platform/agentHost/test/node/wslRemoteAgentHostHelpers.test.ts @@ -5,7 +5,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TelemetryConfiguration } from '../../../telemetry/common/telemetry.js'; import { + composeAgentHostBootstrapScript, decodeWslOutput, parseRunningDistros, parseWslListVerbose, @@ -99,4 +101,34 @@ suite('WSL Remote Agent Host Helpers', () => { assert.strictEqual(decodeWslOutput(Buffer.alloc(0)), ''); }); }); + + suite('composeAgentHostBootstrapScript', () => { + test('propagates telemetry disablement to the WSL agent host', () => { + const commit = 'a'.repeat(40); + const script = composeAgentHostBootstrapScript({ + serverDataFolderName: '.vscode-server', + quality: 'stable', + commit, + os: 'linux', + arch: 'x64', + telemetryLevel: TelemetryConfiguration.OFF, + }); + + assert.ok(script.endsWith(`exec ~/.vscode-server/code-${commit} --cli-data-dir ~/.vscode-server/cli --telemetry-level off agent host --port 0`)); + }); + + test('exports telemetry disablement for a custom command', () => { + const script = composeAgentHostBootstrapScript({ + serverDataFolderName: '.vscode-server', + quality: 'stable', + commit: undefined, + os: 'linux', + arch: 'x64', + telemetryLevel: TelemetryConfiguration.OFF, + remoteAgentHostCommand: './start-agent-host', + }); + + assert.strictEqual(script, 'export VSCODE_AGENT_HOST_TELEMETRY_LEVEL=off && ./start-agent-host'); + }); + }); }); diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 18d653bac95..00e12e78007 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -110,6 +110,7 @@ export interface NativeParsedArgs { 'skip-release-notes'?: boolean; 'skip-welcome'?: boolean; 'disable-telemetry'?: boolean; + 'telemetry-level'?: string; 'export-default-configuration'?: string; 'export-policy-data'?: string; 'export-default-keybindings'?: string; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 5933c9c71d9..8a36a25aea5 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -185,6 +185,7 @@ export const OPTIONS: OptionDescriptions> = { 'skip-release-notes': { type: 'boolean' }, 'skip-welcome': { type: 'boolean' }, 'disable-telemetry': { type: 'boolean' }, + 'telemetry-level': { type: 'string' }, 'disable-updates': { type: 'boolean' }, 'share-secrets-with-agents-app': { type: 'boolean' }, 'transient': { type: 'boolean', cat: 't', description: localize('transient', "Run with temporary data and extension directories, as if launched for the first time.") }, diff --git a/src/vs/platform/telemetry/common/serverTelemetryService.ts b/src/vs/platform/telemetry/common/serverTelemetryService.ts index f6fc225ab84..7dd6ad7560c 100644 --- a/src/vs/platform/telemetry/common/serverTelemetryService.ts +++ b/src/vs/platform/telemetry/common/serverTelemetryService.ts @@ -30,6 +30,10 @@ export class ServerTelemetryService extends TelemetryService implements IServerT this._injectedTelemetryLevel = injectedTelemetryLevel; } + override get telemetryLevel(): TelemetryLevel { + return Math.min(super.telemetryLevel, this._injectedTelemetryLevel); + } + override publicLog(eventName: string, data?: ITelemetryData) { if (this._injectedTelemetryLevel < TelemetryLevel.USAGE) { return; @@ -58,7 +62,7 @@ export class ServerTelemetryService extends TelemetryService implements IServerT throw new Error('Telemetry level cannot be undefined. This will cause infinite looping!'); } // We always take the most restrictive level because we don't want multiple clients to connect and send data when one client does not consent - this._injectedTelemetryLevel = this._injectedTelemetryLevel ? Math.min(this._injectedTelemetryLevel, telemetryLevel) : telemetryLevel; + this._injectedTelemetryLevel = Math.min(this._injectedTelemetryLevel, telemetryLevel); if (this._injectedTelemetryLevel === TelemetryLevel.NONE) { this.dispose(); } diff --git a/src/vs/platform/telemetry/test/common/serverTelemetryService.test.ts b/src/vs/platform/telemetry/test/common/serverTelemetryService.test.ts new file mode 100644 index 00000000000..327449d4eea --- /dev/null +++ b/src/vs/platform/telemetry/test/common/serverTelemetryService.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import product from '../../../product/common/product.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { TelemetryLevel } from '../../common/telemetry.js'; +import { ServerTelemetryService } from '../../common/serverTelemetryService.js'; +import { NullAppender } from '../../common/telemetryUtils.js'; + +suite('ServerTelemetryService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const productService: IProductService = { _serviceBrand: undefined, ...product }; + + test('exposes and preserves the most restrictive injected telemetry level', async () => { + const service = disposables.add(new ServerTelemetryService( + { appenders: [NullAppender] }, + TelemetryLevel.ERROR, + new TestConfigurationService(), + productService, + )); + + const initialLevel = service.telemetryLevel; + await service.updateInjectedTelemetryLevel(TelemetryLevel.NONE); + await service.updateInjectedTelemetryLevel(TelemetryLevel.USAGE); + + assert.deepStrictEqual({ + initialLevel, + finalLevel: service.telemetryLevel, + }, { + initialLevel: TelemetryLevel.ERROR, + finalLevel: TelemetryLevel.NONE, + }); + }); +}); diff --git a/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts b/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts index 2feba38fb4f..f6e4636aea0 100644 --- a/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts +++ b/src/vs/workbench/contrib/remote/electron-browser/remote.contribution.ts @@ -25,7 +25,7 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '. import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { OpenLocalFileFolderCommand, OpenLocalFileCommand, OpenLocalFolderCommand, SaveLocalFileCommand, RemoteFileDialogContext } from '../../../services/dialogs/browser/simpleFileDialog.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; -import { TELEMETRY_SETTING_ID } from '../../../../platform/telemetry/common/telemetry.js'; +import { ITelemetryService, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../../../platform/telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { IContextKeyService, RawContextKey, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; @@ -100,21 +100,22 @@ class RemoteTelemetryEnablementUpdater extends Disposable implements IWorkbenchC constructor( @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @ITelemetryService private readonly telemetryService: ITelemetryService, ) { super(); this.updateRemoteTelemetryEnablement(); this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(TELEMETRY_SETTING_ID)) { + if (e.affectsConfiguration(TELEMETRY_SETTING_ID) || e.affectsConfiguration(TELEMETRY_OLD_SETTING_ID) || e.affectsConfiguration(TELEMETRY_CRASH_REPORTER_SETTING_ID)) { this.updateRemoteTelemetryEnablement(); } })); } private updateRemoteTelemetryEnablement(): Promise { - return this.remoteAgentService.updateTelemetryLevel(getTelemetryLevel(this.configurationService)); + return this.remoteAgentService.updateTelemetryLevel(Math.min(getTelemetryLevel(this.configurationService), this.telemetryService.telemetryLevel)); } }