Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 2 additions & 1 deletion src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ export interface IEventNamePropertyMapping {

/* __GDPR__
"environment_manager.registered": {
"managerId" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
"managerId" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.ENVIRONMENT_MANAGER_REGISTERED]: {
Expand Down
53 changes: 53 additions & 0 deletions src/common/telemetry/errorClassifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { CancellationError } from 'vscode';
import { RpcTimeoutError } from '../../managers/common/nativePythonFinder';

export type DiscoveryErrorType =
| 'spawn_timeout'
| 'spawn_enoent'
| 'permission_denied'
| 'canceled'
| 'parse_error'
| 'unknown';

/**
* Classifies an error into a telemetry-safe category for the `errorType` property.
* Does NOT include raw error messages — only the category.
*/
export function classifyError(ex: unknown): DiscoveryErrorType {
if (ex instanceof CancellationError) {
return 'canceled';
}

if (ex instanceof RpcTimeoutError) {
return 'spawn_timeout';
}

if (!(ex instanceof Error)) {
return 'unknown';
}

// Check error code for spawn failures (Node.js sets `code` on spawn errors)
const code = (ex as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return 'spawn_enoent';
}
if (code === 'EACCES' || code === 'EPERM') {
return 'permission_denied';
}

// Check message patterns
const msg = ex.message.toLowerCase();
if (msg.includes('timed out') || msg.includes('timeout')) {
return 'spawn_timeout';
}
if (msg.includes('parse') || msg.includes('unexpected token') || msg.includes('json')) {
return 'parse_error';
}

// Check error name for cancellation variants
if (ex.name === 'CancellationError' || ex.name === 'AbortError') {
return 'canceled';
}

return 'unknown';
Comment thread
eleanorjboyd marked this conversation as resolved.
}
Comment thread
eleanorjboyd marked this conversation as resolved.
18 changes: 14 additions & 4 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,12 +546,22 @@ 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);
await sendEnvironmentToolUsageTelemetry(projectManager, envManagers);
try {
await terminalManager.initialize(api);
sendManagerSelectionTelemetry(projectManager);
await sendProjectStructureTelemetry(projectManager, envManagers);
await sendEnvironmentToolUsageTelemetry(projectManager, envManagers);
} catch (postInitError) {
traceError('Post-initialization tasks failed:', postInitError);
}
} catch (error) {
traceError('Failed to initialize environment managers:', error);
sendTelemetryEvent(
EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION,
start.elapsedTime,
undefined,
error instanceof Error ? error : undefined,
);
Comment thread
eleanorjboyd marked this conversation as resolved.
// Show a user-friendly error message
window.showErrorMessage(
l10n.t(
Expand Down
4 changes: 3 additions & 1 deletion src/features/envManagers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PackageManagerAlreadyRegisteredError,
} from '../common/errors/AlreadyRegisteredError';
import { traceError, traceVerbose } from '../common/logging';
import { StopWatch } from '../common/stopWatch';
import { EventNames } from '../common/telemetry/constants';
import { sendTelemetryEvent } from '../common/telemetry/sender';
import { getCallingExtension } from '../common/utils/frameUtils';
Expand Down Expand Up @@ -71,6 +72,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
constructor(private readonly pm: PythonProjectManager) {}

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

if (!managerId.toLowerCase().startsWith('undefined_publisher.')) {
sendTelemetryEvent(EventNames.ENVIRONMENT_MANAGER_REGISTERED, undefined, {
sendTelemetryEvent(EventNames.ENVIRONMENT_MANAGER_REGISTERED, registrationStopWatch.elapsedTime, {
managerId,
});
}
Expand Down
7 changes: 4 additions & 3 deletions src/internal.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { CreateEnvironmentNotSupported, RemoveEnvironmentNotSupported } from './
import { StopWatch } from './common/stopWatch';
import { EventNames } from './common/telemetry/constants';
import { sendTelemetryEvent } from './common/telemetry/sender';
import { classifyError } from './common/telemetry/errorClassifier';

export type EnvironmentManagerScope = undefined | string | Uri | PythonEnvironment;
export type PackageManagerScope = undefined | string | Uri | PythonEnvironment | Package;
Expand Down Expand Up @@ -213,14 +214,14 @@ export class InternalEnvironmentManager implements EnvironmentManager {
envCount: envs.length,
});
} catch (ex) {
const isTimeout = ex instanceof Error && ex.message.includes('timed out');
const errorType = classifyError(ex);
sendTelemetryEvent(
EventNames.ENVIRONMENT_DISCOVERY,
sw.elapsedTime,
{
managerId: this.id,
result: isTimeout ? 'timeout' : 'error',
errorType: ex instanceof Error ? ex.name : 'unknown',
result: errorType === 'canceled' || errorType === 'spawn_timeout' ? 'timeout' : 'error',
errorType,
},
ex instanceof Error ? ex : undefined,
);
Expand Down
68 changes: 68 additions & 0 deletions src/test/common/telemetry/errorClassifier.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert';
import { CancellationError } from 'vscode';
import { classifyError } from '../../../common/telemetry/errorClassifier';
import { RpcTimeoutError } from '../../../managers/common/nativePythonFinder';

suite('Error Classifier', () => {
suite('classifyError', () => {
test('should classify CancellationError as canceled', () => {
assert.strictEqual(classifyError(new CancellationError()), 'canceled');
});

test('should classify RpcTimeoutError as spawn_timeout', () => {
assert.strictEqual(classifyError(new RpcTimeoutError('resolve', 30000)), 'spawn_timeout');
});

test('should classify non-Error values as unknown', () => {
assert.strictEqual(classifyError('string error'), 'unknown');
assert.strictEqual(classifyError(42), 'unknown');
assert.strictEqual(classifyError(null), 'unknown');
assert.strictEqual(classifyError(undefined), 'unknown');
});

test('should classify ENOENT errors as spawn_enoent', () => {
const err = new Error('spawn python ENOENT') as NodeJS.ErrnoException;
err.code = 'ENOENT';
assert.strictEqual(classifyError(err), 'spawn_enoent');
});

test('should classify EACCES errors as permission_denied', () => {
const err = new Error('permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
assert.strictEqual(classifyError(err), 'permission_denied');
});

test('should classify EPERM errors as permission_denied', () => {
const err = new Error('operation not permitted') as NodeJS.ErrnoException;
err.code = 'EPERM';
assert.strictEqual(classifyError(err), 'permission_denied');
});

test('should classify timeout messages as spawn_timeout', () => {
assert.strictEqual(classifyError(new Error('Request timed out')), 'spawn_timeout');
assert.strictEqual(classifyError(new Error('Connection timeout')), 'spawn_timeout');
});

test('should classify parse errors as parse_error', () => {
assert.strictEqual(classifyError(new Error('Failed to parse output')), 'parse_error');
assert.strictEqual(classifyError(new Error('Unexpected token < in JSON')), 'parse_error');
assert.strictEqual(classifyError(new Error('Invalid JSON response')), 'parse_error');
});

test('should classify AbortError name as canceled', () => {
const err = new Error('aborted');
err.name = 'AbortError';
assert.strictEqual(classifyError(err), 'canceled');
});

test('should classify error with CancellationError name as canceled', () => {
const err = new Error('cancelled');
err.name = 'CancellationError';
assert.strictEqual(classifyError(err), 'canceled');
});

test('should classify unrecognized errors as unknown', () => {
assert.strictEqual(classifyError(new Error('something went wrong')), 'unknown');
});
});
});