From 560f93c5285948123eb578f789f58381596faeb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 04:33:19 +0000 Subject: [PATCH 1/2] Initial plan From 5021fd10cc381be9c14e1d2adb6353cb2e2c2d2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 04:39:47 +0000 Subject: [PATCH 2/2] Add telemetry for project structure metrics - Add PROJECT_STRUCTURE event to telemetry constants with GDPR annotations - Implement sendProjectStructureTelemetry function to collect: - totalProjectCount: total number of projects in workspace - uniqueInterpreterCount: count of distinct Python interpreters - projectUnderRoot: count of projects nested under workspace roots - Call telemetry function at extension startup in extension.ts - Add comprehensive unit tests covering all scenarios - Fix path matching logic to properly detect nested projects Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> --- src/common/telemetry/constants.ts | 21 ++ src/common/telemetry/helpers.ts | 56 +++- src/extension.ts | 3 +- .../common/telemetry/helpers.unit.test.ts | 264 ++++++++++++++++++ 4 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 src/test/common/telemetry/helpers.unit.test.ts diff --git a/src/common/telemetry/constants.ts b/src/common/telemetry/constants.ts index d9ae5d9f..efd58ca6 100644 --- a/src/common/telemetry/constants.ts +++ b/src/common/telemetry/constants.ts @@ -19,6 +19,14 @@ export enum EventNames { * - triggeredLocation: string (where the create command is called from) */ CREATE_ENVIRONMENT = 'CREATE_ENVIRONMENT', + /** + * Telemetry event for project structure metrics at extension startup. + * Properties: + * - totalProjectCount: number (total number of projects) + * - uniqueInterpreterCount: number (count of distinct interpreter paths) + * - projectUnderRoot: number (count of projects nested under workspace roots) + */ + PROJECT_STRUCTURE = 'PROJECT_STRUCTURE', } // Map all events to their properties @@ -120,4 +128,17 @@ export interface IEventNamePropertyMapping { manager: string; triggeredLocation: string; }; + + /* __GDPR__ + "project_structure": { + "totalProjectCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }, + "uniqueInterpreterCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }, + "projectUnderRoot": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" } + } + */ + [EventNames.PROJECT_STRUCTURE]: { + totalProjectCount: number; + uniqueInterpreterCount: number; + projectUnderRoot: number; + }; } diff --git a/src/common/telemetry/helpers.ts b/src/common/telemetry/helpers.ts index d2a43c6d..8a226d0f 100644 --- a/src/common/telemetry/helpers.ts +++ b/src/common/telemetry/helpers.ts @@ -1,5 +1,6 @@ import { getDefaultEnvManagerSetting, getDefaultPkgManagerSetting } from '../../features/settings/settingHelpers'; -import { PythonProjectManager } from '../../internal.api'; +import { EnvironmentManagers, PythonProjectManager } from '../../internal.api'; +import { getWorkspaceFolders } from '../workspace.apis'; import { EventNames } from './constants'; import { sendTelemetryEvent } from './sender'; @@ -26,3 +27,56 @@ export function sendManagerSelectionTelemetry(pm: PythonProjectManager) { sendTelemetryEvent(EventNames.PACKAGE_MANAGER_SELECTED, undefined, { managerId: pkg }); }); } + +export async function sendProjectStructureTelemetry( + pm: PythonProjectManager, + envManagers: EnvironmentManagers, +): Promise { + const projects = pm.getProjects(); + + // 1. Total project count + const totalProjectCount = projects.length; + + // 2. Unique interpreter count + const interpreterPaths = new Set(); + for (const project of projects) { + try { + const env = await envManagers.getEnvironment(project.uri); + if (env?.environmentPath) { + interpreterPaths.add(env.environmentPath.fsPath); + } + } catch { + // Ignore errors when getting environment for a project + } + } + const uniqueInterpreterCount = interpreterPaths.size; + + // 3. Projects under workspace root count + const workspaceFolders = getWorkspaceFolders() ?? []; + let projectUnderRoot = 0; + for (const project of projects) { + for (const wsFolder of workspaceFolders) { + const workspacePath = wsFolder.uri.fsPath; + const projectPath = project.uri.fsPath; + + // Check if project is a subdirectory of workspace folder: + // - Path must start with workspace path + // - Path must not be equal to workspace path + // - The character after workspace path must be a path separator + if ( + projectPath !== workspacePath && + projectPath.startsWith(workspacePath) && + (projectPath[workspacePath.length] === '/' || projectPath[workspacePath.length] === '\\') + ) { + projectUnderRoot++; + break; // Count each project only once even if under multiple workspace folders + } + } + } + + sendTelemetryEvent(EventNames.PROJECT_STRUCTURE, undefined, { + totalProjectCount, + uniqueInterpreterCount, + projectUnderRoot, + }); +} diff --git a/src/extension.ts b/src/extension.ts index 817effb0..74ee761d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,7 +7,7 @@ import { clearPersistentState, setPersistentState } from './common/persistentSta import { newProjectSelection } from './common/pickers/managers'; import { StopWatch } from './common/stopWatch'; import { EventNames } from './common/telemetry/constants'; -import { sendManagerSelectionTelemetry } from './common/telemetry/helpers'; +import { sendManagerSelectionTelemetry, sendProjectStructureTelemetry } from './common/telemetry/helpers'; import { sendTelemetryEvent } from './common/telemetry/sender'; import { createDeferred } from './common/utils/deferred'; @@ -465,6 +465,7 @@ export async function activate(context: ExtensionContext): Promise { + suite('sendProjectStructureTelemetry', () => { + let sendTelemetryEventStub: sinon.SinonStub; + let getWorkspaceFoldersStub: sinon.SinonStub; + let mockProjectManager: PythonProjectManager; + let mockEnvManagers: EnvironmentManagers; + + setup(() => { + sendTelemetryEventStub = sinon.stub(sender, 'sendTelemetryEvent'); + getWorkspaceFoldersStub = sinon.stub(workspaceApis, 'getWorkspaceFolders'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('should send telemetry with correct totalProjectCount', async () => { + // Mock + const projects: PythonProject[] = [ + { name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject, + { name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject, + { name: 'project3', uri: Uri.file('/other/project3') } as PythonProject, + ]; + + mockProjectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + + mockEnvManagers = { + getEnvironment: sinon.stub().resolves(undefined), + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual(call.args[0], EventNames.PROJECT_STRUCTURE); + assert.strictEqual(call.args[2].totalProjectCount, 3); + }); + + test('should send telemetry with correct uniqueInterpreterCount', async () => { + // Mock + const projects: PythonProject[] = [ + { name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject, + { name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject, + { name: 'project3', uri: Uri.file('/other/project3') } as PythonProject, + ]; + + mockProjectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + + const env1 = { environmentPath: Uri.file('/path/to/python1') } as PythonEnvironment; + const env2 = { environmentPath: Uri.file('/path/to/python2') } as PythonEnvironment; + const env3 = { environmentPath: Uri.file('/path/to/python1') } as PythonEnvironment; // Same as env1 + + const getEnvironmentStub = sinon.stub(); + getEnvironmentStub.withArgs(projects[0].uri).resolves(env1); + getEnvironmentStub.withArgs(projects[1].uri).resolves(env2); + getEnvironmentStub.withArgs(projects[2].uri).resolves(env3); + + mockEnvManagers = { + getEnvironment: getEnvironmentStub, + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual(call.args[2].uniqueInterpreterCount, 2, 'Should have 2 unique interpreters'); + }); + + test('should send telemetry with correct projectUnderRoot count', async () => { + // Mock + const projects: PythonProject[] = [ + { name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject, // Under root + { name: 'project2', uri: Uri.file('/workspace/subfolder/project2') } as PythonProject, // Under root + { name: 'workspace', uri: Uri.file('/workspace') } as PythonProject, // Equal to root, not counted + { name: 'project3', uri: Uri.file('/other/project3') } as PythonProject, // Not under root + ]; + + mockProjectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + + mockEnvManagers = { + getEnvironment: sinon.stub().resolves(undefined), + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([{ uri: Uri.file('/workspace'), name: 'workspace' }]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual(call.args[2].projectUnderRoot, 2, 'Should count 2 projects under workspace root'); + }); + + test('should handle projects with no environments', async () => { + // Mock + const projects: PythonProject[] = [ + { name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject, + { name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject, + ]; + + mockProjectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + + mockEnvManagers = { + getEnvironment: sinon.stub().resolves(undefined), + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual(call.args[2].uniqueInterpreterCount, 0, 'Should have 0 interpreters'); + }); + + test('should handle getEnvironment errors gracefully', async () => { + // Mock + const projects: PythonProject[] = [ + { name: 'project1', uri: Uri.file('/workspace/project1') } as PythonProject, + { name: 'project2', uri: Uri.file('/workspace/project2') } as PythonProject, + ]; + + mockProjectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + + const getEnvironmentStub = sinon.stub(); + getEnvironmentStub.withArgs(projects[0].uri).rejects(new Error('Failed to get environment')); + getEnvironmentStub.withArgs(projects[1].uri).resolves({ + environmentPath: Uri.file('/path/to/python'), + } as PythonEnvironment); + + mockEnvManagers = { + getEnvironment: getEnvironmentStub, + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual( + call.args[2].uniqueInterpreterCount, + 1, + 'Should count only the successful environment', + ); + }); + + test('should handle empty projects list', async () => { + // Mock + mockProjectManager = { + getProjects: () => [], + } as unknown as PythonProjectManager; + + mockEnvManagers = { + getEnvironment: sinon.stub().resolves(undefined), + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual(call.args[2].totalProjectCount, 0); + assert.strictEqual(call.args[2].uniqueInterpreterCount, 0); + assert.strictEqual(call.args[2].projectUnderRoot, 0); + }); + + test('should handle multiple workspace folders', async () => { + // Mock + const projects: PythonProject[] = [ + { name: 'project1', uri: Uri.file('/workspace1/project1') } as PythonProject, // Under workspace1 + { name: 'project2', uri: Uri.file('/workspace2/project2') } as PythonProject, // Under workspace2 + { name: 'project3', uri: Uri.file('/other/project3') } as PythonProject, // Not under any workspace + ]; + + mockProjectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + + mockEnvManagers = { + getEnvironment: sinon.stub().resolves(undefined), + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([ + { uri: Uri.file('/workspace1'), name: 'workspace1' }, + { uri: Uri.file('/workspace2'), name: 'workspace2' }, + ]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual(call.args[2].projectUnderRoot, 2, 'Should count 2 projects under workspace roots'); + }); + + test('should not count projects with path prefix that are not actually nested', async () => { + // Mock - Test edge case where path starts with workspace path but is not nested + const projects: PythonProject[] = [ + { name: 'workspace', uri: Uri.file('/workspace') } as PythonProject, // Equal to root + { name: 'workspace2', uri: Uri.file('/workspace2') } as PythonProject, // Starts with prefix but not nested + ]; + + mockProjectManager = { + getProjects: () => projects, + } as unknown as PythonProjectManager; + + mockEnvManagers = { + getEnvironment: sinon.stub().resolves(undefined), + } as unknown as EnvironmentManagers; + + getWorkspaceFoldersStub.returns([{ uri: Uri.file('/workspace'), name: 'workspace' }]); + + // Run + await sendProjectStructureTelemetry(mockProjectManager, mockEnvManagers); + + // Assert + assert(sendTelemetryEventStub.calledOnce, 'sendTelemetryEvent should be called once'); + const call = sendTelemetryEventStub.firstCall; + assert.strictEqual( + call.args[2].projectUnderRoot, + 0, + 'Should not count projects that are not actually nested', + ); + }); + }); +});