Skip to content

Commit 9f95296

Browse files
authored
Improve opt-in/out telemetry (#15946)
* Add telemetry event declaration * Send telemetry * Add tests
1 parent 5205c44 commit 9f95296

4 files changed

Lines changed: 263 additions & 7 deletions

File tree

src/client/common/experiments/service.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import { inject, injectable, named } from 'inversify';
77
import { Memento } from 'vscode';
88
import { getExperimentationService, IExperimentationService, TargetPopulation } from 'vscode-tas-client';
9+
import { sendTelemetryEvent } from '../../telemetry';
10+
import { EventName } from '../../telemetry/constants';
911
import { IApplicationEnvironment, IWorkspaceService } from '../application/types';
1012
import { PVSC_EXTENSION_ID, STANDARD_OUTPUT_CHANNEL } from '../constants';
1113
import { GLOBAL_MEMENTO, IExperimentService, IMemento, IOutputChannel } from '../types';
@@ -81,6 +83,7 @@ export class ExperimentService implements IExperimentService {
8183
await this.experimentationService.initializePromise;
8284
await this.experimentationService.initialFetch;
8385
}
86+
sendOptInOptOutTelemetry(this._optInto, this._optOutFrom, this.appEnvironment.packageJson);
8487
}
8588

8689
public async inExperiment(experiment: string): Promise<boolean> {
@@ -162,3 +165,47 @@ export class ExperimentService implements IExperimentService {
162165
});
163166
}
164167
}
168+
169+
/**
170+
* Read accepted experiment settings values from the extension's package.json.
171+
* This function assumes that the `setting` argument is a string array that has a specific set of accepted values.
172+
*
173+
* Accessing the values is done via these keys:
174+
* <root> -> "contributes" -> "configuration" -> "properties" -> <setting name> -> "items" -> "enum"
175+
*
176+
* @param setting The setting we want to read the values of.
177+
* @param packageJson The content of `package.json`, as a JSON object.
178+
*
179+
* @returns An array containing all accepted values for the setting, or [] if there were none.
180+
*/
181+
function readEnumValues(setting: string, packageJson: Record<string, unknown>): string[] {
182+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
183+
const settingProperties = (packageJson.contributes as any).configuration.properties[setting];
184+
185+
if (settingProperties) {
186+
return settingProperties.items.enum ?? [];
187+
}
188+
189+
return [];
190+
}
191+
192+
/**
193+
* Send telemetry on experiments that have been manually opted into or opted-out from.
194+
* The telemetry will only contain values that are present in the list of accepted values for these settings.
195+
*
196+
* @param optedIn The list of experiments opted into.
197+
* @param optedOut The list of experiments opted out from.
198+
* @param packageJson The content of `package.json`, as a JSON object.
199+
*/
200+
function sendOptInOptOutTelemetry(optedIn: string[], optedOut: string[], packageJson: Record<string, unknown>): void {
201+
const optedInEnumValues = readEnumValues('python.experiments.optInto', packageJson);
202+
const optedOutEnumValues = readEnumValues('python.experiments.optOutFrom', packageJson);
203+
204+
const sanitizedOptedIn = optedIn.filter((exp) => optedInEnumValues.includes(exp));
205+
const sanitizedOptedOut = optedOut.filter((exp) => optedOutEnumValues.includes(exp));
206+
207+
sendTelemetryEvent(EventName.PYTHON_EXPERIMENTS_OPT_IN_OPT_OUT_SETTINGS, undefined, {
208+
optedInto: sanitizedOptedIn,
209+
optedOutFrom: sanitizedOptedOut,
210+
});
211+
}

src/client/telemetry/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export enum EventName {
7676
PYTHON_EXPERIMENTS = 'PYTHON_EXPERIMENTS',
7777
PYTHON_EXPERIMENTS_DISABLED = 'PYTHON_EXPERIMENTS_DISABLED',
7878
PYTHON_EXPERIMENTS_DOWNLOAD_SUCCESS_RATE = 'PYTHON_EXPERIMENTS_DOWNLOAD_SUCCESS_RATE',
79+
PYTHON_EXPERIMENTS_OPT_IN_OPT_OUT_SETTINGS = 'PYTHON_EXPERIMENTS_OPT_IN_OPT_OUT_SETTINGS',
7980
PYTHON_WEB_APP_RELOAD = 'PYTHON_WEB_APP.RELOAD',
8081
EXTENSION_SURVEY_PROMPT = 'EXTENSION_SURVEY_PROMPT',
8182
ACTIVATION_TIP_PROMPT = 'ACTIVATION_TIP_PROMPT',

src/client/telemetry/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,21 @@ export interface IEventNamePropertyMapping {
12741274
*/
12751275
error?: string;
12761276
};
1277+
/**
1278+
* Telemetry event sent once on session start with details on which experiments are opted into and opted out from.
1279+
*/
1280+
[EventName.PYTHON_EXPERIMENTS_OPT_IN_OPT_OUT_SETTINGS]: {
1281+
/**
1282+
* List of valid experiments in the python.experiments.optInto setting
1283+
* @type {string[]}
1284+
*/
1285+
optedInto: string[];
1286+
/**
1287+
* List of valid experiments in the python.experiments.optOutFrom setting
1288+
* @type {string[]}
1289+
*/
1290+
optedOutFrom: string[];
1291+
};
12771292
/**
12781293
* Telemetry event sent when LS is started for workspace (workspace folder in case of multi-root)
12791294
*/

src/test/common/experiments/service.unit.test.ts

Lines changed: 200 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { WorkspaceService } from '../../../client/common/application/workspace';
1414
import { ExperimentService } from '../../../client/common/experiments/service';
1515
import { Experiments } from '../../../client/common/utils/localize';
1616
import * as Telemetry from '../../../client/telemetry';
17+
import { EventName } from '../../../client/telemetry/constants';
1718
import { PVSC_EXTENSION_ID_FOR_TESTS } from '../../constants';
1819
import { MockOutputChannel } from '../../mockClasses';
1920
import { MockMemento } from '../../mocks/mementos';
@@ -56,10 +57,10 @@ suite('Experimentation service', () => {
5657
} as any);
5758
}
5859

59-
function configureApplicationEnvironment(channel: Channel, version: string) {
60+
function configureApplicationEnvironment(channel: Channel, version: string, contributes?: Record<string, unknown>) {
6061
when(appEnvironment.extensionChannel).thenReturn(channel);
6162
when(appEnvironment.extensionName).thenReturn(PVSC_EXTENSION_ID_FOR_TESTS);
62-
when(appEnvironment.packageJson).thenReturn({ version });
63+
when(appEnvironment.packageJson).thenReturn({ version, contributes });
6364
}
6465

6566
suite('Initialization', () => {
@@ -149,7 +150,7 @@ suite('Experimentation service', () => {
149150
);
150151
const output = `${Experiments.inGroup().format('pythonExperiment')}\n`;
151152

152-
assert.equal(outputChannel.output, output);
153+
assert.strictEqual(outputChannel.output, output);
153154
});
154155
});
155156

@@ -223,7 +224,7 @@ suite('Experimentation service', () => {
223224
const result = await experimentService.inExperiment(experiment);
224225

225226
assert.isTrue(result);
226-
assert.equal(telemetryEvents.length, 0);
227+
assert.strictEqual(telemetryEvents.length, 0);
227228
});
228229

229230
test('If the opt-in setting contains `All`, inExperiment should check the value cached by the experiment service', async () => {
@@ -270,7 +271,7 @@ suite('Experimentation service', () => {
270271
const result = await experimentService.inExperiment(experiment);
271272

272273
assert.isTrue(result);
273-
assert.equal(telemetryEvents.length, 0);
274+
assert.strictEqual(telemetryEvents.length, 0);
274275
sinon.assert.calledOnce(isCachedFlightEnabledStub);
275276
});
276277

@@ -318,7 +319,7 @@ suite('Experimentation service', () => {
318319
const result = await experimentService.inExperiment(experiment);
319320

320321
assert.isFalse(result);
321-
assert.equal(telemetryEvents.length, 0);
322+
assert.strictEqual(telemetryEvents.length, 0);
322323
sinon.assert.notCalled(isCachedFlightEnabledStub);
323324
});
324325
});
@@ -347,7 +348,7 @@ suite('Experimentation service', () => {
347348
);
348349
const result = await experimentService.getExperimentValue(experiment);
349350

350-
assert.equal(result, 'value');
351+
assert.strictEqual(result, 'value');
351352
sinon.assert.calledOnce(getTreatmentVariableAsyncStub);
352353
});
353354

@@ -396,4 +397,196 @@ suite('Experimentation service', () => {
396397
sinon.assert.notCalled(getTreatmentVariableAsyncStub);
397398
});
398399
});
400+
401+
suite('Opt-in/out telemetry', () => {
402+
let telemetryEvents: { eventName: string; properties: Record<string, unknown> }[] = [];
403+
let sendTelemetryEventStub: sinon.SinonStub;
404+
405+
setup(() => {
406+
sendTelemetryEventStub = sinon
407+
.stub(Telemetry, 'sendTelemetryEvent')
408+
.callsFake((eventName: string, _, properties: Record<string, unknown>) => {
409+
const telemetry = { eventName, properties };
410+
telemetryEvents.push(telemetry);
411+
});
412+
413+
configureApplicationEnvironment('stable', extensionVersion);
414+
});
415+
416+
teardown(() => {
417+
telemetryEvents = [];
418+
});
419+
420+
test('Telemetry should be sent when activating the ExperimentService instance', async () => {
421+
configureSettings(true, [], []);
422+
configureApplicationEnvironment('stable', extensionVersion, { configuration: { properties: {} } });
423+
424+
const experimentService = new ExperimentService(
425+
instance(workspaceService),
426+
instance(appEnvironment),
427+
globalMemento,
428+
outputChannel,
429+
);
430+
431+
await experimentService.activate();
432+
433+
assert.strictEqual(telemetryEvents.length, 1);
434+
assert.strictEqual(telemetryEvents[0].eventName, EventName.PYTHON_EXPERIMENTS_OPT_IN_OPT_OUT_SETTINGS);
435+
sinon.assert.calledOnce(sendTelemetryEventStub);
436+
});
437+
438+
test('The telemetry event properties should only be populated with valid experiment values', async () => {
439+
const contributes = {
440+
configuration: {
441+
properties: {
442+
'python.experiments.optInto': {
443+
items: {
444+
enum: ['foo', 'bar'],
445+
},
446+
},
447+
'python.experiments.optOutFrom': {
448+
items: {
449+
enum: ['foo', 'bar'],
450+
},
451+
},
452+
},
453+
},
454+
};
455+
configureSettings(true, ['foo', 'baz'], ['bar', 'invalid']);
456+
configureApplicationEnvironment('stable', extensionVersion, contributes);
457+
458+
const experimentService = new ExperimentService(
459+
instance(workspaceService),
460+
instance(appEnvironment),
461+
globalMemento,
462+
outputChannel,
463+
);
464+
465+
await experimentService.activate();
466+
467+
const { properties } = telemetryEvents[0];
468+
assert.deepStrictEqual(properties, { optedInto: ['foo'], optedOutFrom: ['bar'] });
469+
});
470+
471+
test('Set telemetry properties to empty arrays if no experiments have been opted into or out from', async () => {
472+
const contributes = {
473+
configuration: {
474+
properties: {
475+
'python.experiments.optInto': {
476+
items: {
477+
enum: ['foo', 'bar'],
478+
},
479+
},
480+
'python.experiments.optOutFrom': {
481+
items: {
482+
enum: ['foo', 'bar'],
483+
},
484+
},
485+
},
486+
},
487+
};
488+
configureSettings(true, [], []);
489+
configureApplicationEnvironment('stable', extensionVersion, contributes);
490+
491+
const experimentService = new ExperimentService(
492+
instance(workspaceService),
493+
instance(appEnvironment),
494+
globalMemento,
495+
outputChannel,
496+
);
497+
498+
await experimentService.activate();
499+
500+
const { properties } = telemetryEvents[0];
501+
assert.deepStrictEqual(properties, { optedInto: [], optedOutFrom: [] });
502+
});
503+
504+
test('If the entered value for a setting contains "All", do not expand it to be a list of all experiments, and pass it as-is', async () => {
505+
const contributes = {
506+
configuration: {
507+
properties: {
508+
'python.experiments.optInto': {
509+
items: {
510+
enum: ['foo', 'bar', 'All'],
511+
},
512+
},
513+
'python.experiments.optOutFrom': {
514+
items: {
515+
enum: ['foo', 'bar', 'All'],
516+
},
517+
},
518+
},
519+
},
520+
};
521+
configureSettings(true, ['All'], ['All']);
522+
configureApplicationEnvironment('stable', extensionVersion, contributes);
523+
524+
const experimentService = new ExperimentService(
525+
instance(workspaceService),
526+
instance(appEnvironment),
527+
globalMemento,
528+
outputChannel,
529+
);
530+
531+
await experimentService.activate();
532+
533+
const { properties } = telemetryEvents[0];
534+
assert.deepStrictEqual(properties, { optedInto: ['All'], optedOutFrom: ['All'] });
535+
});
536+
537+
// This is an unlikely scenario.
538+
test('If a setting is not in package.json, set the corresponding telemetry property to an empty array', async () => {
539+
const contributes = {
540+
configuration: {
541+
properties: {},
542+
},
543+
};
544+
configureSettings(true, ['something'], ['another']);
545+
configureApplicationEnvironment('stable', extensionVersion, contributes);
546+
547+
const experimentService = new ExperimentService(
548+
instance(workspaceService),
549+
instance(appEnvironment),
550+
globalMemento,
551+
outputChannel,
552+
);
553+
554+
await experimentService.activate();
555+
556+
const { properties } = telemetryEvents[0];
557+
assert.deepStrictEqual(properties, { optedInto: [], optedOutFrom: [] });
558+
});
559+
560+
// This is also an unlikely scenario.
561+
test('If a setting does not have an enum of valid values, set the corresponding telemetry property to an empty array', async () => {
562+
const contributes = {
563+
configuration: {
564+
properties: {
565+
'python.experiments.optInto': {
566+
items: {},
567+
},
568+
'python.experiments.optOutFrom': {
569+
items: {
570+
enum: ['foo', 'bar', 'All'],
571+
},
572+
},
573+
},
574+
},
575+
};
576+
configureSettings(true, ['something'], []);
577+
configureApplicationEnvironment('stable', extensionVersion, contributes);
578+
579+
const experimentService = new ExperimentService(
580+
instance(workspaceService),
581+
instance(appEnvironment),
582+
globalMemento,
583+
outputChannel,
584+
);
585+
586+
await experimentService.activate();
587+
588+
const { properties } = telemetryEvents[0];
589+
assert.deepStrictEqual(properties, { optedInto: [], optedOutFrom: [] });
590+
});
591+
});
399592
});

0 commit comments

Comments
 (0)