Skip to content

Commit 015102f

Browse files
Add explicit Environments opt-out scope telemetry (#26171)
## Summary Distinguish explicit `python.useEnvironmentsExtension: false` configuration from defaults and extension availability without changing integration behavior. - Add `envsExplicitFalseScope` to the existing `EDITOR.LOAD` event, including untrusted-workspace startup. - Add `ENVIRONMENTS_EXTENSION.SETTING_CHANGED` with the same property when VS Code reports this setting affected by a configuration change. - Include GDPR declarations and focused tests. ## Property semantics `envsExplicitFalseScope` is one of `none`, `user`, `workspace`, `folder`, or `multiple`. - Only explicitly configured `false` values count; defaults are ignored. - Inspect all current workspace folders using their resource scope. - `multiple` means multiple scope kinds, not multiple folders. - Report explicit false values even when overridden by higher-precedence settings. This describes configuration, not an effective integration decision or proof of user intent. - A configuration-change event can report an unchanged scope or `none`; it is not necessarily an opt-out action. The existing `usingEnvironmentsExtension` property remains unchanged. There are no configuration writes, integration or activation changes, new identifiers, paths, or persistent tracking. The listener is disposed with the extension. ## Validation Validated using the repository's pinned Node v22.21.1: - `gulp prePublishNonBundle` (core and API compilation) - `npm run test:unittests -- --grep 'Environments extension telemetry|Startup Telemetry'` - 33 passing tests - ESLint and Prettier checks on all eight changed files - CRLF-aware whitespace check, preserving existing test-file line endings Coverage includes defaults, overridden values, scope combinations, multi-root and empty workspaces, setting addition/removal, unrelated changes, listener disposal, and trusted/untrusted startup payloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 70f695e commit 015102f

8 files changed

Lines changed: 368 additions & 4 deletions

File tree

‎src/client/envExt/telemetry.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { IDisposableRegistry } from '../common/types';
5+
import { getConfiguration, getWorkspaceFolders, onDidChangeConfiguration } from '../common/vscodeApis/workspaceApis';
6+
import { sendTelemetryEvent } from '../telemetry';
7+
import { EventName } from '../telemetry/constants';
8+
import { EnvsExplicitFalseScope } from '../telemetry/types';
9+
10+
/**
11+
* Reports which scope kinds explicitly configure python.useEnvironmentsExtension to false.
12+
* Defaults and precedence are not applied: overridden false values are still reported.
13+
* Multiple folders count as one scope kind; no setting values or paths are changed.
14+
*/
15+
export function getEnvsExplicitFalseScope(): EnvsExplicitFalseScope {
16+
const folders = getWorkspaceFolders() ?? [];
17+
const inspection = getConfiguration('python', folders[0]?.uri).inspect<boolean>('useEnvironmentsExtension');
18+
const scopes: EnvsExplicitFalseScope[] = [];
19+
if (inspection?.globalValue === false) {
20+
scopes.push('user');
21+
}
22+
if (inspection?.workspaceValue === false) {
23+
scopes.push('workspace');
24+
}
25+
if (
26+
folders.some(
27+
(folder) =>
28+
getConfiguration('python', folder.uri).inspect<boolean>('useEnvironmentsExtension')
29+
?.workspaceFolderValue === false,
30+
)
31+
) {
32+
scopes.push('folder');
33+
}
34+
return scopes.length > 1 ? 'multiple' : scopes[0] ?? 'none';
35+
}
36+
37+
/**
38+
* Reports the current explicit-false scopes when the integration setting changes.
39+
* Registers the listener in disposables without changing the cached integration decision.
40+
* A notification reports configuration, not necessarily an intentional user opt-out.
41+
*/
42+
export function registerEnvironmentsExtensionTelemetry(disposables: IDisposableRegistry): void {
43+
disposables.push(
44+
onDidChangeConfiguration((event) => {
45+
if (event.affectsConfiguration('python.useEnvironmentsExtension')) {
46+
sendTelemetryEvent(EventName.ENVIRONMENTS_EXTENSION_SETTING_CHANGED, undefined, {
47+
envsExplicitFalseScope: getEnvsExplicitFalseScope(),
48+
});
49+
}
50+
}),
51+
);
52+
}

‎src/client/extensionActivation.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { registerTriggerForTerminalREPL } from './terminals/codeExecution/termin
4848
import { registerPythonStartup } from './terminals/pythonStartup';
4949
import { registerPixiFeatures } from './pythonEnvironments/common/environmentManagers/pixi';
5050
import { registerCustomTerminalLinkProvider } from './terminals/pythonStartupLinkProvider';
51+
import { registerEnvironmentsExtensionTelemetry } from './envExt/telemetry';
5152

5253
export async function activateComponents(
5354
// `ext` is passed to any extra activation funcs.
@@ -84,6 +85,7 @@ export async function activateComponents(
8485
}
8586

8687
export function activateFeatures(ext: ExtensionState, _components: Components): void {
88+
registerEnvironmentsExtensionTelemetry(ext.disposables);
8789
const interpreterQuickPick: IInterpreterQuickPick = ext.legacyIOC.serviceContainer.get<IInterpreterQuickPick>(
8890
IInterpreterQuickPick,
8991
);

‎src/client/startupTelemetry.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { EventName } from './telemetry/constants';
1717
import { EditorLoadTelemetry } from './telemetry/types';
1818
import { IStartupDurations } from './types';
1919
import { useEnvExtension } from './envExt/api.internal';
20+
import { getEnvsExplicitFalseScope } from './envExt/telemetry';
2021

2122
export async function sendStartupTelemetry(
2223
activatedPromise: Promise<any>,
@@ -93,7 +94,12 @@ async function getActivationTelemetryProps(
9394
const terminalHelper = serviceContainer.get<ITerminalHelper>(ITerminalHelper);
9495
const terminalShellType = terminalHelper.identifyTerminalShell();
9596
if (!workspaceService.isTrusted) {
96-
return { workspaceFolderCount, terminal: terminalShellType, isFirstSession };
97+
return {
98+
workspaceFolderCount,
99+
terminal: terminalShellType,
100+
isFirstSession,
101+
envsExplicitFalseScope: getEnvsExplicitFalseScope(),
102+
};
97103
}
98104
const interpreterService = serviceContainer.get<IInterpreterService>(IInterpreterService);
99105
const mainWorkspaceUri = workspaceService.workspaceFolders?.length
@@ -150,5 +156,6 @@ async function getActivationTelemetryProps(
150156
appName,
151157
isFirstSession,
152158
usingEnvironmentsExtension,
159+
envsExplicitFalseScope: getEnvsExplicitFalseScope(),
153160
};
154161
}

‎src/client/telemetry/constants.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
export enum EventName {
77
FORMAT_ON_TYPE = 'FORMAT.FORMAT_ON_TYPE',
88
EDITOR_LOAD = 'EDITOR.LOAD',
9+
ENVIRONMENTS_EXTENSION_SETTING_CHANGED = 'ENVIRONMENTS_EXTENSION.SETTING_CHANGED',
910
REPL = 'REPL',
1011
INVOKE_TOOL = 'INVOKE_TOOL',
1112
CREATE_NEW_FILE_COMMAND = 'CREATE_NEW_FILE_COMMAND',

‎src/client/telemetry/index.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { EnvironmentType, PythonEnvironment } from '../pythonEnvironments/info';
1313
import { TensorBoardPromptSelection } from '../tensorBoard/constants';
1414
import { EventName } from './constants';
1515
import type { UnitTestRunFailureCategory } from './constants';
16-
import type { TestTool } from './types';
16+
import type { EnvsExplicitFalseScope, TestTool } from './types';
1717

1818
/**
1919
* Checks whether telemetry is supported.
@@ -359,7 +359,8 @@ export interface IEventNamePropertyMapping {
359359
"usinguserdefinedinterpreter" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "luabud" },
360360
"usingglobalinterpreter" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "luabud" },
361361
"isfirstsession" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "luabud" },
362-
"usingenvironmentsextension" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" }
362+
"usingenvironmentsextension" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eduardovil" },
363+
"envsexplicitfalsescope" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
363364
}
364365
*/
365366
[EventName.EDITOR_LOAD]: {
@@ -410,6 +411,23 @@ export interface IEventNamePropertyMapping {
410411
* If user has enabled the Python Environments extension integration
411412
*/
412413
usingEnvironmentsExtension?: boolean;
414+
/**
415+
* Scope kinds explicitly setting python.useEnvironmentsExtension to false, even if overridden.
416+
* 'none' excludes defaults; 'multiple' means more than one of user, workspace, or folder.
417+
*/
418+
envsExplicitFalseScope?: EnvsExplicitFalseScope;
419+
};
420+
/**
421+
* Reports explicit-false scopes after a python.useEnvironmentsExtension configuration change.
422+
* This is a configuration notification, not proof of user intent or a changed integration decision.
423+
*/
424+
/* __GDPR__
425+
"environments_extension.setting_changed" : {
426+
"envsexplicitfalsescope" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
427+
}
428+
*/
429+
[EventName.ENVIRONMENTS_EXTENSION_SETTING_CHANGED]: {
430+
envsExplicitFalseScope: EnvsExplicitFalseScope;
413431
};
414432
/**
415433
* Telemetry event sent when substituting Environment variables to calculate value of variables

‎src/client/telemetry/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { EventName } from './constants';
88

99
export type EditorLoadTelemetry = IEventNamePropertyMapping[EventName.EDITOR_LOAD];
1010

11+
export type EnvsExplicitFalseScope = 'none' | 'user' | 'workspace' | 'folder' | 'multiple';
12+
1113
export type PythonInterpreterTelemetry = IEventNamePropertyMapping[EventName.PYTHON_INTERPRETER];
1214
export type TestTool = 'pytest' | 'unittest';
1315
export type TestRunTelemetry = IEventNamePropertyMapping[EventName.UNITTEST_RUN];
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { assert } from 'chai';
5+
import * as path from 'path';
6+
import * as sinon from 'sinon';
7+
import * as typemoq from 'typemoq';
8+
import { ConfigurationChangeEvent, EventEmitter, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode';
9+
import { IDisposableRegistry } from '../../client/common/types';
10+
import * as workspaceApis from '../../client/common/vscodeApis/workspaceApis';
11+
import { getEnvsExplicitFalseScope, registerEnvironmentsExtensionTelemetry } from '../../client/envExt/telemetry';
12+
import * as telemetry from '../../client/telemetry';
13+
import { EventName } from '../../client/telemetry/constants';
14+
import { EnvsExplicitFalseScope } from '../../client/telemetry/types';
15+
16+
type Inspection = {
17+
defaultValue?: boolean;
18+
globalValue?: boolean;
19+
workspaceValue?: boolean;
20+
workspaceFolderValue?: boolean;
21+
};
22+
23+
function configuration(values?: Inspection): WorkspaceConfiguration {
24+
const config = typemoq.Mock.ofType<WorkspaceConfiguration>();
25+
config
26+
.setup((c) => c.inspect<boolean>('useEnvironmentsExtension'))
27+
.returns(() => (values ? { key: 'python.useEnvironmentsExtension', ...values } : undefined));
28+
return config.object;
29+
}
30+
31+
suite('Environments extension telemetry', () => {
32+
const first: WorkspaceFolder = {
33+
name: 'first',
34+
index: 0,
35+
uri: Uri.file(path.join(process.cwd(), 'first')),
36+
};
37+
const second: WorkspaceFolder = {
38+
name: 'second',
39+
index: 1,
40+
uri: Uri.file(path.join(process.cwd(), 'second')),
41+
};
42+
let getConfiguration: sinon.SinonStub;
43+
let getWorkspaceFolders: sinon.SinonStub;
44+
let sendTelemetryEvent: sinon.SinonStub;
45+
let changes: EventEmitter<ConfigurationChangeEvent>;
46+
let disposables: IDisposableRegistry;
47+
48+
setup(() => {
49+
getConfiguration = sinon.stub(workspaceApis, 'getConfiguration').returns(configuration({}));
50+
getWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined);
51+
sendTelemetryEvent = sinon.stub(telemetry, 'sendTelemetryEvent');
52+
changes = new EventEmitter<ConfigurationChangeEvent>();
53+
sinon.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((listener) => changes.event(listener));
54+
disposables = [];
55+
});
56+
57+
teardown(() => {
58+
disposables.forEach((disposable) => disposable.dispose());
59+
changes.dispose();
60+
sinon.restore();
61+
});
62+
63+
const scopeCases: { inspection: Inspection; expected: EnvsExplicitFalseScope }[] = [
64+
{ inspection: {}, expected: 'none' },
65+
{ inspection: { globalValue: false }, expected: 'user' },
66+
{ inspection: { workspaceValue: false }, expected: 'workspace' },
67+
{ inspection: { workspaceFolderValue: false }, expected: 'folder' },
68+
{ inspection: { globalValue: false, workspaceValue: false }, expected: 'multiple' },
69+
{ inspection: { globalValue: false, workspaceFolderValue: false }, expected: 'multiple' },
70+
{ inspection: { workspaceValue: false, workspaceFolderValue: false }, expected: 'multiple' },
71+
{
72+
inspection: { globalValue: false, workspaceValue: false, workspaceFolderValue: false },
73+
expected: 'multiple',
74+
},
75+
];
76+
scopeCases.forEach(({ inspection, expected }) => {
77+
test(`reports ${expected} for ${JSON.stringify(inspection)}`, () => {
78+
getConfiguration.returns(configuration(inspection));
79+
getWorkspaceFolders.returns([first]);
80+
81+
assert.strictEqual(getEnvsExplicitFalseScope(), expected);
82+
sinon.assert.alwaysCalledWithExactly(getConfiguration, 'python', first.uri);
83+
});
84+
});
85+
86+
[undefined, false, true].forEach((defaultValue) => {
87+
test(`ignores the default value ${defaultValue}`, () => {
88+
getConfiguration.returns(configuration({ defaultValue }));
89+
90+
assert.strictEqual(getEnvsExplicitFalseScope(), 'none');
91+
});
92+
});
93+
94+
test('handles unavailable setting inspection', () => {
95+
getConfiguration.returns(configuration());
96+
97+
assert.strictEqual(getEnvsExplicitFalseScope(), 'none');
98+
});
99+
100+
test('does not count explicit true settings as opt-outs', () => {
101+
getWorkspaceFolders.returns([first]);
102+
getConfiguration.returns(
103+
configuration({ defaultValue: false, globalValue: true, workspaceValue: true, workspaceFolderValue: true }),
104+
);
105+
106+
assert.strictEqual(getEnvsExplicitFalseScope(), 'none');
107+
});
108+
109+
test('reports a user false even when higher-precedence values are true', () => {
110+
getWorkspaceFolders.returns([first]);
111+
getConfiguration.returns(
112+
configuration({ globalValue: false, workspaceValue: true, workspaceFolderValue: true }),
113+
);
114+
115+
assert.strictEqual(getEnvsExplicitFalseScope(), 'user');
116+
});
117+
118+
test('checks later folders with their own configuration scope', () => {
119+
getWorkspaceFolders.returns([first, second]);
120+
getConfiguration.withArgs('python', first.uri).returns(configuration({ workspaceFolderValue: true }));
121+
getConfiguration.withArgs('python', second.uri).returns(configuration({ workspaceFolderValue: false }));
122+
123+
assert.strictEqual(getEnvsExplicitFalseScope(), 'folder');
124+
sinon.assert.calledWithExactly(getConfiguration, 'python', second.uri);
125+
});
126+
127+
test('multiple false folders are one scope kind', () => {
128+
getWorkspaceFolders.returns([first, second]);
129+
getConfiguration.returns(configuration({ workspaceFolderValue: false }));
130+
131+
assert.strictEqual(getEnvsExplicitFalseScope(), 'folder');
132+
});
133+
134+
test('inspects user and workspace settings with no folders', () => {
135+
getConfiguration.returns(configuration({ globalValue: false, workspaceValue: false }));
136+
137+
assert.strictEqual(getEnvsExplicitFalseScope(), 'multiple');
138+
sinon.assert.calledOnceWithExactly(getConfiguration, 'python', undefined);
139+
});
140+
141+
test('registration does not emit a configuration-change event', () => {
142+
registerEnvironmentsExtensionTelemetry(disposables);
143+
144+
assert.lengthOf(disposables, 1);
145+
sinon.assert.notCalled(sendTelemetryEvent);
146+
});
147+
148+
test('ignores unrelated configuration changes', () => {
149+
registerEnvironmentsExtensionTelemetry(disposables);
150+
changes.fire({ affectsConfiguration: (section) => section === 'python.defaultInterpreterPath' });
151+
152+
sinon.assert.notCalled(sendTelemetryEvent);
153+
sinon.assert.notCalled(getConfiguration);
154+
});
155+
156+
test('emits the current scope when false is added, moved, and removed', () => {
157+
registerEnvironmentsExtensionTelemetry(disposables);
158+
const event: ConfigurationChangeEvent = {
159+
affectsConfiguration: (section) => section === 'python.useEnvironmentsExtension',
160+
};
161+
getConfiguration.returns(configuration({ globalValue: false }));
162+
changes.fire(event);
163+
getConfiguration.returns(configuration({ workspaceValue: false }));
164+
changes.fire(event);
165+
getConfiguration.returns(configuration({ workspaceValue: true }));
166+
changes.fire(event);
167+
168+
assert.deepEqual(
169+
sendTelemetryEvent.getCalls().map((call) => call.args),
170+
['user', 'workspace', 'none'].map((envsExplicitFalseScope) => [
171+
EventName.ENVIRONMENTS_EXTENSION_SETTING_CHANGED,
172+
undefined,
173+
{ envsExplicitFalseScope },
174+
]),
175+
);
176+
});
177+
178+
test('uses the current workspace folders on configuration changes', () => {
179+
registerEnvironmentsExtensionTelemetry(disposables);
180+
getWorkspaceFolders.returns([second]);
181+
getConfiguration.withArgs('python', second.uri).returns(configuration({ workspaceFolderValue: false }));
182+
changes.fire({ affectsConfiguration: () => true });
183+
184+
sinon.assert.calledOnceWithExactly(
185+
sendTelemetryEvent,
186+
EventName.ENVIRONMENTS_EXTENSION_SETTING_CHANGED,
187+
undefined,
188+
{ envsExplicitFalseScope: 'folder' },
189+
);
190+
});
191+
192+
test('stops reporting after disposal', () => {
193+
registerEnvironmentsExtensionTelemetry(disposables);
194+
disposables.forEach((disposable) => disposable.dispose());
195+
changes.fire({ affectsConfiguration: () => true });
196+
197+
sinon.assert.notCalled(sendTelemetryEvent);
198+
});
199+
});

0 commit comments

Comments
 (0)