Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
};
}
56 changes: 55 additions & 1 deletion src/common/telemetry/helpers.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<void> {
const projects = pm.getProjects();

// 1. Total project count
const totalProjectCount = projects.length;

// 2. Unique interpreter count
const interpreterPaths = new Set<string>();
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,
});
}
3 changes: 2 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -465,6 +465,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
sendTelemetryEvent(EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION, start.elapsedTime);
await terminalManager.initialize(api);
sendManagerSelectionTelemetry(projectManager);
await sendProjectStructureTelemetry(projectManager, envManagers);
});

sendTelemetryEvent(EventNames.EXTENSION_ACTIVATION_DURATION, start.elapsedTime);
Expand Down
264 changes: 264 additions & 0 deletions src/test/common/telemetry/helpers.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
import assert from 'node:assert';
import * as sinon from 'sinon';
import { Uri } from 'vscode';
import { PythonEnvironment, PythonProject } from '../../../api';
import { sendProjectStructureTelemetry } from '../../../common/telemetry/helpers';
import { EventNames } from '../../../common/telemetry/constants';
import * as sender from '../../../common/telemetry/sender';
import * as workspaceApis from '../../../common/workspace.apis';
import { EnvironmentManagers, PythonProjectManager } from '../../../internal.api';

suite('Telemetry Helpers', () => {
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',
);
});
});
});