diff --git a/src/vs/base/parts/ipc/node/ipc.cp.ts b/src/vs/base/parts/ipc/node/ipc.cp.ts index e07b3ab7bdf2a1..458e7888063c65 100644 --- a/src/vs/base/parts/ipc/node/ipc.cp.ts +++ b/src/vs/base/parts/ipc/node/ipc.cp.ts @@ -99,6 +99,10 @@ export class Client implements IChannelClient, IDisposable { this._client = null; } + get isConnected(): boolean { + return this.child?.connected ?? false; + } + getChannel(channelName: string): T { const that = this; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index ea1cbd86b3e953..e86b99e79a50b5 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -755,9 +755,11 @@ export class CodeApplication extends Disposable { // This manager self-disposes after its lifecycle join; CodeApplication disposes before later shutdown listeners run. appInstantiationService.createInstance(AgentHostProcessManager, agentHostStarter, process.platform); - // Metered Connection + // Metered connection telemetry appInstantiationService.invokeFunction(accessor => { - (accessor.get(IMeteredConnectionService) as MeteredConnectionMainService).start(); + const meteredConnectionService = accessor.get(IMeteredConnectionService) as MeteredConnectionMainService; + meteredConnectionService.setTelemetryService(accessor.get(ITelemetryService)); + meteredConnectionService.start(); }); // Auth Handler @@ -1305,7 +1307,7 @@ export class CodeApplication extends Disposable { const appender = new TelemetryAppenderClient(channel); const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, this.productService.commit, this.productService.version, machineId, sqmId, devDeviceId, isInternal, this.productService.date); const piiPaths = getPiiPathsFromEnvironment(this.environmentMainService); - const config: ITelemetryServiceConfig = { appenders: [appender], commonProperties, piiPaths, sendErrorTelemetry: true }; + const config: ITelemetryServiceConfig = { appenders: [appender], commonProperties, piiPaths, sendErrorTelemetry: true, meteredConnectionService }; services.set(ITelemetryService, new SyncDescriptor(TelemetryService, [config], false)); } else { diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index d559985d182fb2..4116ce2b1e5eca 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -333,7 +333,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { const logAppender = new TelemetryLogAppender('', false, loggerService, environmentService, productService); appenders.push(logAppender); if (!isLoggingOnly(productService, environmentService) && productService.aiConfig?.ariaKey) { - const collectorAppender = new OneDataSystemAppender(requestService, internalTelemetry, 'monacoworkbench', null, productService.aiConfig.ariaKey); + const collectorAppender = new OneDataSystemAppender(requestService, internalTelemetry, 'monacoworkbench', null, productService.aiConfig.ariaKey, meteredConnectionService); this._register(toDisposable(() => collectorAppender.flush())); // Ensure the 1DS appender is disposed so that it flushes remaining data appenders.push(collectorAppender); } @@ -343,6 +343,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, this.configuration.machineId, this.configuration.sqmId, this.configuration.devDeviceId, internalTelemetry, productService.date), sendErrorTelemetry: true, piiPaths: getPiiPathsFromEnvironment(environmentService), + meteredConnectionService, }, configurationService, productService); } else { telemetryService = NullTelemetryService; @@ -354,7 +355,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { services.set(ITelemetryService, telemetryService); // Custom Endpoint Telemetry - const customEndpointTelemetryService = new CustomEndpointTelemetryService(configurationService, telemetryService, loggerService, environmentService, productService); + const customEndpointTelemetryService = this._register(new CustomEndpointTelemetryService(configurationService, telemetryService, loggerService, environmentService, productService, meteredConnectionService)); services.set(ICustomEndpointTelemetryService, customEndpointTelemetryService); // Extension Management diff --git a/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts b/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts index 731e49c0145654..1b6f724baedb65 100644 --- a/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts +++ b/src/vs/platform/meteredConnection/electron-main/meteredConnectionMainService.ts @@ -7,6 +7,7 @@ import type { MeteredConnectionMonitor, MeteredConnectionState } from '@vscode/m import { DeferredPromise, raceTimeout } from '../../../base/common/async.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; +import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AbstractMeteredConnectionService } from '../common/meteredConnection.js'; type MonitorFactory = () => Promise; @@ -27,6 +28,7 @@ async function createMonitor(): Promise { * This implementation receives metered connection updates from the operating system. */ export class MeteredConnectionMainService extends AbstractMeteredConnectionService { + private telemetryService: ITelemetryService | undefined; private readonly monitorFactory: MonitorFactory; private readonly initialized = new DeferredPromise(); private readonly initializationTimeout: number; @@ -43,6 +45,10 @@ export class MeteredConnectionMainService extends AbstractMeteredConnectionServi this.initializationTimeout = options?.initializationTimeout ?? INITIALIZATION_TIMEOUT; } + public setTelemetryService(telemetryService: ITelemetryService): void { + this.telemetryService = telemetryService; + } + public start(): void { if (!this.started) { this.started = true; @@ -100,4 +106,28 @@ export class MeteredConnectionMainService extends AbstractMeteredConnectionServi return false; } } + + protected override onChangeUnderlyingConnection() { + // Fire event after sending telemetry if switching to metered since telemetry will be paused. + const fireAfter = this.isUnderlyingConnectionMetered; + if (!fireAfter) { + super.onChangeUnderlyingConnection(); + } + + type MeteredConnectionStateChangeEvent = { + connectionState: boolean; + }; + type MeteredConnectionStateChangeClassification = { + owner: 'dmitrivMS'; + comment: 'Tracks metered network connection state changes to understand usage patterns.'; + connectionState: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the underlying network connection is metered according to the OS.' }; + }; + this.telemetryService?.publicLog2('meteredConnectionStateChange', { + connectionState: this.isUnderlyingConnectionMetered, + }); + + if (fireAfter) { + super.onChangeUnderlyingConnection(); + } + } } diff --git a/src/vs/platform/meteredConnection/test/common/testMeteredConnectionService.ts b/src/vs/platform/meteredConnection/test/common/testMeteredConnectionService.ts new file mode 100644 index 00000000000000..709f921344c9e4 --- /dev/null +++ b/src/vs/platform/meteredConnection/test/common/testMeteredConnectionService.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IMeteredConnectionService } from '../../common/meteredConnection.js'; + +export class TestMeteredConnectionService extends Disposable implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeIsConnectionMetered = this._register(new Emitter()); + readonly onDidChangeIsConnectionMetered = this._onDidChangeIsConnectionMetered.event; + + constructor( + public isConnectionMetered: boolean, + readonly whenInitialized: Promise = Promise.resolve(), + ) { + super(); + } + + setIsConnectionMetered(isMetered: boolean): void { + this.isConnectionMetered = isMetered; + this._onDidChangeIsConnectionMetered.fire(isMetered); + } +} diff --git a/src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts b/src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts index ba6bac5a95fc73..9a168b420ff9c9 100644 --- a/src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts +++ b/src/vs/platform/meteredConnection/test/electron-main/meteredConnectionMainService.test.ts @@ -10,6 +10,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { NullLogService } from '../../../log/common/log.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; import { MeteredConnectionCommand } from '../../common/meteredConnectionIpc.js'; import { MeteredConnectionChannel } from '../../electron-main/meteredConnectionChannel.js'; import { MeteredConnectionMainService } from '../../electron-main/meteredConnectionMainService.js'; @@ -46,6 +47,7 @@ suite('MeteredConnectionMainService', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const monitor = new TestMeteredConnectionMonitor(); const service = store.add(new MeteredConnectionMainService({ monitorFactory: async () => monitor }, configurationService, new NullLogService())); + service.setTelemetryService(NullTelemetryService); service.start(); let initialized = false; void service.whenInitialized.then(() => initialized = true); @@ -73,6 +75,7 @@ suite('MeteredConnectionMainService', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const monitor = new TestMeteredConnectionMonitor(); const service = store.add(new MeteredConnectionMainService({ monitorFactory: async () => monitor }, configurationService, new NullLogService())); + service.setTelemetryService(NullTelemetryService); service.start(); const changes: boolean[] = []; store.add(service.onDidChangeIsConnectionMetered(state => changes.push(state))); @@ -108,6 +111,7 @@ suite('MeteredConnectionMainService', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const monitor = new TestMeteredConnectionMonitor(); const service = store.add(new MeteredConnectionMainService({ monitorFactory: async () => monitor }, configurationService, new NullLogService())); + service.setTelemetryService(NullTelemetryService); service.start(); const channel = new MeteredConnectionChannel(service); let resolved = false; @@ -135,6 +139,7 @@ suite('MeteredConnectionMainService', () => { monitorFactory: async () => monitor, initializationTimeout: 0, }, configurationService, new NullLogService())); + service.setTelemetryService(NullTelemetryService); service.start(); await service.whenInitialized; @@ -158,6 +163,7 @@ suite('MeteredConnectionMainService', () => { monitorFactory: () => monitorPromise.p, initializationTimeout: 0, }, configurationService, new NullLogService())); + service.setTelemetryService(NullTelemetryService); service.start(); await service.whenInitialized; @@ -176,6 +182,7 @@ suite('MeteredConnectionMainService', () => { store.add(configurationService.onDidChangeConfigurationEmitter); const monitor = new TestMeteredConnectionMonitor(); const service = store.add(new MeteredConnectionMainService({ monitorFactory: async () => monitor }, configurationService, new NullLogService())); + service.setTelemetryService(NullTelemetryService); service.start(); await timeout(0); @@ -199,6 +206,7 @@ suite('MeteredConnectionMainService', () => { const monitor = new TestMeteredConnectionMonitor(); const monitorPromise = new DeferredPromise(); const service = store.add(new MeteredConnectionMainService({ monitorFactory: () => monitorPromise.p }, configurationService, new NullLogService())); + service.setTelemetryService(NullTelemetryService); service.start(); service.dispose(); diff --git a/src/vs/platform/telemetry/common/1dsAppender.ts b/src/vs/platform/telemetry/common/1dsAppender.ts index 9298c26e970f04..8e43974084a274 100644 --- a/src/vs/platform/telemetry/common/1dsAppender.ts +++ b/src/vs/platform/telemetry/common/1dsAppender.ts @@ -19,6 +19,11 @@ export interface IAppInsightsCore { unload(isAsync: boolean, unloadComplete: (unloadState: ITelemetryUnloadState) => void): void; } +interface IAppInsightsClient { + readonly core: IAppInsightsCore; + readonly transmissionController: Pick; +} + const endpointUrl = 'https://mobile.events.data.microsoft.com/OneCollector/1.0'; const endpointHealthUrl = 'https://mobile.events.data.microsoft.com/ping'; @@ -39,7 +44,7 @@ export function applyEnvelopeDefaults(envelope: ITelemetryItem, isInternalMachin } } -async function getClient(instrumentationKey: string, addInternalFlag?: boolean, xhrOverride?: IXHROverride): Promise { +async function getClient(instrumentationKey: string, addInternalFlag?: boolean, xhrOverride?: IXHROverride): Promise { // eslint-disable-next-line local/code-amd-node-module const oneDs = isWeb ? await importAMDNodeModule('@microsoft/1ds-core-js', 'bundle/ms.core.min.js') : await import('@microsoft/1ds-core-js'); // eslint-disable-next-line local/code-amd-node-module @@ -76,7 +81,10 @@ async function getClient(instrumentationKey: string, addInternalFlag?: boolean, appInsightsCore.addTelemetryInitializer(envelope => applyEnvelopeDefaults(envelope, addInternalFlag)); - return appInsightsCore; + return { + core: appInsightsCore, + transmissionController: collectorChannelPlugin, + }; } // TODO @lramos15 maybe make more in line with src/vs/platform/telemetry/browser/appInsightsAppender.ts with caching support @@ -84,6 +92,8 @@ export abstract class AbstractOneDataSystemAppender implements ITelemetryAppende protected _aiCoreOrKey: IAppInsightsCore | string | undefined; private _asyncAiCore: Promise | null; + private _transmissionController: Pick | undefined; + private _isTransmissionPaused = false; protected readonly endPointUrl = endpointUrl; protected readonly endPointHealthUrl = endpointHealthUrl; @@ -106,6 +116,30 @@ export abstract class AbstractOneDataSystemAppender implements ITelemetryAppende this._asyncAiCore = null; } + protected get isTransmissionPaused(): boolean { + return this._isTransmissionPaused; + } + + protected setTransmissionController(transmissionController: Pick): void { + this._transmissionController = transmissionController; + if (this.isTransmissionPaused) { + transmissionController.pause(); + } + } + + protected setTransmissionPaused(isPaused: boolean): void { + if (this.isTransmissionPaused === isPaused) { + return; + } + + this._isTransmissionPaused = isPaused; + if (isPaused) { + this._transmissionController?.pause(); + } else { + this._transmissionController?.resume(); + } + } + private _withAIClient(callback: (aiCore: IAppInsightsCore) => void): void { if (!this._aiCoreOrKey) { return; @@ -117,7 +151,10 @@ export abstract class AbstractOneDataSystemAppender implements ITelemetryAppende } if (!this._asyncAiCore) { - this._asyncAiCore = getClient(this._aiCoreOrKey, this._isInternalTelemetry, this._xhrOverride); + this._asyncAiCore = getClient(this._aiCoreOrKey, this._isInternalTelemetry, this._xhrOverride).then(client => { + this.setTransmissionController(client.transmissionController); + return client.core; + }); } this._asyncAiCore.then( @@ -132,7 +169,7 @@ export abstract class AbstractOneDataSystemAppender implements ITelemetryAppende } log(eventName: string, data?: unknown): void { - if (!this._aiCoreOrKey) { + if (!this._aiCoreOrKey || this.isTransmissionPaused) { return; } data = mixin(data, this._defaultData); @@ -141,6 +178,10 @@ export abstract class AbstractOneDataSystemAppender implements ITelemetryAppende try { this._withAIClient((aiClient) => { + if (this.isTransmissionPaused) { + return; + } + aiClient.pluginVersionString = validatedData?.properties.version ?? 'Unknown'; aiClient.track({ name, @@ -151,11 +192,17 @@ export abstract class AbstractOneDataSystemAppender implements ITelemetryAppende } flush(): Promise { - if (this._aiCoreOrKey) { + if (this._aiCoreOrKey && !this.isTransmissionPaused) { return new Promise(resolve => { this._withAIClient((aiClient) => { + if (this.isTransmissionPaused) { + resolve(); + return; + } + aiClient.unload(true, () => { this._aiCoreOrKey = undefined; + this._transmissionController = undefined; resolve(undefined); }); }); diff --git a/src/vs/platform/telemetry/common/telemetryIpc.ts b/src/vs/platform/telemetry/common/telemetryIpc.ts index f43064678f594e..e41ab389ac281c 100644 --- a/src/vs/platform/telemetry/common/telemetryIpc.ts +++ b/src/vs/platform/telemetry/common/telemetryIpc.ts @@ -13,17 +13,45 @@ export interface ITelemetryLog { data?: ITelemetryData; } +const LOG = 'log'; +const SET_IS_CONNECTION_METERED = 'setIsConnectionMetered'; + +function isTelemetryLog(arg: unknown): arg is ITelemetryLog { + return typeof arg === 'object' && arg !== null && 'eventName' in arg && typeof arg.eventName === 'string'; +} + export class TelemetryAppenderChannel implements IServerChannel { - constructor(private appenders: ITelemetryAppender[]) { } + constructor( + private readonly appenders: ITelemetryAppender[], + private readonly setIsConnectionMetered?: (isMetered: boolean) => void, + ) { } listen(_: unknown, event: string): Event { throw new Error(`Event not found: ${event}`); } - call(_: unknown, command: string, { eventName, data }: ITelemetryLog) { - this.appenders.forEach(a => a.log(eventName, data ?? {})); - return Promise.resolve(null as unknown as T); + async call(_: unknown, command: string, arg: unknown): Promise { + switch (command) { + case LOG: + if (!isTelemetryLog(arg)) { + throw new Error('Invalid telemetry log argument'); + } + this.appenders.forEach(a => a.log(arg.eventName, arg.data ?? {})); + break; + case SET_IS_CONNECTION_METERED: + if (typeof arg !== 'boolean') { + throw new Error('Invalid metered connection argument'); + } + if (!this.setIsConnectionMetered) { + throw new Error('Metered connection updates are not supported by this telemetry appender'); + } + this.setIsConnectionMetered(arg); + break; + default: + throw new Error(`Unknown telemetry appender command: ${command}`); + } + return undefined!; } } @@ -32,12 +60,20 @@ export class TelemetryAppenderClient implements ITelemetryAppender { constructor(private channel: IChannel) { } log(eventName: string, data?: unknown): unknown { - this.channel.call('log', { eventName, data }) + this.channel.call(LOG, { eventName, data }) .then(undefined, err => `Failed to log telemetry: ${console.warn(err)}`); return Promise.resolve(null); } + async setIsConnectionMetered(isMetered: boolean): Promise { + try { + await this.channel.call(SET_IS_CONNECTION_METERED, isMetered); + } catch (error) { + console.warn(`Failed to update telemetry connection state: ${error}`); + } + } + flush(): Promise { // TODO return Promise.resolve(); diff --git a/src/vs/platform/telemetry/common/telemetryService.ts b/src/vs/platform/telemetry/common/telemetryService.ts index 4e472aec13a00f..e86aeadc6ae0e1 100644 --- a/src/vs/platform/telemetry/common/telemetryService.ts +++ b/src/vs/platform/telemetry/common/telemetryService.ts @@ -11,6 +11,7 @@ import { escapeRegExpCharacters } from '../../../base/common/strings.js'; import { localize } from '../../../nls.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; import { Registry } from '../../registry/common/platform.js'; @@ -33,6 +34,10 @@ export interface ITelemetryServiceConfig { * (up to 10 seconds) to ensure experiment context is attached to all events. */ waitForExperimentProperties?: boolean; + /** + * If provided, telemetry events will be dropped when the connection is metered. + */ + meteredConnectionService?: IMeteredConnectionService; } interface IPendingEvent { @@ -64,9 +69,13 @@ export class TelemetryService implements ITelemetryService { private _telemetryLevel: TelemetryLevel; private _sendErrorTelemetry: boolean; + private readonly _meteredConnectionService: IMeteredConnectionService | undefined; + private _isMeteredConnectionInitialized: boolean; + private _pendingEvents: IPendingEvent[] = []; private _isExperimentPropertySet = false; private _flushTimeout: ReturnType | undefined; + private _isDisposed = false; private readonly _disposables = new DisposableStore(); private _cleanupPatterns: RegExp[] = []; @@ -94,6 +103,16 @@ export class TelemetryService implements ITelemetryService { this._piiPaths = config.piiPaths || []; this._telemetryLevel = TelemetryLevel.USAGE; this._sendErrorTelemetry = !!config.sendErrorTelemetry; + this._meteredConnectionService = config.meteredConnectionService; + this._isMeteredConnectionInitialized = !this._meteredConnectionService; + if (this._meteredConnectionService) { + void this._meteredConnectionService.whenInitialized.then(() => { + if (!this._isDisposed) { + this._isMeteredConnectionInitialized = true; + this._flushPendingEventsIfReady(); + } + }); + } // static cleanup pattern for: `vscode-file:///DANGEROUS/PATH/resources/app/Useful/Information` this._cleanupPatterns = [/(vscode-)?file:\/\/.*?\/resources\/app\//gi]; @@ -151,19 +170,29 @@ export class TelemetryService implements ITelemetryService { } } - private _flushPendingEvents(): void { - if (this._isExperimentPropertySet) { - return; + private _flushPendingEvents(force = false): void { + if (!this._isExperimentPropertySet) { + this._isExperimentPropertySet = true; + + if (this._flushTimeout !== undefined) { + clearTimeout(this._flushTimeout); + this._flushTimeout = undefined; + } } - this._isExperimentPropertySet = true; + this._flushPendingEventsIfReady(force); + } - if (this._flushTimeout !== undefined) { - clearTimeout(this._flushTimeout); - this._flushTimeout = undefined; + private _flushPendingEventsIfReady(force = false): void { + if (!this._isExperimentPropertySet || (!this._isMeteredConnectionInitialized && !force)) { + return; + } + + if (this._meteredConnectionService?.isConnectionMetered) { + this._pendingEvents = []; + return; } - // Send all buffered events now that experiment properties are available for (const event of this._pendingEvents) { this._doLog(event.eventName, event.eventLevel, event.data); } @@ -197,8 +226,9 @@ export class TelemetryService implements ITelemetryService { } dispose(): void { + this._isDisposed = true; // Flush any remaining pending events before disposing - this._flushPendingEvents(); + this._flushPendingEvents(true); this._disposables.dispose(); } @@ -208,8 +238,12 @@ export class TelemetryService implements ITelemetryService { return; } - // Buffer events until experiment properties are set (or timeout expires) - if (!this._isExperimentPropertySet) { + if (this._isMeteredConnectionInitialized && this._meteredConnectionService?.isConnectionMetered) { + return; + } + + // Buffer events until experiment properties and the initial metered connection state are available. + if (!this._isExperimentPropertySet || !this._isMeteredConnectionInitialized) { if (this._pendingEvents.length < TelemetryService.MAX_BUFFER_SIZE) { this._pendingEvents.push({ eventName, eventLevel, data }); } diff --git a/src/vs/platform/telemetry/node/1dsAppender.ts b/src/vs/platform/telemetry/node/1dsAppender.ts index 0fdbbd1a732571..f03072ccc8ca01 100644 --- a/src/vs/platform/telemetry/node/1dsAppender.ts +++ b/src/vs/platform/telemetry/node/1dsAppender.ts @@ -6,7 +6,10 @@ import type { IPayloadData, IXHROverride } from '@microsoft/1ds-post-js'; import { streamToBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; +import { CancellationError, onUnexpectedError } from '../../../base/common/errors.js'; +import { IDisposable } from '../../../base/common/lifecycle.js'; import { IRequestOptions } from '../../../base/parts/request/common/request.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { IRequestService, NO_FETCH_TELEMETRY } from '../../request/common/request.js'; import { AbstractOneDataSystemAppender, IAppInsightsCore } from '../common/1dsAppender.js'; @@ -41,8 +44,12 @@ async function makeTelemetryRequest(options: IRequestOptions, requestService: IR * @param options The options which will be used to make the request * @returns An object containing the headers, statusCode, and responseData */ -async function makeLegacyTelemetryRequest(options: IRequestOptions): Promise { +async function makeLegacyTelemetryRequest(options: IRequestOptions, isTransmissionPaused: () => boolean): Promise { const https = await import('https'); // Lazy due to https://github.com/nodejs/node/issues/59686 + if (isTransmissionPaused()) { + throw new CancellationError(); + } + const httpsOptions = { method: options.type, headers: options.headers @@ -71,7 +78,12 @@ async function makeLegacyTelemetryRequest(options: IRequestOptions): Promise boolean) { + if (isTransmissionPaused()) { + oncomplete(0, {}); + return; + } + const telemetryRequestData = typeof payload.data === 'string' ? payload.data : new TextDecoder().decode(payload.data); const requestOptions: IRequestOptions = { type: 'POST', @@ -86,7 +98,7 @@ async function sendPostAsync(requestService: IRequestService | undefined, payloa }; try { - const responseData = requestService ? await makeTelemetryRequest(requestOptions, requestService) : await makeLegacyTelemetryRequest(requestOptions); + const responseData = requestService ? await makeTelemetryRequest(requestOptions, requestService) : await makeLegacyTelemetryRequest(requestOptions, isTransmissionPaused); oncomplete(responseData.statusCode, responseData.headers, responseData.responseData); } catch { // If it errors out, send status of 0 and a blank response to oncomplete so we can retry events @@ -97,21 +109,57 @@ async function sendPostAsync(requestService: IRequestService | undefined, payloa export class OneDataSystemAppender extends AbstractOneDataSystemAppender { + private readonly _meteredConnectionListener: IDisposable | undefined; + private _isFlushed = false; + constructor( requestService: IRequestService | undefined, isInternalTelemetry: boolean, eventPrefix: string, defaultData: { [key: string]: unknown } | null, iKeyOrClientFactory: string | (() => IAppInsightsCore), // allow factory function for testing + meteredConnectionService?: IMeteredConnectionService, ) { // Override the way events get sent since node doesn't have XHTMLRequest const customHttpXHROverride: IXHROverride = { sendPOST: (payload: IPayloadData, oncomplete: OnCompleteFunc) => { // Fire off the async request without awaiting it - sendPostAsync(requestService, payload, oncomplete); + void sendPostAsync(requestService, payload, oncomplete, () => this.isTransmissionPaused); } }; super(isInternalTelemetry, eventPrefix, defaultData, iKeyOrClientFactory, customHttpXHROverride); + + if (meteredConnectionService) { + let initialized = false; + const updateConnectionState = () => this.setIsConnectionMetered(!initialized || meteredConnectionService.isConnectionMetered); + updateConnectionState(); + this._meteredConnectionListener = meteredConnectionService.onDidChangeIsConnectionMetered(updateConnectionState); + void meteredConnectionService.whenInitialized.then(() => { + if (!this._isFlushed) { + initialized = true; + updateConnectionState(); + } + }, onUnexpectedError); + } + } + + setIsConnectionMetered(isMetered: boolean): void { + this.setTransmissionPaused(this._isFlushed || isMetered); + } + + override async flush(): Promise { + if (this._isFlushed) { + return; + } + + this._isFlushed = true; + try { + await super.flush(); + } finally { + this.setTransmissionPaused(true); + this._aiCoreOrKey = undefined; + this._meteredConnectionListener?.dispose(); + } } } diff --git a/src/vs/platform/telemetry/node/customEndpointTelemetryService.ts b/src/vs/platform/telemetry/node/customEndpointTelemetryService.ts index fe6164bf6c6a35..5150e879079e18 100644 --- a/src/vs/platform/telemetry/node/customEndpointTelemetryService.ts +++ b/src/vs/platform/telemetry/node/customEndpointTelemetryService.ts @@ -3,72 +3,116 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { FileAccess } from '../../../base/common/network.js'; import { Client as TelemetryClient } from '../../../base/parts/ipc/node/ipc.cp.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILoggerService } from '../../log/common/log.js'; +import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { IProductService } from '../../product/common/productService.js'; import { ICustomEndpointTelemetryService, ITelemetryData, ITelemetryEndpoint, ITelemetryService } from '../common/telemetry.js'; import { TelemetryAppenderClient } from '../common/telemetryIpc.js'; import { TelemetryLogAppender } from '../common/telemetryLogAppender.js'; import { TelemetryService } from '../common/telemetryService.js'; -export class CustomEndpointTelemetryService implements ICustomEndpointTelemetryService { +interface ICustomTelemetryServiceEntry { + readonly service: ITelemetryService; + readonly client: TelemetryClient; + readonly appender: TelemetryAppenderClient; +} + +export class CustomEndpointTelemetryService extends Disposable implements ICustomEndpointTelemetryService { declare readonly _serviceBrand: undefined; - private customTelemetryServices = new Map(); + private readonly customTelemetryServices = new Map(); + private readonly customTelemetryDisposables = this._register(new DisposableMap()); constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ILoggerService private readonly loggerService: ILoggerService, @IEnvironmentService private readonly environmentService: IEnvironmentService, - @IProductService private readonly productService: IProductService - ) { } + @IProductService private readonly productService: IProductService, + @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, + ) { + super(); + this._register(meteredConnectionService.onDidChangeIsConnectionMetered(isMetered => { + for (const { client, appender } of this.customTelemetryServices.values()) { + if (client.isConnected) { + void appender.setIsConnectionMetered(isMetered); + } + } + })); + } + + protected createTelemetryClient(args: string[]): TelemetryClient { + return new TelemetryClient( + FileAccess.asFileUri('bootstrap-fork').fsPath, + { + serverName: 'Debug Telemetry', + timeout: 1000 * 60 * 5, + args, + env: { + ELECTRON_RUN_AS_NODE: 1, + VSCODE_PIPE_LOGGING: 'true', + VSCODE_ESM_ENTRYPOINT: 'vs/workbench/contrib/debug/node/telemetryApp' + } + } + ); + } private getCustomTelemetryService(endpoint: ITelemetryEndpoint): ITelemetryService { - if (!this.customTelemetryServices.has(endpoint.id)) { + let entry = this.customTelemetryServices.get(endpoint.id); + if (!entry) { + const disposables = new DisposableStore(); + this.customTelemetryDisposables.set(endpoint.id, disposables); + const serviceDisposables = disposables.add(new DisposableStore()); const telemetryInfo: { [key: string]: string } = Object.create(null); telemetryInfo['common.vscodemachineid'] = this.telemetryService.machineId; telemetryInfo['common.vscodesessionid'] = this.telemetryService.sessionId; - const args = [endpoint.id, JSON.stringify(telemetryInfo), endpoint.aiKey]; - const client = new TelemetryClient( - FileAccess.asFileUri('bootstrap-fork').fsPath, - { - serverName: 'Debug Telemetry', - timeout: 1000 * 60 * 5, - args, - env: { - ELECTRON_RUN_AS_NODE: 1, - VSCODE_PIPE_LOGGING: 'true', - VSCODE_ESM_ENTRYPOINT: 'vs/workbench/contrib/debug/node/telemetryApp' - } - } - ); + const args = [endpoint.id, JSON.stringify(telemetryInfo), endpoint.aiKey, String(this.meteredConnectionService.isConnectionMetered)]; + const client = disposables.add(this.createTelemetryClient(args)); const channel = client.getChannel('telemetryAppender'); + const appender = new TelemetryAppenderClient(channel); const appenders = [ - new TelemetryAppenderClient(channel), - new TelemetryLogAppender(`[${endpoint.id}] `, false, this.loggerService, this.environmentService, this.productService), + appender, + disposables.add(new TelemetryLogAppender(`[${endpoint.id}] `, false, this.loggerService, this.environmentService, this.productService)), ]; - this.customTelemetryServices.set(endpoint.id, new TelemetryService({ + const service = serviceDisposables.add(new TelemetryService({ appenders, - sendErrorTelemetry: endpoint.sendErrorTelemetry + sendErrorTelemetry: endpoint.sendErrorTelemetry, + meteredConnectionService: this.meteredConnectionService, }, this.configurationService, this.productService)); + entry = { service, client, appender }; + this.customTelemetryServices.set(endpoint.id, entry); } - return this.customTelemetryServices.get(endpoint.id)!; + return entry.service; } - publicLog(telemetryEndpoint: ITelemetryEndpoint, eventName: string, data?: ITelemetryData) { + async publicLog(telemetryEndpoint: ITelemetryEndpoint, eventName: string, data?: ITelemetryData): Promise { + await this.meteredConnectionService.whenInitialized; + if (this._store.isDisposed || this.meteredConnectionService.isConnectionMetered) { + return; + } const customTelemetryService = this.getCustomTelemetryService(telemetryEndpoint); customTelemetryService.publicLog(eventName, data); } - publicLogError(telemetryEndpoint: ITelemetryEndpoint, errorEventName: string, data?: ITelemetryData) { + async publicLogError(telemetryEndpoint: ITelemetryEndpoint, errorEventName: string, data?: ITelemetryData): Promise { + await this.meteredConnectionService.whenInitialized; + if (this._store.isDisposed || this.meteredConnectionService.isConnectionMetered) { + return; + } const customTelemetryService = this.getCustomTelemetryService(telemetryEndpoint); customTelemetryService.publicLogError(errorEventName, data); } + + override dispose(): void { + super.dispose(); + this.customTelemetryServices.clear(); + } } diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index 1447eddaaef6ee..7aa16a0e524d9e 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -6,10 +6,12 @@ import assert from 'assert'; import * as sinon from 'sinon'; import sinonTest from 'sinon-test'; import { mainWindow } from '../../../../base/browser/window.js'; +import { DeferredPromise } from '../../../../base/common/async.js'; import * as Errors from '../../../../base/common/errors.js'; -import { Emitter } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IMeteredConnectionService } from '../../../meteredConnection/common/meteredConnection.js'; import product from '../../../product/common/product.js'; import { IProductService } from '../../../product/common/productService.js'; import ErrorTelemetry from '../../browser/errorTelemetry.js'; @@ -43,6 +45,16 @@ class TestTelemetryAppender implements ITelemetryAppender { } } +class TestMeteredConnectionService implements IMeteredConnectionService { + declare readonly _serviceBrand: undefined; + readonly onDidChangeIsConnectionMetered = Event.None; + + constructor( + public isConnectionMetered: boolean, + readonly whenInitialized: Promise, + ) { } +} + class ErrorTestingSettings { public personalInfo: string; public importantInfo: string; @@ -137,6 +149,155 @@ suite('TelemetryService', () => { service.dispose(); })); + test('buffers events until the metered connection state is initialized', async () => { + const initialized = new DeferredPromise(); + const testAppender = new TestTelemetryAppender(); + const service = new TelemetryService({ + appenders: [testAppender], + meteredConnectionService: new TestMeteredConnectionService(false, initialized.p), + }, new TestConfigurationService(), TestProductService); + + service.publicLog('testEvent'); + assert.strictEqual(testAppender.getEventsCount(), 0); + + initialized.complete(); + await initialized.p; + await Promise.resolve(); + + assert.strictEqual(testAppender.getEventsCount(), 1); + service.dispose(); + }); + + test('drops buffered events when the initialized connection is metered', async () => { + const initialized = new DeferredPromise(); + const testAppender = new TestTelemetryAppender(); + const service = new TelemetryService({ + appenders: [testAppender], + meteredConnectionService: new TestMeteredConnectionService(true, initialized.p), + }, new TestConfigurationService(), TestProductService); + + service.publicLog('testEvent'); + initialized.complete(); + await initialized.p; + await Promise.resolve(); + + assert.strictEqual(testAppender.getEventsCount(), 0); + service.dispose(); + }); + + test('drops usage and error events while metered and resumes when unmetered', async () => { + const testAppender = new TestTelemetryAppender(); + const meteredConnectionService = new TestMeteredConnectionService(false, Promise.resolve()); + const service = new TelemetryService({ + appenders: [testAppender], + sendErrorTelemetry: true, + meteredConnectionService, + }, new TestConfigurationService(), TestProductService); + await meteredConnectionService.whenInitialized; + + service.publicLog('before'); + meteredConnectionService.isConnectionMetered = true; + service.publicLog('meteredUsage'); + service.publicLogError('meteredError'); + meteredConnectionService.isConnectionMetered = false; + service.publicLogError('after'); + service.dispose(); + + assert.deepStrictEqual(testAppender.events.map(event => event.eventName), ['before', 'after']); + }); + + test('drops experiment-buffered events if the connection becomes metered', async () => { + const testAppender = new TestTelemetryAppender(); + const meteredConnectionService = new TestMeteredConnectionService(false, Promise.resolve()); + const service = new TelemetryService({ + appenders: [testAppender], + waitForExperimentProperties: true, + meteredConnectionService, + }, new TestConfigurationService(), TestProductService); + await meteredConnectionService.whenInitialized; + + service.publicLog('buffered'); + meteredConnectionService.isConnectionMetered = true; + service.setExperimentProperty('experiment', 'enabled'); + meteredConnectionService.isConnectionMetered = false; + service.publicLog('resumed'); + service.dispose(); + + assert.deepStrictEqual(testAppender.events.map(event => event.eventName), ['resumed']); + }); + + test('does not buffer known metered events while waiting for experiment properties', async () => { + const testAppender = new TestTelemetryAppender(); + const meteredConnectionService = new TestMeteredConnectionService(true, Promise.resolve()); + const service = new TelemetryService({ + appenders: [testAppender], + sendErrorTelemetry: true, + waitForExperimentProperties: true, + meteredConnectionService, + }, new TestConfigurationService(), TestProductService); + await meteredConnectionService.whenInitialized; + + service.publicLog('meteredUsage'); + service.publicLogError('meteredError'); + meteredConnectionService.isConnectionMetered = false; + service.setExperimentProperty('experiment', 'enabled'); + service.publicLog('resumed'); + service.dispose(); + + assert.deepStrictEqual(testAppender.events.map(event => event.eventName), ['resumed']); + }); + + test('experiment properties do not bypass metered initialization', async () => { + const initialized = new DeferredPromise(); + const testAppender = new TestTelemetryAppender(); + const meteredConnectionService = new TestMeteredConnectionService(false, initialized.p); + const service = new TelemetryService({ + appenders: [testAppender], + waitForExperimentProperties: true, + meteredConnectionService, + }, new TestConfigurationService(), TestProductService); + + service.publicLog('startup'); + service.setExperimentProperty('experiment', 'enabled'); + const eventsBeforeInitialization = testAppender.getEventsCount(); + meteredConnectionService.isConnectionMetered = true; + await initialized.complete(); + service.dispose(); + + assert.deepStrictEqual({ eventsBeforeInitialization, events: testAppender.events }, { + eventsBeforeInitialization: 0, + events: [], + }); + }); + + test('flushes buffered events on dispose while the metered connection state is pending', () => { + const initialized = new DeferredPromise(); + const testAppender = new TestTelemetryAppender(); + const service = new TelemetryService({ + appenders: [testAppender], + meteredConnectionService: new TestMeteredConnectionService(false, initialized.p), + }, new TestConfigurationService(), TestProductService); + + service.publicLog('testEvent'); + service.dispose(); + + assert.strictEqual(testAppender.getEventsCount(), 1); + }); + + test('drops buffered events on dispose while the pending connection state is conservatively metered', () => { + const initialized = new DeferredPromise(); + const testAppender = new TestTelemetryAppender(); + const service = new TelemetryService({ + appenders: [testAppender], + meteredConnectionService: new TestMeteredConnectionService(true, initialized.p), + }, new TestConfigurationService(), TestProductService); + + service.publicLog('testEvent'); + service.dispose(); + + assert.strictEqual(testAppender.getEventsCount(), 0); + }); + test('Fixed telemetry level does not require a configuration service', sinonTestFn(function () { const testAppender = new TestTelemetryAppender(); const service = TelemetryService.createWithLevel({ diff --git a/src/vs/platform/telemetry/test/common/telemetryIpc.test.ts b/src/vs/platform/telemetry/test/common/telemetryIpc.test.ts new file mode 100644 index 00000000000000..4e720868d9e8f3 --- /dev/null +++ b/src/vs/platform/telemetry/test/common/telemetryIpc.test.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ITelemetryLog, TelemetryAppenderChannel, TelemetryAppenderClient } from '../../common/telemetryIpc.js'; + +suite('TelemetryAppenderChannel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('forwards metered state separately from telemetry events', async () => { + const events: ITelemetryLog[] = []; + const states: boolean[] = []; + const channel = new TelemetryAppenderChannel([{ + log: (eventName, data) => events.push({ eventName, data }), + flush: async () => { }, + }], isMetered => states.push(isMetered)); + const clientChannel: IChannel = { + call: (command, arg) => channel.call(undefined, command, arg), + listen: event => channel.listen(undefined, event), + }; + const client = new TelemetryAppenderClient(clientChannel); + + await client.setIsConnectionMetered(true); + await client.log('testEvent', { value: 1 }); + await client.setIsConnectionMetered(false); + + assert.deepStrictEqual({ events, states }, { + events: [{ eventName: 'testEvent', data: { value: 1 } }], + states: [true, false], + }); + }); + + test('rejects malformed messages and unknown commands', async () => { + const channel = new TelemetryAppenderChannel([], () => assert.fail('Invalid state must not be forwarded')); + + await assert.rejects(channel.call(undefined, 'setIsConnectionMetered', 'true'), /Invalid metered connection argument/); + await assert.rejects(channel.call(undefined, 'log', null), /Invalid telemetry log argument/); + await assert.rejects(channel.call(undefined, 'log', { eventName: 42 }), /Invalid telemetry log argument/); + await assert.rejects(channel.call(undefined, 'unknown', undefined), /Unknown telemetry appender command/); + }); + + test('rejects state updates when the channel has no state handler', async () => { + const channel = new TelemetryAppenderChannel([]); + await assert.rejects(channel.call(undefined, 'setIsConnectionMetered', true), /Metered connection updates are not supported/); + }); +}); diff --git a/src/vs/platform/telemetry/test/node/1dsAppender.test.ts b/src/vs/platform/telemetry/test/node/1dsAppender.test.ts new file mode 100644 index 00000000000000..2b6036f73fe7a8 --- /dev/null +++ b/src/vs/platform/telemetry/test/node/1dsAppender.test.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ITelemetryItem, ITelemetryUnloadState } from '@microsoft/1ds-core-js'; +import { PostChannel } from '@microsoft/1ds-post-js'; +import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { bufferToStream, VSBuffer } from '../../../../base/common/buffer.js'; +import { IRequestOptions } from '../../../../base/parts/request/common/request.js'; +import { upcastPartial } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/virtualScheduling/index.js'; +import { TestMeteredConnectionService } from '../../../meteredConnection/test/common/testMeteredConnectionService.js'; +import { IRequestService } from '../../../request/common/request.js'; +import { IAppInsightsCore } from '../../common/1dsAppender.js'; +import { OneDataSystemAppender } from '../../node/1dsAppender.js'; + +class TestAppInsightsCore implements IAppInsightsCore { + pluginVersionString = ''; + readonly events: ITelemetryItem[] = []; + unloadCount = 0; + + track(item: ITelemetryItem): void { + this.events.push(item); + } + + unload(isAsync: boolean, unloadComplete: (unloadState: ITelemetryUnloadState) => void): void { + this.unloadCount++; + unloadComplete({ reason: 0, isAsync }); + } +} + +class TestOneDataSystemAppender extends OneDataSystemAppender { + readonly clientInitialized = new DeferredPromise(); + transmissionController: Pick | undefined; + + override setTransmissionController(transmissionController: Pick): void { + super.setTransmissionController(transmissionController); + this.transmissionController = transmissionController; + void this.clientInitialized.complete(); + } +} + +suite('OneDataSystemAppender', () => { + const appenders: OneDataSystemAppender[] = []; + + teardown(async () => { + await Promise.all(appenders.splice(0).map(appender => appender.flush())); + }); + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createAppender(isMetered: boolean, whenInitialized = Promise.resolve()) { + const core = new TestAppInsightsCore(); + const meteredConnectionService = store.add(new TestMeteredConnectionService(isMetered, whenInitialized)); + const appender = new TestOneDataSystemAppender(undefined, false, 'test', null, () => core, meteredConnectionService); + appenders.push(appender); + const transmissionChanges: string[] = []; + appender.setTransmissionController({ + pause: () => transmissionChanges.push('paused'), + resume: () => transmissionChanges.push('resumed'), + }); + return { core, meteredConnectionService, appender, transmissionChanges }; + } + + function createNetworkAppender(responseCodes: readonly number[] = []) { + const requests: IRequestOptions[] = []; + const requestService = upcastPartial({ + request: async options => { + requests.push(options); + return { + res: { statusCode: responseCodes[requests.length - 1] ?? 200, headers: {} }, + stream: bufferToStream(VSBuffer.fromString('')), + }; + }, + }); + const appender = new TestOneDataSystemAppender(requestService, false, 'test', null, 'test-key'); + appenders.push(appender); + return { appender, requests }; + } + + test('pauses until initialization, then follows metered state changes', async () => { + const { appender, core, meteredConnectionService, transmissionChanges } = createAppender(false); + appender.log('beforeInitialization'); + await meteredConnectionService.whenInitialized; + appender.log('unmetered'); + meteredConnectionService.setIsConnectionMetered(true); + appender.log('metered'); + meteredConnectionService.setIsConnectionMetered(false); + appender.log('resumed'); + await appender.flush(); + + assert.deepStrictEqual({ + transmissionChanges, + events: core.events.map(event => event.name), + unloadCount: core.unloadCount, + }, { + transmissionChanges: ['paused', 'resumed', 'paused', 'resumed'], + events: ['test/unmetered', 'test/resumed'], + unloadCount: 1, + }); + }); + + test('resumes after a silent unmetered initial snapshot', async () => { + const initialized = new DeferredPromise(); + const { appender, core, meteredConnectionService, transmissionChanges } = createAppender(true, initialized.p); + appender.log('beforeInitialization'); + meteredConnectionService.isConnectionMetered = false; + await initialized.complete(); + appender.log('afterInitialization'); + await appender.flush(); + + assert.deepStrictEqual({ + transmissionChanges, + events: core.events.map(event => event.name), + }, { + transmissionChanges: ['paused', 'resumed'], + events: ['test/afterInitialization'], + }); + }); + + test('does not resume on unmetered events before initialization', async () => { + const initialized = new DeferredPromise(); + const { appender, core, meteredConnectionService, transmissionChanges } = createAppender(false, initialized.p); + meteredConnectionService.setIsConnectionMetered(false); + appender.log('pending'); + meteredConnectionService.isConnectionMetered = true; + await initialized.complete(); + appender.log('metered'); + await appender.flush(); + + assert.deepStrictEqual({ transmissionChanges, events: core.events, unloadCount: core.unloadCount }, { + transmissionChanges: ['paused'], + events: [], + unloadCount: 0, + }); + }); + + test('does not flush or resume after shutdown before initialization', async () => { + const initialized = new DeferredPromise(); + const { appender, core, meteredConnectionService, transmissionChanges } = createAppender(false, initialized.p); + await appender.flush(); + await initialized.complete(); + meteredConnectionService.setIsConnectionMetered(false); + appender.log('afterShutdown'); + + assert.deepStrictEqual({ transmissionChanges, events: core.events, unloadCount: core.unloadCount }, { + transmissionChanges: ['paused'], + events: [], + unloadCount: 0, + }); + }); + + test('does not flush queued events on metered shutdown', async () => { + const { appender, core, meteredConnectionService, transmissionChanges } = createAppender(false); + await meteredConnectionService.whenInitialized; + appender.log('queued'); + meteredConnectionService.setIsConnectionMetered(true); + await appender.flush(); + meteredConnectionService.setIsConnectionMetered(false); + + assert.deepStrictEqual({ + transmissionChanges, + events: core.events.map(event => event.name), + unloadCount: core.unloadCount, + }, { + transmissionChanges: ['paused', 'resumed', 'paused'], + events: ['test/queued'], + unloadCount: 0, + }); + }); + + test('suspends real 1DS batches without sending HTTP until unmetered', async () => { + const { appender, requests } = createNetworkAppender(); + appender.log('queued'); + await appender.clientInitialized.p; + await Promise.resolve(); + assert(appender.transmissionController instanceof PostChannel); + + appender.setIsConnectionMetered(true); + appender.log('metered'); + appender.transmissionController.flush(false); + const requestsWhileMetered = requests.length; + + appender.setIsConnectionMetered(false); + appender.log('resumed'); + await appender.flush(); + + const events = requests.flatMap(request => request.data!.trim().split('\n').map(line => { + const event: ITelemetryItem = JSON.parse(line); + return event.name; + })); + assert.deepStrictEqual({ requestsWhileMetered, events }, { + requestsWhileMetered: 0, + events: ['test/queued', 'test/resumed'], + }); + }); + + test('applies metered state before a lazily created 1DS client can send', async () => { + const { appender, requests } = createNetworkAppender(); + appender.log('initializing'); + appender.setIsConnectionMetered(true); + await appender.clientInitialized.p; + await Promise.resolve(); + assert(appender.transmissionController instanceof PostChannel); + appender.transmissionController.flush(false); + const requestsWhileMetered = requests.length; + + appender.setIsConnectionMetered(false); + await appender.flush(); + + assert.deepStrictEqual({ requestsWhileMetered, requestsAfterResume: requests.length }, { + requestsWhileMetered: 0, + requestsAfterResume: 0, + }); + }); + + test('suspends scheduled 1DS retries until unmetered', () => runWithFakedTimers({}, async () => { + const { appender, requests } = createNetworkAppender([500, 200]); + appender.log('retry'); + await appender.clientInitialized.p; + await Promise.resolve(); + assert(appender.transmissionController instanceof PostChannel); + appender.transmissionController.flush(true); + await timeout(1); + const requestsBeforeMetered = requests.length; + + appender.setIsConnectionMetered(true); + await timeout(10000); + const requestsWhileMetered = requests.length; + + appender.setIsConnectionMetered(false); + await appender.flush(); + assert.deepStrictEqual({ requestsBeforeMetered, requestsWhileMetered, requestsAfterResume: requests.length }, { + requestsBeforeMetered: 1, + requestsWhileMetered: 1, + requestsAfterResume: 2, + }); + })); +}); diff --git a/src/vs/platform/telemetry/test/node/customEndpointTelemetryService.test.ts b/src/vs/platform/telemetry/test/node/customEndpointTelemetryService.test.ts new file mode 100644 index 00000000000000..6f1a60216bab16 --- /dev/null +++ b/src/vs/platform/telemetry/test/node/customEndpointTelemetryService.test.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise } from '../../../../base/common/async.js'; +import { Client } from '../../../../base/parts/ipc/node/ipc.cp.js'; +import { upcastPartial } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IEnvironmentService } from '../../../environment/common/environment.js'; +import { NullLoggerService } from '../../../log/common/log.js'; +import { TestMeteredConnectionService } from '../../../meteredConnection/test/common/testMeteredConnectionService.js'; +import product from '../../../product/common/product.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { ITelemetryEndpoint } from '../../common/telemetry.js'; +import { NullTelemetryService } from '../../common/telemetryUtils.js'; +import { CustomEndpointTelemetryService } from '../../node/customEndpointTelemetryService.js'; + +class TestTelemetryClient extends Client { + readonly messages: { command: string; arg: unknown }[] = []; + running = false; + + constructor(readonly args: string[]) { + super('', { serverName: 'Test Telemetry' }); + } + + override get isConnected(): boolean { + return this.running; + } + + protected override async requestPromise(_channelName: string, command: string, arg?: unknown): Promise { + this.running = true; + this.messages.push({ command, arg }); + return undefined!; + } +} + +class TestCustomEndpointTelemetryService extends CustomEndpointTelemetryService { + readonly clients: TestTelemetryClient[] = []; + + protected override createTelemetryClient(args: string[]): TestTelemetryClient { + const client = new TestTelemetryClient(args); + this.clients.push(client); + return client; + } +} + +suite('CustomEndpointTelemetryService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const endpoint: ITelemetryEndpoint = { id: 'test', aiKey: 'test-key', sendErrorTelemetry: true }; + + function createService(isMetered: boolean, whenInitialized = Promise.resolve(), telemetryLevel = 'all') { + const meteredConnectionService = store.add(new TestMeteredConnectionService(isMetered, whenInitialized)); + const configurationService = new TestConfigurationService({ telemetry: { telemetryLevel } }); + const loggerService = store.add(new NullLoggerService()); + const productService: IProductService = { _serviceBrand: undefined, ...product }; + const service = store.add(new TestCustomEndpointTelemetryService( + configurationService, NullTelemetryService, loggerService, + upcastPartial({ isBuilt: false }), productService, meteredConnectionService, + )); + return { service, meteredConnectionService }; + } + + test('does not create a telemetry child while metered', async () => { + const { service } = createService(true); + await service.publicLog(endpoint, 'usage'); + await service.publicLogError(endpoint, 'error'); + assert.deepStrictEqual(service.clients, []); + }); + + test('waits for the initial state and resumes after a silent unmetered snapshot', async () => { + const initialized = new DeferredPromise(); + const { service, meteredConnectionService } = createService(true, initialized.p); + const logged = service.publicLog(endpoint, 'startup'); + const clientsBeforeInitialization = service.clients.length; + meteredConnectionService.isConnectionMetered = false; + await initialized.complete(); + await logged; + + assert.deepStrictEqual({ + clientsBeforeInitialization, + initialMeteredArgument: service.clients[0].args[3], + messages: service.clients[0].messages, + }, { + clientsBeforeInitialization: 0, + initialMeteredArgument: 'false', + messages: [{ command: 'log', arg: { eventName: 'startup', data: {} } }], + }); + }); + + test('forwards state to a running child and drops metered events', async () => { + const { service, meteredConnectionService } = createService(false); + await service.publicLog(endpoint, 'before'); + meteredConnectionService.setIsConnectionMetered(true); + await service.publicLog(endpoint, 'meteredUsage'); + await service.publicLogError(endpoint, 'meteredError'); + meteredConnectionService.setIsConnectionMetered(false); + await service.publicLogError(endpoint, 'after'); + + assert.deepStrictEqual(service.clients[0].messages, [ + { command: 'log', arg: { eventName: 'before', data: {} } }, + { command: 'setIsConnectionMetered', arg: true }, + { command: 'setIsConnectionMetered', arg: false }, + { command: 'log', arg: { eventName: 'after', data: { isError: true } } }, + ]); + }); + + test('does not restart an idle child to forward connection state', async () => { + const { service, meteredConnectionService } = createService(false); + await service.publicLog(endpoint, 'beforeIdle'); + const client = service.clients[0]; + client.running = false; + meteredConnectionService.setIsConnectionMetered(true); + meteredConnectionService.setIsConnectionMetered(false); + const runningAfterStateChange = client.running; + await service.publicLog(endpoint, 'afterIdle'); + + assert.deepStrictEqual({ runningAfterStateChange, messages: client.messages }, { + runningAfterStateChange: false, + messages: [ + { command: 'log', arg: { eventName: 'beforeIdle', data: {} } }, + { command: 'log', arg: { eventName: 'afterIdle', data: {} } }, + ], + }); + }); + + test('does not start a child when telemetry is disabled', async () => { + const { service, meteredConnectionService } = createService(false, Promise.resolve(), 'off'); + await service.publicLog(endpoint, 'disabled'); + meteredConnectionService.setIsConnectionMetered(true); + assert.deepStrictEqual(service.clients.map(client => ({ running: client.running, messages: client.messages })), [ + { running: false, messages: [] }, + ]); + }); + + test('does not initialize or resume telemetry after disposal', async () => { + const initialized = new DeferredPromise(); + const { service, meteredConnectionService } = createService(false, initialized.p); + const logged = service.publicLog(endpoint, 'pending'); + service.dispose(); + await initialized.complete(); + await logged; + meteredConnectionService.setIsConnectionMetered(false); + assert.deepStrictEqual(service.clients, []); + }); +}); diff --git a/src/vs/workbench/contrib/debug/node/telemetryApp.ts b/src/vs/workbench/contrib/debug/node/telemetryApp.ts index f4d65d3b020d03..54fa429e3276fe 100644 --- a/src/vs/workbench/contrib/debug/node/telemetryApp.ts +++ b/src/vs/workbench/contrib/debug/node/telemetryApp.ts @@ -8,8 +8,9 @@ import { TelemetryAppenderChannel } from '../../../../platform/telemetry/common/ import { OneDataSystemAppender } from '../../../../platform/telemetry/node/1dsAppender.js'; const appender = new OneDataSystemAppender(undefined, false, process.argv[2], JSON.parse(process.argv[3]), process.argv[4]); +appender.setIsConnectionMetered(process.argv[5] === 'true'); process.once('exit', () => appender.flush()); -const channel = new TelemetryAppenderChannel([appender]); +const channel = new TelemetryAppenderChannel([appender], isMetered => appender.setIsConnectionMetered(isMetered)); const server = new Server('telemetry'); server.registerChannel('telemetryAppender', channel); diff --git a/src/vs/workbench/services/telemetry/browser/telemetryService.ts b/src/vs/workbench/services/telemetry/browser/telemetryService.ts index eb64abc71422cb..daa2c00fdd13e5 100644 --- a/src/vs/workbench/services/telemetry/browser/telemetryService.ts +++ b/src/vs/workbench/services/telemetry/browser/telemetryService.ts @@ -17,6 +17,7 @@ import { ITelemetryServiceConfig, TelemetryService as BaseTelemetryService } fro import { getTelemetryLevel, isInternalTelemetry, isLoggingOnly, ITelemetryAppender, NullTelemetryService, supportsTelemetry } from '../../../../platform/telemetry/common/telemetryUtils.js'; import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/environmentService.js'; import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { resolveWorkbenchCommonProperties } from './workbenchCommonProperties.js'; import { experimentsEnabled } from '../common/workbenchTelemetryUtils.js'; @@ -43,16 +44,17 @@ export class TelemetryService extends Disposable implements ITelemetryService { @IStorageService storageService: IStorageService, @IProductService productService: IProductService, @IRemoteAgentService remoteAgentService: IRemoteAgentService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, @IRequestService requestService: IRequestService ) { super(); - this.impl = this.initializeService(environmentService, loggerService, configurationService, storageService, productService, remoteAgentService); + this.impl = this.initializeService(environmentService, loggerService, configurationService, storageService, productService, remoteAgentService, meteredConnectionService); // When the level changes it could change from off to on and we want to make sure telemetry is properly intialized this._register(configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TELEMETRY_SETTING_ID)) { - this.impl = this.initializeService(environmentService, loggerService, configurationService, storageService, productService, remoteAgentService); + this.impl = this.initializeService(environmentService, loggerService, configurationService, storageService, productService, remoteAgentService, meteredConnectionService); } })); @@ -91,7 +93,8 @@ export class TelemetryService extends Disposable implements ITelemetryService { configurationService: IConfigurationService, storageService: IStorageService, productService: IProductService, - remoteAgentService: IRemoteAgentService + remoteAgentService: IRemoteAgentService, + meteredConnectionService: IMeteredConnectionService ) { const telemetrySupported = supportsTelemetry(productService, environmentService) && productService.aiConfig?.ariaKey; if (telemetrySupported && getTelemetryLevel(configurationService) !== TelemetryLevel.NONE && this.impl === NullTelemetryService) { @@ -120,6 +123,7 @@ export class TelemetryService extends Disposable implements ITelemetryService { piiPaths: [mainWindow.location.origin], sendErrorTelemetry: this.sendErrorTelemetry, waitForExperimentProperties: experimentsEnabled(configurationService, productService, environmentService), + meteredConnectionService, }; return this._register(new BaseTelemetryService(config, configurationService, productService)); diff --git a/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts b/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts index 98ab319d75ff4c..7de35b7d0f782e 100644 --- a/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts +++ b/src/vs/workbench/services/telemetry/electron-browser/telemetryService.ts @@ -19,6 +19,7 @@ import { ClassifiedEvent, StrictPropertyCheck, OmitMetadata, IGDPRProperty } fro import { process } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { experimentsEnabled } from '../common/workbenchTelemetryUtils.js'; import { IRequestService, NO_FETCH_TELEMETRY } from '../../../../platform/request/common/request.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; export class TelemetryService extends Disposable implements ITelemetryService { @@ -40,7 +41,8 @@ export class TelemetryService extends Disposable implements ITelemetryService { @ISharedProcessService sharedProcessService: ISharedProcessService, @IStorageService storageService: IStorageService, @IConfigurationService configurationService: IConfigurationService, - @IRequestService requestService: IRequestService + @IRequestService requestService: IRequestService, + @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService ) { super(); @@ -64,6 +66,7 @@ export class TelemetryService extends Disposable implements ITelemetryService { piiPaths: getPiiPathsFromEnvironment(environmentService), sendErrorTelemetry: true, waitForExperimentProperties: experimentsEnabled(configurationService, productService, environmentService), + meteredConnectionService, }; this.impl = this._register(new BaseTelemetryService(config, configurationService, productService));