Skip to content

Commit ac39905

Browse files
authored
Add testing performance telemetry (#25980)
helps to measure: #25978
1 parent 0e60b3c commit ac39905

15 files changed

Lines changed: 334 additions & 67 deletions

src/client/telemetry/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export enum EventName {
4848
UNITTEST_DISCOVERY_DONE = 'UNITTEST.DISCOVERY.DONE',
4949
UNITTEST_RUN_STOP = 'UNITTEST.RUN.STOP',
5050
UNITTEST_RUN = 'UNITTEST.RUN',
51+
UNITTEST_RUN_DONE = 'UNITTEST.RUN.DONE',
5152
UNITTEST_RUN_ALL_FAILED = 'UNITTEST.RUN_ALL_FAILED',
5253
UNITTEST_DISABLED = 'UNITTEST.DISABLED',
5354

@@ -101,6 +102,17 @@ export enum EventName {
101102
ENVIRONMENT_TERMINAL_GLOBAL_PIP = 'ENVIRONMENT.TERMINAL.GLOBAL_PIP',
102103
}
103104

105+
export const UNITTEST_RUN_FAILURE_CATEGORIES = [
106+
'pipe-cancelled',
107+
'subprocess-crash',
108+
'no-results',
109+
'env-mismatch',
110+
'cancelled',
111+
'unknown',
112+
] as const;
113+
114+
export type UnitTestRunFailureCategory = typeof UNITTEST_RUN_FAILURE_CATEGORIES[number];
115+
104116
export enum PlatformErrors {
105117
FailedToParseVersion = 'FailedToParseVersion',
106118
FailedToDetermineOS = 'FailedToDetermineOS',

src/client/telemetry/index.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { StopWatch } from '../common/utils/stopWatch';
1212
import { EnvironmentType, PythonEnvironment } from '../pythonEnvironments/info';
1313
import { TensorBoardPromptSelection } from '../tensorBoard/constants';
1414
import { EventName } from './constants';
15+
import type { UnitTestRunFailureCategory } from './constants';
1516
import type { TestTool } from './types';
1617

1718
/**
@@ -2165,7 +2166,12 @@ export interface IEventNamePropertyMapping {
21652166
/* __GDPR__
21662167
"unittest.discovery.done" : {
21672168
"tool" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2168-
"failed" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
2169+
"failed" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2170+
"mode" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2171+
"trigger" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2172+
"failureCategory" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "eleanorjboyd" },
2173+
"totalDurationMs" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "eleanorjboyd", "isMeasurement": true },
2174+
"testCount" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "eleanorjboyd", "isMeasurement": true }
21692175
}
21702176
*/
21712177
[EventName.UNITTEST_DISCOVERY_DONE]: {
@@ -2181,6 +2187,36 @@ export interface IEventNamePropertyMapping {
21812187
* @type {boolean}
21822188
*/
21832189
failed: boolean;
2190+
/**
2191+
* Testing architecture used for discovery:
2192+
* 'project' = per-project discovery through the Python Environments API;
2193+
* 'legacy' = workspace-level discovery through the existing WorkspaceTestAdapter.
2194+
*/
2195+
mode?: 'project' | 'legacy';
2196+
/**
2197+
* Source that triggered the discovery.
2198+
*/
2199+
trigger?: 'auto' | 'ui' | 'commandpalette' | 'watching' | 'interpreter';
2200+
/**
2201+
* Coarse failure category. Only populated when `failed` is true.
2202+
*/
2203+
failureCategory?:
2204+
| 'subprocess-exit-non-zero'
2205+
| 'pipe-error'
2206+
| 'pytest-collect-error'
2207+
| 'plugin-exception'
2208+
| 'timeout'
2209+
| 'env-resolution'
2210+
| 'cancelled'
2211+
| 'unknown';
2212+
/**
2213+
* Wall-clock duration of the discovery cycle in milliseconds.
2214+
*/
2215+
totalDurationMs?: number;
2216+
/**
2217+
* Number of test items discovered (leaf nodes).
2218+
*/
2219+
testCount?: number;
21842220
};
21852221
/**
21862222
* Telemetry event sent when cancelling discovering tests
@@ -2222,6 +2258,43 @@ export interface IEventNamePropertyMapping {
22222258
"unittest.run.all_failed" : { "owner": "eleanorjboyd" }
22232259
*/
22242260
[EventName.UNITTEST_RUN_ALL_FAILED]: never | undefined;
2261+
/**
2262+
* Telemetry event sent at the end of a test run, capturing duration and pipe health.
2263+
* Companion to UNITTEST_RUN (which is emitted at run start).
2264+
*/
2265+
/* __GDPR__
2266+
"unittest.run.done" : {
2267+
"tool" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2268+
"debugging" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2269+
"mode" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2270+
"failed" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
2271+
"failureCategory" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "eleanorjboyd" },
2272+
"durationMs" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "eleanorjboyd", "isMeasurement": true },
2273+
"requestedCount" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "eleanorjboyd", "isMeasurement": true }
2274+
}
2275+
*/
2276+
[EventName.UNITTEST_RUN_DONE]: {
2277+
tool: TestTool;
2278+
debugging: boolean;
2279+
mode: 'project' | 'legacy';
2280+
/**
2281+
* `true` if the run ended without reporting all requested results,
2282+
* or if the subprocess crashed / threw.
2283+
*/
2284+
failed: boolean;
2285+
/**
2286+
* Coarse failure category when `failed` is true.
2287+
*/
2288+
failureCategory?: UnitTestRunFailureCategory;
2289+
/**
2290+
* Wall-clock duration of the run in milliseconds.
2291+
*/
2292+
durationMs?: number;
2293+
/**
2294+
* Number of test items the user asked to run.
2295+
*/
2296+
requestedCount?: number;
2297+
};
22252298
/**
22262299
* Telemetry event sent when testing is disabled for a workspace.
22272300
*/

src/client/testing/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ export class UnitTestManagementService implements IExtensionActivationService {
223223
interpreterService.onDidChangeInterpreter(async () => {
224224
traceVerbose('Testing: Triggered refresh due to interpreter change.');
225225
sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_TRIGGER, undefined, { trigger: 'interpreter' });
226-
await this.testController?.refreshTestData(undefined, { forceRefresh: true });
226+
await this.testController?.refreshTestData(undefined, { forceRefresh: true, trigger: 'interpreter' });
227227
}),
228228
);
229229
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { StopWatch } from '../../../common/utils/stopWatch';
5+
6+
/** Source that requested a test discovery refresh. */
7+
export type DiscoveryTriggerKind = 'auto' | 'ui' | 'commandpalette' | 'watching' | 'interpreter';
8+
9+
/** Testing architecture used for discovery. */
10+
export type DiscoveryMode = 'project' | 'legacy';
11+
12+
export interface DiscoveryTelemetryCycle {
13+
mode: DiscoveryMode;
14+
trigger?: DiscoveryTriggerKind;
15+
stopWatch: StopWatch;
16+
}
17+
18+
export class DiscoveryTelemetryState {
19+
private activeCycle?: DiscoveryTelemetryCycle;
20+
21+
constructor(public readonly defaultMode: DiscoveryMode) {}
22+
23+
public start(context: Omit<DiscoveryTelemetryCycle, 'stopWatch'>): void {
24+
this.activeCycle = { ...context, stopWatch: new StopWatch() };
25+
}
26+
27+
public complete(): DiscoveryTelemetryCycle | undefined {
28+
const cycle = this.activeCycle;
29+
this.activeCycle = undefined;
30+
return cycle;
31+
}
32+
}

src/client/testing/testController/common/projectTestExecution.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { CancellationToken, FileCoverageDetail, TestItem, TestRun, TestRunProfil
55
import { traceError, traceInfo, traceVerbose, traceWarn } from '../../../logging';
66
import { sendTelemetryEvent } from '../../../telemetry';
77
import { EventName } from '../../../telemetry/constants';
8+
import type { UnitTestRunFailureCategory } from '../../../telemetry/constants';
9+
import { StopWatch } from '../../../common/utils/stopWatch';
810
import { IPythonExecutionFactory } from '../../../common/process/types';
911
import { ITestDebugLauncher } from '../../common/types';
1012
import { ProjectAdapter } from './projectAdapter';
@@ -70,13 +72,28 @@ export async function executeTestsForProjects(
7072
debugging: isDebugMode,
7173
});
7274

75+
const stopWatch = new StopWatch();
76+
let failed = false;
77+
let failureCategory: UnitTestRunFailureCategory | undefined;
7378
try {
7479
await executeTestsForProject(project, items, runInstance, request, deps);
7580
} catch (error) {
81+
failed = true;
82+
failureCategory = token.isCancellationRequested ? 'cancelled' : 'unknown';
7683
// Don't log cancellation as an error
7784
if (!token.isCancellationRequested) {
7885
traceError(`[test-by-project] Execution failed for project ${project.projectName}:`, error);
7986
}
87+
} finally {
88+
sendTelemetryEvent(EventName.UNITTEST_RUN_DONE, undefined, {
89+
tool: project.testProvider,
90+
debugging: isDebugMode,
91+
mode: 'project',
92+
failed,
93+
failureCategory,
94+
durationMs: stopWatch.elapsedTime,
95+
requestedCount: items.length,
96+
});
8097
}
8198
});
8299

src/client/testing/testController/common/resultResolver.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TestItemIndex } from './testItemIndex';
1111
import { TestDiscoveryHandler } from './testDiscoveryHandler';
1212
import { TestExecutionHandler } from './testExecutionHandler';
1313
import { TestCoverageHandler } from './testCoverageHandler';
14+
import { DiscoveryTelemetryState } from './discoveryTelemetry';
1415

1516
export class PythonResultResolver implements ITestResultResolver {
1617
testController: TestController;
@@ -26,6 +27,8 @@ export class PythonResultResolver implements ITestResultResolver {
2627

2728
public detailedCoverageMap = new Map<string, FileCoverageDetail[]>();
2829

30+
public readonly discoveryTelemetry: DiscoveryTelemetryState;
31+
2932
/**
3033
* Optional project ID for scoping test IDs.
3134
* When set, all test IDs are prefixed with `{projectId}@@vsc@@` for project-based testing.
@@ -52,6 +55,7 @@ export class PythonResultResolver implements ITestResultResolver {
5255
this.projectName = projectName;
5356
// Initialize a new TestItemIndex which will be used to track test items in this workspace/project
5457
this.testItemIndex = new TestItemIndex();
58+
this.discoveryTelemetry = new DiscoveryTelemetryState(projectId ? 'project' : 'legacy');
5559
}
5660

5761
// Expose for backward compatibility (WorkspaceTestAdapter accesses these)
@@ -76,7 +80,7 @@ export class PythonResultResolver implements ITestResultResolver {
7680
}
7781

7882
public resolveDiscovery(payload: DiscoveredTestPayload, token?: CancellationToken): void {
79-
PythonResultResolver.discoveryHandler.processDiscovery(
83+
const testCount = PythonResultResolver.discoveryHandler.processDiscovery(
8084
payload,
8185
this.testController,
8286
this.testItemIndex,
@@ -86,9 +90,17 @@ export class PythonResultResolver implements ITestResultResolver {
8690
this.projectId,
8791
this.projectName,
8892
);
93+
const cycle = this.discoveryTelemetry.complete();
94+
const mode = cycle?.mode ?? this.discoveryTelemetry.defaultMode;
95+
const failed = payload?.status === 'error';
8996
sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, {
9097
tool: this.testProvider,
91-
failed: false,
98+
failed,
99+
mode,
100+
trigger: cycle?.trigger,
101+
failureCategory: failed ? (token?.isCancellationRequested ? 'cancelled' : 'unknown') : undefined,
102+
totalDurationMs: cycle?.stopWatch.elapsedTime,
103+
testCount,
92104
});
93105
}
94106

src/client/testing/testController/common/testDiscoveryHandler.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ export class TestDiscoveryHandler {
3030
token?: CancellationToken,
3131
projectId?: string,
3232
projectName?: string,
33-
): void {
33+
): number {
3434
if (!payload) {
3535
// No test data is available
36-
return;
36+
return 0;
3737
}
3838

3939
const workspacePath = workspaceUri.fsPath;
@@ -57,10 +57,14 @@ export class TestDiscoveryHandler {
5757
// Clear existing mappings before rebuilding test tree
5858
testItemIndex.clear();
5959

60+
if (rawTestData.tests === null) {
61+
return 0;
62+
}
63+
6064
// If the test root for this folder exists: Workspace refresh, update its children.
6165
// Otherwise, it is a freshly discovered workspace, and we need to create a new test root and populate the test tree.
6266
// Note: populateTestTree will call testItemIndex.registerTestItem() for each discovered test
63-
populateTestTree(
67+
return populateTestTree(
6468
testController,
6569
rawTestData.tests,
6670
undefined,
@@ -74,6 +78,8 @@ export class TestDiscoveryHandler {
7478
projectName,
7579
);
7680
}
81+
82+
return 0;
7783
}
7884

7985
/**

src/client/testing/testController/common/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ITestDebugLauncher } from '../../common/types';
1717
import { IPythonExecutionFactory } from '../../../common/process/types';
1818
import { PythonEnvironment } from '../../../pythonEnvironments/info';
1919
import { ProjectAdapter } from './projectAdapter';
20+
import { DiscoveryTriggerKind } from './discoveryTelemetry';
2021

2122
export enum TestDataKinds {
2223
Workspace,
@@ -34,7 +35,10 @@ export interface TestData {
3435
kind: TestDataKinds;
3536
}
3637

37-
export type TestRefreshOptions = { forceRefresh: boolean };
38+
export type TestRefreshOptions = {
39+
forceRefresh: boolean;
40+
trigger?: DiscoveryTriggerKind;
41+
};
3842

3943
export const ITestController = Symbol('ITestController');
4044
export interface ITestController {

src/client/testing/testController/common/utils.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ export function buildErrorNodeOptions(
223223
};
224224
}
225225

226+
/**
227+
* Populates the VS Code test tree from discovered test data.
228+
* @returns The number of leaf test items added or updated while walking the tree.
229+
*/
226230
export function populateTestTree(
227231
testController: TestController,
228232
testTreeData: DiscoveredTestNode,
@@ -231,7 +235,10 @@ export function populateTestTree(
231235
token?: CancellationToken,
232236
projectId?: string,
233237
projectName?: string,
234-
): void {
238+
): number {
239+
// Count leaf tests while walking the tree so telemetry does not need a second traversal.
240+
let testCount = 0;
241+
235242
// If testRoot is undefined, use the info of the root item of testTreeData to create a test item, and append it to the test controller.
236243
if (!testRoot) {
237244
// Create project-scoped ID if projectId is provided
@@ -275,6 +282,7 @@ export function populateTestTree(
275282
testItemMappings.runIdToTestItem.set(child.runID, testItem);
276283
testItemMappings.runIdToVSid.set(child.runID, vsId);
277284
testItemMappings.vsIdToRunId.set(vsId, child.runID);
285+
testCount += 1;
278286
} else {
279287
// Use project-scoped ID for non-test nodes and look up within the current root
280288
const nodeId = projectId ? `${projectId}${PROJECT_ID_SEPARATOR}${child.id_}` : child.id_;
@@ -302,10 +310,20 @@ export function populateTestTree(
302310

303311
testRoot!.children.add(node);
304312
}
305-
populateTestTree(testController, child, node, testItemMappings, token, projectId, projectName);
313+
testCount += populateTestTree(
314+
testController,
315+
child,
316+
node,
317+
testItemMappings,
318+
token,
319+
projectId,
320+
projectName,
321+
);
306322
}
307323
}
308324
});
325+
326+
return testCount;
309327
}
310328

311329
function isTestItem(test: DiscoveredTestNode | DiscoveredTestItem): test is DiscoveredTestItem {

0 commit comments

Comments
 (0)