Skip to content

Commit 37e216e

Browse files
Add telemetry for project structure metrics at extension startup (microsoft#1121)
co-authored with copilot Collects workspace project structure patterns at extension activation: project count, unique interpreter count, and nested project count. No paths are transmitted, only aggregated counts. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 9ff1f2a commit 37e216e

File tree

4 files changed

+342
-2
lines changed

4 files changed

+342
-2
lines changed

src/common/telemetry/constants.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export enum EventNames {
1919
* - triggeredLocation: string (where the create command is called from)
2020
*/
2121
CREATE_ENVIRONMENT = 'CREATE_ENVIRONMENT',
22+
/**
23+
* Telemetry event for project structure metrics at extension startup.
24+
* Properties:
25+
* - totalProjectCount: number (total number of projects)
26+
* - uniqueInterpreterCount: number (count of distinct interpreter paths)
27+
* - projectUnderRoot: number (count of projects nested under workspace roots)
28+
*/
29+
PROJECT_STRUCTURE = 'PROJECT_STRUCTURE',
2230
}
2331

2432
// Map all events to their properties
@@ -120,4 +128,17 @@ export interface IEventNamePropertyMapping {
120128
manager: string;
121129
triggeredLocation: string;
122130
};
131+
132+
/* __GDPR__
133+
"project_structure": {
134+
"totalProjectCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
135+
"uniqueInterpreterCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
136+
"projectUnderRoot": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
137+
}
138+
*/
139+
[EventNames.PROJECT_STRUCTURE]: {
140+
totalProjectCount: number;
141+
uniqueInterpreterCount: number;
142+
projectUnderRoot: number;
143+
};
123144
}

src/common/telemetry/helpers.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getDefaultEnvManagerSetting, getDefaultPkgManagerSetting } from '../../features/settings/settingHelpers';
2-
import { PythonProjectManager } from '../../internal.api';
2+
import { EnvironmentManagers, PythonProjectManager } from '../../internal.api';
3+
import { getWorkspaceFolders } from '../workspace.apis';
34
import { EventNames } from './constants';
45
import { sendTelemetryEvent } from './sender';
56

@@ -26,3 +27,56 @@ export function sendManagerSelectionTelemetry(pm: PythonProjectManager) {
2627
sendTelemetryEvent(EventNames.PACKAGE_MANAGER_SELECTED, undefined, { managerId: pkg });
2728
});
2829
}
30+
31+
export async function sendProjectStructureTelemetry(
32+
pm: PythonProjectManager,
33+
envManagers: EnvironmentManagers,
34+
): Promise<void> {
35+
const projects = pm.getProjects();
36+
37+
// 1. Total project count
38+
const totalProjectCount = projects.length;
39+
40+
// 2. Unique interpreter count
41+
const interpreterPaths = new Set<string>();
42+
for (const project of projects) {
43+
try {
44+
const env = await envManagers.getEnvironment(project.uri);
45+
if (env?.environmentPath) {
46+
interpreterPaths.add(env.environmentPath.fsPath);
47+
}
48+
} catch {
49+
// Ignore errors when getting environment for a project
50+
}
51+
}
52+
const uniqueInterpreterCount = interpreterPaths.size;
53+
54+
// 3. Projects under workspace root count
55+
const workspaceFolders = getWorkspaceFolders() ?? [];
56+
let projectUnderRoot = 0;
57+
for (const project of projects) {
58+
for (const wsFolder of workspaceFolders) {
59+
const workspacePath = wsFolder.uri.fsPath;
60+
const projectPath = project.uri.fsPath;
61+
62+
// Check if project is a subdirectory of workspace folder:
63+
// - Path must start with workspace path
64+
// - Path must not be equal to workspace path
65+
// - The character after workspace path must be a path separator
66+
if (
67+
projectPath !== workspacePath &&
68+
projectPath.startsWith(workspacePath) &&
69+
(projectPath[workspacePath.length] === '/' || projectPath[workspacePath.length] === '\\')
70+
) {
71+
projectUnderRoot++;
72+
break; // Count each project only once even if under multiple workspace folders
73+
}
74+
}
75+
}
76+
77+
sendTelemetryEvent(EventNames.PROJECT_STRUCTURE, undefined, {
78+
totalProjectCount,
79+
uniqueInterpreterCount,
80+
projectUnderRoot,
81+
});
82+
}

src/extension.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { clearPersistentState, setPersistentState } from './common/persistentSta
77
import { newProjectSelection } from './common/pickers/managers';
88
import { StopWatch } from './common/stopWatch';
99
import { EventNames } from './common/telemetry/constants';
10-
import { sendManagerSelectionTelemetry } from './common/telemetry/helpers';
10+
import { sendManagerSelectionTelemetry, sendProjectStructureTelemetry } from './common/telemetry/helpers';
1111
import { sendTelemetryEvent } from './common/telemetry/sender';
1212
import { createDeferred } from './common/utils/deferred';
1313

@@ -465,6 +465,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
465465
sendTelemetryEvent(EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION, start.elapsedTime);
466466
await terminalManager.initialize(api);
467467
sendManagerSelectionTelemetry(projectManager);
468+
await sendProjectStructureTelemetry(projectManager, envManagers);
468469
});
469470

470471
sendTelemetryEvent(EventNames.EXTENSION_ACTIVATION_DURATION, start.elapsedTime);
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import assert from 'node:assert';
2+
import * as sinon from 'sinon';
3+
import { Uri } from 'vscode';
4+
import { PythonEnvironment, PythonProject } from '../../../api';
5+
import { sendProjectStructureTelemetry } from '../../../common/telemetry/helpers';
6+
import { EventNames } from '../../../common/telemetry/constants';
7+
import * as sender from '../../../common/telemetry/sender';
8+
import * as workspaceApis from '../../../common/workspace.apis';
9+
import { EnvironmentManagers, PythonProjectManager } from '../../../internal.api';
10+
11+
suite('Telemetry Helpers', () => {
12+
suite('sendProjectStructureTelemetry', () => {
13+
let sendTelemetryEventStub: sinon.SinonStub;
14+
let getWorkspaceFoldersStub: sinon.SinonStub;
15+
let mockProjectManager: PythonProjectManager;
16+
let mockEnvManagers: EnvironmentManagers;
17+
18+
setup(() => {
19+
sendTelemetryEventStub = sinon.stub(sender, 'sendTelemetryEvent');
20+
getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders');
21+
});
22+
23+
teardown(() => {
24+
sinon.restore();
25+
});
26+
27+
test('should send telemetry with correct totalProjectCount', async () => {
28+
// Mock
29+
const projects: PythonProject[] = [
30+
{ name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject,
31+
{ name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject,
32+
{ name: 'project3', uri: Uri.file('/other/project3') } as PythonProject,
33+
];
34+
35+
mockProjectManager = {
36+
getProjects: () => projects,
37+
} as unknown as PythonProjectManager;
38+
39+
mockEnvManagers = {
40+
getEnvironment: sinon.stub().resolves(undefined),
41+
} as unknown as EnvironmentManagers;
42+
43+
getWorkspaceFoldersStub.returns([]);
44+
45+
// Run
46+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
47+
48+
// Assert
49+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
50+
const call = sendTelemetryEventStub.firstCall;
51+
assert.strictEqual(call.args[0], EventNames.PROJECT_STRUCTURE);
52+
assert.strictEqual(call.args[2].totalProjectCount, 3);
53+
});
54+
55+
test('should send telemetry with correct uniqueInterpreterCount', async () => {
56+
// Mock
57+
const projects: PythonProject[] = [
58+
{ name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject,
59+
{ name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject,
60+
{ name: 'project3', uri: Uri.file('/other/project3') } as PythonProject,
61+
];
62+
63+
mockProjectManager = {
64+
getProjects: () => projects,
65+
} as unknown as PythonProjectManager;
66+
67+
const env1 = { environmentPath: Uri.file('/path/to/python1') } as PythonEnvironment;
68+
const env2 = { environmentPath: Uri.file('/path/to/python2') } as PythonEnvironment;
69+
const env3 = { environmentPath: Uri.file('/path/to/python1') } as PythonEnvironment; // Same as env1
70+
71+
const getEnvironmentStub = sinon.stub();
72+
getEnvironmentStub.withArgs(projects[0].uri).resolves(env1);
73+
getEnvironmentStub.withArgs(projects[1].uri).resolves(env2);
74+
getEnvironmentStub.withArgs(projects[2].uri).resolves(env3);
75+
76+
mockEnvManagers = {
77+
getEnvironment: getEnvironmentStub,
78+
} as unknown as EnvironmentManagers;
79+
80+
getWorkspaceFoldersStub.returns([]);
81+
82+
// Run
83+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
84+
85+
// Assert
86+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
87+
const call = sendTelemetryEventStub.firstCall;
88+
assert.strictEqual(call.args[2].uniqueInterpreterCount, 2, 'Should have 2 unique interpreters');
89+
});
90+
91+
test('should send telemetry with correct projectUnderRoot count', async () => {
92+
// Mock
93+
const projects: PythonProject[] = [
94+
{ name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject, // Under root
95+
{ name: 'project2', uri: Uri.file('/workspace/subfolder/project2') } as PythonProject, // Under root
96+
{ name: 'workspace', uri: Uri.file('/workspace') } as PythonProject, // Equal to root, not counted
97+
{ name: 'project3', uri: Uri.file('/other/project3') } as PythonProject, // Not under root
98+
];
99+
100+
mockProjectManager = {
101+
getProjects: () => projects,
102+
} as unknown as PythonProjectManager;
103+
104+
mockEnvManagers = {
105+
getEnvironment: sinon.stub().resolves(undefined),
106+
} as unknown as EnvironmentManagers;
107+
108+
getWorkspaceFoldersStub.returns([{ uri: Uri.file('/workspace'), name: 'workspace' }]);
109+
110+
// Run
111+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
112+
113+
// Assert
114+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
115+
const call = sendTelemetryEventStub.firstCall;
116+
assert.strictEqual(call.args[2].projectUnderRoot, 2, 'Should count 2 projects under workspace root');
117+
});
118+
119+
test('should handle projects with no environments', async () => {
120+
// Mock
121+
const projects: PythonProject[] = [
122+
{ name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject,
123+
{ name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject,
124+
];
125+
126+
mockProjectManager = {
127+
getProjects: () => projects,
128+
} as unknown as PythonProjectManager;
129+
130+
mockEnvManagers = {
131+
getEnvironment: sinon.stub().resolves(undefined),
132+
} as unknown as EnvironmentManagers;
133+
134+
getWorkspaceFoldersStub.returns([]);
135+
136+
// Run
137+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
138+
139+
// Assert
140+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
141+
const call = sendTelemetryEventStub.firstCall;
142+
assert.strictEqual(call.args[2].uniqueInterpreterCount, 0, 'Should have 0 interpreters');
143+
});
144+
145+
test('should handle getEnvironment errors gracefully', async () => {
146+
// Mock
147+
const projects: PythonProject[] = [
148+
{ name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject,
149+
{ name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject,
150+
];
151+
152+
mockProjectManager = {
153+
getProjects: () => projects,
154+
} as unknown as PythonProjectManager;
155+
156+
const getEnvironmentStub = sinon.stub();
157+
getEnvironmentStub.withArgs(projects[0].uri).rejects(new Error('Failed to get environment'));
158+
getEnvironmentStub.withArgs(projects[1].uri).resolves({
159+
environmentPath: Uri.file('/path/to/python'),
160+
} as PythonEnvironment);
161+
162+
mockEnvManagers = {
163+
getEnvironment: getEnvironmentStub,
164+
} as unknown as EnvironmentManagers;
165+
166+
getWorkspaceFoldersStub.returns([]);
167+
168+
// Run
169+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
170+
171+
// Assert
172+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
173+
const call = sendTelemetryEventStub.firstCall;
174+
assert.strictEqual(
175+
call.args[2].uniqueInterpreterCount,
176+
1,
177+
'Should count only the successful environment',
178+
);
179+
});
180+
181+
test('should handle empty projects list', async () => {
182+
// Mock
183+
mockProjectManager = {
184+
getProjects: () => [],
185+
} as unknown as PythonProjectManager;
186+
187+
mockEnvManagers = {
188+
getEnvironment: sinon.stub().resolves(undefined),
189+
} as unknown as EnvironmentManagers;
190+
191+
getWorkspaceFoldersStub.returns([]);
192+
193+
// Run
194+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
195+
196+
// Assert
197+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
198+
const call = sendTelemetryEventStub.firstCall;
199+
assert.strictEqual(call.args[2].totalProjectCount, 0);
200+
assert.strictEqual(call.args[2].uniqueInterpreterCount, 0);
201+
assert.strictEqual(call.args[2].projectUnderRoot, 0);
202+
});
203+
204+
test('should handle multiple workspace folders', async () => {
205+
// Mock
206+
const projects: PythonProject[] = [
207+
{ name: 'project1', uri: Uri.file('/workspace1/project1') } as PythonProject, // Under workspace1
208+
{ name: 'project2', uri: Uri.file('/workspace2/project2') } as PythonProject, // Under workspace2
209+
{ name: 'project3', uri: Uri.file('/other/project3') } as PythonProject, // Not under any workspace
210+
];
211+
212+
mockProjectManager = {
213+
getProjects: () => projects,
214+
} as unknown as PythonProjectManager;
215+
216+
mockEnvManagers = {
217+
getEnvironment: sinon.stub().resolves(undefined),
218+
} as unknown as EnvironmentManagers;
219+
220+
getWorkspaceFoldersStub.returns([
221+
{ uri: Uri.file('/workspace1'), name: 'workspace1' },
222+
{ uri: Uri.file('/workspace2'), name: 'workspace2' },
223+
]);
224+
225+
// Run
226+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
227+
228+
// Assert
229+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
230+
const call = sendTelemetryEventStub.firstCall;
231+
assert.strictEqual(call.args[2].projectUnderRoot, 2, 'Should count 2 projects under workspace roots');
232+
});
233+
234+
test('should not count projects with path prefix that are not actually nested', async () => {
235+
// Mock - Test edge case where path starts with workspace path but is not nested
236+
const projects: PythonProject[] = [
237+
{ name: 'workspace', uri: Uri.file('/workspace') } as PythonProject, // Equal to root
238+
{ name: 'workspace2', uri: Uri.file('/workspace2') } as PythonProject, // Starts with prefix but not nested
239+
];
240+
241+
mockProjectManager = {
242+
getProjects: () => projects,
243+
} as unknown as PythonProjectManager;
244+
245+
mockEnvManagers = {
246+
getEnvironment: sinon.stub().resolves(undefined),
247+
} as unknown as EnvironmentManagers;
248+
249+
getWorkspaceFoldersStub.returns([{ uri: Uri.file('/workspace'), name: 'workspace' }]);
250+
251+
// Run
252+
await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers);
253+
254+
// Assert
255+
assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once');
256+
const call = sendTelemetryEventStub.firstCall;
257+
assert.strictEqual(
258+
call.args[2].projectUnderRoot,
259+
0,
260+
'Should not count projects that are not actually nested',
261+
);
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)