Skip to content

Commit 8b9890a

Browse files
authored
feat: enhance telemetry with error classification and duration tracking for environment manager registration (#1292)
help identify what could be causing discovery errors and which managers may be taking a while to load
1 parent b1a1f61 commit 8b9890a

File tree

9 files changed

+199
-23
lines changed

9 files changed

+199
-23
lines changed

src/common/telemetry/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export interface IEventNamePropertyMapping {
6969

7070
/* __GDPR__
7171
"environment_manager.registered": {
72-
"managerId" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
72+
"managerId" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
73+
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
7374
}
7475
*/
7576
[EventNames.ENVIRONMENT_MANAGER_REGISTERED]: {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { CancellationError } from 'vscode';
2+
import { RpcTimeoutError } from '../../managers/common/nativePythonFinder';
3+
4+
export type DiscoveryErrorType =
5+
| 'spawn_timeout'
6+
| 'spawn_enoent'
7+
| 'permission_denied'
8+
| 'canceled'
9+
| 'parse_error'
10+
| 'unknown';
11+
12+
/**
13+
* Classifies an error into a telemetry-safe category for the `errorType` property.
14+
* Does NOT include raw error messages — only the category.
15+
*/
16+
export function classifyError(ex: unknown): DiscoveryErrorType {
17+
if (ex instanceof CancellationError) {
18+
return 'canceled';
19+
}
20+
21+
if (ex instanceof RpcTimeoutError) {
22+
return 'spawn_timeout';
23+
}
24+
25+
if (!(ex instanceof Error)) {
26+
return 'unknown';
27+
}
28+
29+
// Check error code for spawn failures (Node.js sets `code` on spawn errors)
30+
const code = (ex as NodeJS.ErrnoException).code;
31+
if (code === 'ENOENT') {
32+
return 'spawn_enoent';
33+
}
34+
if (code === 'EACCES' || code === 'EPERM') {
35+
return 'permission_denied';
36+
}
37+
38+
// Check message patterns
39+
const msg = ex.message.toLowerCase();
40+
if (msg.includes('timed out') || msg.includes('timeout')) {
41+
return 'spawn_timeout';
42+
}
43+
if (msg.includes('parse') || msg.includes('unexpected token') || msg.includes('json')) {
44+
return 'parse_error';
45+
}
46+
47+
// Check error name for cancellation variants
48+
if (ex.name === 'CancellationError' || ex.name === 'AbortError') {
49+
return 'canceled';
50+
}
51+
52+
return 'unknown';
53+
}

src/extension.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -558,15 +558,25 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
558558
);
559559

560560
sendTelemetryEvent(EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION, start.elapsedTime);
561-
await terminalManager.initialize(api);
562-
sendManagerSelectionTelemetry(projectManager);
563-
await sendProjectStructureTelemetry(projectManager, envManagers);
564-
await sendEnvironmentToolUsageTelemetry(projectManager, envManagers);
565-
566-
// Log discovery summary to help users troubleshoot environment detection issues
567-
await logDiscoverySummary(envManagers);
561+
try {
562+
await terminalManager.initialize(api);
563+
sendManagerSelectionTelemetry(projectManager);
564+
await sendProjectStructureTelemetry(projectManager, envManagers);
565+
await sendEnvironmentToolUsageTelemetry(projectManager, envManagers);
566+
567+
// Log discovery summary to help users troubleshoot environment detection issues
568+
await logDiscoverySummary(envManagers);
569+
} catch (postInitError) {
570+
traceError('Post-initialization tasks failed:', postInitError);
571+
}
568572
} catch (error) {
569573
traceError('Failed to initialize environment managers:', error);
574+
sendTelemetryEvent(
575+
EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION,
576+
start.elapsedTime,
577+
undefined,
578+
error instanceof Error ? error : undefined,
579+
);
570580
// Show a user-friendly error message
571581
window.showErrorMessage(
572582
l10n.t(

src/features/envManagers.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
PackageManagerAlreadyRegisteredError,
1717
} from '../common/errors/AlreadyRegisteredError';
1818
import { traceError, traceVerbose } from '../common/logging';
19+
import { StopWatch } from '../common/stopWatch';
1920
import { EventNames } from '../common/telemetry/constants';
2021
import { sendTelemetryEvent } from '../common/telemetry/sender';
2122
import { getCallingExtension } from '../common/utils/frameUtils';
@@ -71,6 +72,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
7172
constructor(private readonly pm: PythonProjectManager) {}
7273

7374
public registerEnvironmentManager(manager: EnvironmentManager): Disposable {
75+
const registrationStopWatch = new StopWatch();
7476
const managerId = generateId(manager.name);
7577
if (this._environmentManagers.has(managerId)) {
7678
const ex = new EnvironmentManagerAlreadyRegisteredError(
@@ -105,7 +107,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
105107
this._onDidChangeEnvironmentManager.fire({ kind: 'registered', manager: mgr });
106108

107109
if (!managerId.toLowerCase().startsWith('undefined_publisher.')) {
108-
sendTelemetryEvent(EventNames.ENVIRONMENT_MANAGER_REGISTERED, undefined, {
110+
sendTelemetryEvent(EventNames.ENVIRONMENT_MANAGER_REGISTERED, registrationStopWatch.elapsedTime, {
109111
managerId,
110112
});
111113
}
@@ -327,6 +329,13 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
327329
},
328330
]);
329331
}
332+
traceVerbose(
333+
`[setEnvironment] scope=${scope instanceof Uri ? scope.fsPath : scope}, ` +
334+
`env=${environment?.envId?.id ?? 'undefined'}, manager=${manager.id}, ` +
335+
`project=${project?.uri?.toString() ?? 'none'}, ` +
336+
`packageManager=${this.getPackageManager(environment)?.id ?? 'UNDEFINED'}, ` +
337+
`settingsPersisted=${!!(project && this.getPackageManager(environment))}`,
338+
);
330339
}
331340

332341
const key = project ? project.uri.toString() : 'global';
@@ -503,6 +512,10 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
503512
async getEnvironment(scope: GetEnvironmentScope): Promise<PythonEnvironment | undefined> {
504513
const manager = this.getEnvironmentManager(scope);
505514
if (!manager) {
515+
traceVerbose(
516+
`[getEnvironment] No manager found for scope=${scope instanceof Uri ? scope.fsPath : scope}, ` +
517+
`settingsManagerId=${getDefaultEnvManagerSetting(this.pm, scope instanceof Uri ? scope : undefined)}`,
518+
);
506519
return undefined;
507520
}
508521

src/internal.api.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { traceWarn } from './common/logging';
3333
import { StopWatch } from './common/stopWatch';
3434
import { EventNames } from './common/telemetry/constants';
3535
import { sendTelemetryEvent } from './common/telemetry/sender';
36+
import { classifyError } from './common/telemetry/errorClassifier';
3637

3738
export type EnvironmentManagerScope = undefined | string | Uri | PythonEnvironment;
3839
export type PackageManagerScope = undefined | string | Uri | PythonEnvironment | Package;
@@ -226,14 +227,13 @@ export class InternalEnvironmentManager implements EnvironmentManager {
226227
}
227228
} catch (ex) {
228229
const duration = sw.elapsedTime;
229-
const isTimeout = ex instanceof Error && ex.message.includes('timed out');
230-
const errorType = ex instanceof Error ? ex.name : 'unknown';
230+
const errorType = classifyError(ex);
231231
sendTelemetryEvent(
232232
EventNames.ENVIRONMENT_DISCOVERY,
233233
duration,
234234
{
235235
managerId: this.id,
236-
result: isTimeout ? 'timeout' : 'error',
236+
result: errorType === 'canceled' || errorType === 'spawn_timeout' ? 'timeout' : 'error',
237237
errorType,
238238
},
239239
ex instanceof Error ? ex : undefined,

src/managers/builtin/sysPythonManager.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,20 @@ export class SysPythonManager implements EnvironmentManager {
166166
const pw = this.api.getPythonProject(scope);
167167
if (!pw) {
168168
this.log.warn(
169-
`Unable to set environment for ${scope.fsPath}: Not a python project, folder or PEP723 script.`,
170-
this.api.getPythonProjects().map((p) => p.uri.fsPath),
169+
`[SYS_SET] Unable to set environment for ${scope.fsPath}: Not a python project. ` +
170+
`Known projects: [${this.api
171+
.getPythonProjects()
172+
.map((p) => p.uri.fsPath)
173+
.join(', ')}]`,
171174
);
172175
return;
173176
}
174177

175178
const normalizedPwPath = normalizePath(pw.uri.fsPath);
179+
this.log.info(
180+
`[SYS_SET] scope=${scope.fsPath}, project=${pw.uri.fsPath}, ` +
181+
`normalizedKey=${normalizedPwPath}, env=${environment?.envId?.id ?? 'undefined'}`,
182+
);
176183
if (environment) {
177184
this.fsPathToEnv.set(normalizedPwPath, environment);
178185
} else {
@@ -297,16 +304,28 @@ export class SysPythonManager implements EnvironmentManager {
297304
}
298305

299306
private fromEnvMap(uri: Uri): PythonEnvironment | undefined {
307+
const normalizedUri = normalizePath(uri.fsPath);
300308
// Find environment directly using the URI mapping
301-
const env = this.fsPathToEnv.get(normalizePath(uri.fsPath));
309+
const env = this.fsPathToEnv.get(normalizedUri);
302310
if (env) {
303311
return env;
304312
}
305313

306314
// Find environment using the Python project for the Uri
307315
const project = this.api.getPythonProject(uri);
308-
if (project) {
309-
return this.fsPathToEnv.get(normalizePath(project.uri.fsPath));
316+
const projectKey = project ? normalizePath(project.uri.fsPath) : undefined;
317+
const projectEnv = projectKey ? this.fsPathToEnv.get(projectKey) : undefined;
318+
319+
this.log.info(
320+
`[SYS_GET] uri=${uri.fsPath}, normalizedKey=${normalizedUri}, ` +
321+
`project=${project?.uri?.fsPath ?? 'none'}, projectKey=${projectKey ?? 'none'}, ` +
322+
`mapKeys=[${Array.from(this.fsPathToEnv.keys()).join(', ')}], ` +
323+
`directHit=${!!env}, projectHit=${!!projectEnv}, ` +
324+
`fallbackToGlobal=${!projectEnv}, globalEnv=${this.globalEnv?.envId?.id ?? 'none'}`,
325+
);
326+
327+
if (projectEnv) {
328+
return projectEnv;
310329
}
311330

312331
return this.globalEnv;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import assert from 'node:assert';
2+
import { CancellationError } from 'vscode';
3+
import { classifyError } from '../../../common/telemetry/errorClassifier';
4+
import { RpcTimeoutError } from '../../../managers/common/nativePythonFinder';
5+
6+
suite('Error Classifier', () => {
7+
suite('classifyError', () => {
8+
test('should classify CancellationError as canceled', () => {
9+
assert.strictEqual(classifyError(new CancellationError()), 'canceled');
10+
});
11+
12+
test('should classify RpcTimeoutError as spawn_timeout', () => {
13+
assert.strictEqual(classifyError(new RpcTimeoutError('resolve', 30000)), 'spawn_timeout');
14+
});
15+
16+
test('should classify non-Error values as unknown', () => {
17+
assert.strictEqual(classifyError('string error'), 'unknown');
18+
assert.strictEqual(classifyError(42), 'unknown');
19+
assert.strictEqual(classifyError(null), 'unknown');
20+
assert.strictEqual(classifyError(undefined), 'unknown');
21+
});
22+
23+
test('should classify ENOENT errors as spawn_enoent', () => {
24+
const err = new Error('spawn python ENOENT') as NodeJS.ErrnoException;
25+
err.code = 'ENOENT';
26+
assert.strictEqual(classifyError(err), 'spawn_enoent');
27+
});
28+
29+
test('should classify EACCES errors as permission_denied', () => {
30+
const err = new Error('permission denied') as NodeJS.ErrnoException;
31+
err.code = 'EACCES';
32+
assert.strictEqual(classifyError(err), 'permission_denied');
33+
});
34+
35+
test('should classify EPERM errors as permission_denied', () => {
36+
const err = new Error('operation not permitted') as NodeJS.ErrnoException;
37+
err.code = 'EPERM';
38+
assert.strictEqual(classifyError(err), 'permission_denied');
39+
});
40+
41+
test('should classify timeout messages as spawn_timeout', () => {
42+
assert.strictEqual(classifyError(new Error('Request timed out')), 'spawn_timeout');
43+
assert.strictEqual(classifyError(new Error('Connection timeout')), 'spawn_timeout');
44+
});
45+
46+
test('should classify parse errors as parse_error', () => {
47+
assert.strictEqual(classifyError(new Error('Failed to parse output')), 'parse_error');
48+
assert.strictEqual(classifyError(new Error('Unexpected token < in JSON')), 'parse_error');
49+
assert.strictEqual(classifyError(new Error('Invalid JSON response')), 'parse_error');
50+
});
51+
52+
test('should classify AbortError name as canceled', () => {
53+
const err = new Error('aborted');
54+
err.name = 'AbortError';
55+
assert.strictEqual(classifyError(err), 'canceled');
56+
});
57+
58+
test('should classify error with CancellationError name as canceled', () => {
59+
const err = new Error('cancelled');
60+
err.name = 'CancellationError';
61+
assert.strictEqual(classifyError(err), 'canceled');
62+
});
63+
64+
test('should classify unrecognized errors as unknown', () => {
65+
assert.strictEqual(classifyError(new Error('something went wrong')), 'unknown');
66+
});
67+
});
68+
});

src/test/integration/pythonProjects.integration.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ suite('Integration: Python Projects', function () {
185185
// Track what getEnvironment returns during polling for diagnostics
186186
let pollCount = 0;
187187
let lastRetrievedId: string | undefined;
188+
let lastRetrievedManagerId: string | undefined;
188189

189190
// Wait for the environment to be retrievable with the correct ID
190191
// This handles async persistence across platforms
@@ -194,16 +195,18 @@ suite('Integration: Python Projects', function () {
194195
const retrieved = await api.getEnvironment(project.uri);
195196
pollCount++;
196197
const retrievedId = retrieved?.envId?.id;
198+
lastRetrievedManagerId = retrieved?.envId?.managerId;
197199
if (retrievedId !== lastRetrievedId) {
198200
console.log(
199-
`[TEST DEBUG] Poll #${pollCount}: getEnvironment returned envId=${retrievedId ?? 'undefined'}`,
201+
`[TEST DEBUG] Poll #${pollCount}: getEnvironment returned envId=${retrievedId ?? 'undefined'}, managerId=${lastRetrievedManagerId ?? 'undefined'}`,
200202
);
201203
lastRetrievedId = retrievedId;
202204
}
203205
return retrieved !== undefined && retrieved.envId.id === env.envId.id;
204206
},
205207
15_000,
206-
`Environment was not set correctly. Expected envId: ${env.envId.id}, last retrieved: ${lastRetrievedId}`,
208+
() =>
209+
`Environment was not set correctly. Expected envId: ${env.envId.id} (manager: ${env.envId.managerId}), last retrieved: ${lastRetrievedId ?? 'undefined'} (manager: ${lastRetrievedManagerId ?? 'undefined'}) after ${pollCount} polls`,
207210
);
208211

209212
// Final verification
@@ -285,13 +288,16 @@ suite('Integration: Python Projects', function () {
285288

286289
// Wait for it to be set
287290
// Use 15s timeout - CI runners can be slow with settings persistence
291+
let clearTestLastId: string | undefined;
288292
await waitForCondition(
289293
async () => {
290294
const retrieved = await api.getEnvironment(project.uri);
295+
clearTestLastId = retrieved?.envId?.id;
291296
return retrieved !== undefined && retrieved.envId.id === env.envId.id;
292297
},
293298
15_000,
294-
'Environment was not set before clearing',
299+
() =>
300+
`Environment was not set before clearing. Expected: ${env.envId.id} (manager: ${env.envId.managerId}), got: ${clearTestLastId ?? 'undefined'}`,
295301
);
296302

297303
// Verify it was set
@@ -344,13 +350,18 @@ suite('Integration: Python Projects', function () {
344350

345351
// Wait for it to be set
346352
// Use 15s timeout - CI runners can be slow with settings persistence
353+
let fileTestLastId: string | undefined;
354+
let fileTestLastManagerId: string | undefined;
347355
await waitForCondition(
348356
async () => {
349357
const retrieved = await api.getEnvironment(project.uri);
358+
fileTestLastId = retrieved?.envId?.id;
359+
fileTestLastManagerId = retrieved?.envId?.managerId;
350360
return retrieved !== undefined && retrieved.envId.id === env.envId.id;
351361
},
352362
15_000,
353-
'Environment was not set for project',
363+
() =>
364+
`Environment was not set for project. Expected: ${env.envId.id} (manager: ${env.envId.managerId}), got: ${fileTestLastId ?? 'undefined'} (manager: ${fileTestLastManagerId ?? 'undefined'})`,
354365
);
355366

356367
// Create a hypothetical file path inside the project

src/test/testUtils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function sleep(ms: number): Promise<void> {
4848
export async function waitForCondition(
4949
condition: () => boolean | Promise<boolean>,
5050
timeoutMs: number = 10_000,
51-
errorMessage: string = 'Condition not met within timeout',
51+
errorMessage: string | (() => string) = 'Condition not met within timeout',
5252
pollIntervalMs: number = 100,
5353
): Promise<void> {
5454
return new Promise<void>((resolve, reject) => {
@@ -66,7 +66,8 @@ export async function waitForCondition(
6666
}
6767

6868
if (Date.now() - startTime >= timeoutMs) {
69-
reject(new Error(`${errorMessage} (waited ${timeoutMs}ms)`));
69+
const msg = typeof errorMessage === 'function' ? errorMessage() : errorMessage;
70+
reject(new Error(`${msg} (waited ${timeoutMs}ms)`));
7071
return;
7172
}
7273

0 commit comments

Comments
 (0)