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
100 changes: 100 additions & 0 deletions src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,42 @@ export enum EventNames {
* - duration: number (milliseconds taken for the CLI operation)
*/
PET_JSON_CLI_FALLBACK = 'PET.JSON_CLI_FALLBACK',
/**
* Telemetry event for a PET refresh attempt (the core discovery RPC call).
* Properties:
* - result: 'success' | 'timeout' | 'error'
* - envCount: number (environments returned via notifications)
* - unresolvedCount: number (envs that needed follow-up resolve calls)
* - workspaceDirCount: number (workspace directories sent in configure)
* - searchPathCount: number (extra search paths sent in configure)
* - attempt: number (0 = first try, 1 = retry)
* - errorType: string (classified error category, on failure only)
*/
PET_REFRESH = 'PET.REFRESH',
/**
* Telemetry event for a PET configure RPC call.
* Properties:
* - result: 'success' | 'timeout' | 'error' | 'skipped'
* - workspaceDirCount: number
* - envDirCount: number (environmentDirectories count)
* - retryCount: number (consecutive timeout count from ConfigureRetryState)
*/
PET_CONFIGURE = 'PET.CONFIGURE',
/**
* Telemetry event for PET process restart attempts.
* Properties:
* - attempt: number (1-based restart attempt number)
* - result: 'success' | 'error'
* - errorType: string (classified error category, on failure only)
*/
PET_PROCESS_RESTART = 'PET.PROCESS_RESTART',
/**
* Telemetry event for PET resolve calls (single-env resolution).
* Properties:
* - result: 'success' | 'timeout' | 'error'
* - errorType: string (classified error category, on failure only)
*/
PET_RESOLVE = 'PET.RESOLVE',
}

// Map all events to their properties
Expand Down Expand Up @@ -424,4 +460,68 @@ export interface IEventNamePropertyMapping {
operation: 'refresh' | 'resolve';
result: 'success' | 'error';
};

/* __GDPR__
"pet.refresh": {
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"envCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"unresolvedCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"workspaceDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"searchPathCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"attempt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.PET_REFRESH]: {
result: 'success' | 'timeout' | 'error';
envCount?: number;
unresolvedCount?: number;
workspaceDirCount?: number;
searchPathCount?: number;
attempt: number;
errorType?: string;
};

/* __GDPR__
"pet.configure": {
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"workspaceDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"envDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"retryCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.PET_CONFIGURE]: {
result: 'success' | 'timeout' | 'error' | 'skipped';
workspaceDirCount?: number;
envDirCount?: number;
retryCount: number;
};

/* __GDPR__
"pet.process_restart": {
"attempt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.PET_PROCESS_RESTART]: {
attempt: number;
result: 'success' | 'error';
errorType?: string;
};

/* __GDPR__
"pet.resolve": {
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.PET_RESOLVE]: {
result: 'success' | 'timeout' | 'error';
errorType?: string;
};
}
71 changes: 71 additions & 0 deletions src/managers/common/nativePythonFinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getExtension } from '../../common/extension.apis';
import { traceError, traceVerbose, traceWarn } from '../../common/logging';
import { StopWatch } from '../../common/stopWatch';
import { EventNames } from '../../common/telemetry/constants';
import { classifyError } from '../../common/telemetry/errorClassifier';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
Comment on lines 13 to 15
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Importing classifyError here introduces a circular dependency: nativePythonFinder.ts -> telemetry/errorClassifier.ts -> nativePythonFinder.ts (because errorClassifier imports RpcTimeoutError from this file). This can lead to partially-initialized exports at runtime (e.g., RpcTimeoutError checks failing or classifyError being undefined depending on load order). Please break the cycle by moving RpcTimeoutError into a small shared module (imported by both files) or by changing errorClassifier to detect timeouts without importing nativePythonFinder (e.g., via ex instanceof Error && ex.name === 'RpcTimeoutError' plus a shape check).

Copilot uses AI. Check for mistakes.
import { untildify, untildifyArray } from '../../common/utils/pathUtils';
import { isWindows } from '../../common/utils/platformUtils';
Expand Down Expand Up @@ -253,6 +254,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
}

public async resolve(executable: string): Promise<NativeEnvInfo> {
const sw = new StopWatch();
try {
await this.ensureProcessRunning();
try {
Expand All @@ -267,6 +269,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
this.outputChannel.info(`Resolved Python Environment ${environment.executable}`);
// Reset restart attempts on successful request
this.restartAttempts = 0;
sendTelemetryEvent(EventNames.PET_RESOLVE, sw.elapsedTime, { result: 'success' });
return environment;
} catch (ex) {
// On resolve timeout or connection error (not configure — configure handles its own timeout),
Expand All @@ -280,6 +283,16 @@ class NativePythonFinderImpl implements NativePythonFinder {
throw ex;
}
} catch (ex) {
const errorType = classifyError(ex);
sendTelemetryEvent(
EventNames.PET_RESOLVE,
sw.elapsedTime,
{
result: errorType === 'spawn_timeout' ? 'timeout' : 'error',
errorType,
},
ex instanceof Error ? ex : undefined,
);
// If the server mode is fully exhausted, fall back to the CLI JSON mode
if (this.isServerExhausted()) {
this.outputChannel.warn('[pet] Server mode exhausted, falling back to JSON CLI for resolve');
Expand Down Expand Up @@ -325,13 +338,15 @@ class NativePythonFinderImpl implements NativePythonFinder {
private async restart(): Promise<void> {
this.isRestarting = true;
this.restartAttempts++;
const attempt = this.restartAttempts;

const backoffMs = RESTART_BACKOFF_BASE_MS * Math.pow(2, this.restartAttempts - 1);
this.outputChannel.warn(
`[pet] Restarting Python Environment Tools (attempt ${this.restartAttempts}/${MAX_RESTART_ATTEMPTS}, ` +
`waiting ${backoffMs}ms)`,
);

const sw = new StopWatch();
try {
// Kill existing process if still running
this.killProcess();
Expand All @@ -353,10 +368,17 @@ class NativePythonFinderImpl implements NativePythonFinder {
this.connection = this.start();

this.outputChannel.info('[pet] Python Environment Tools restarted successfully');
sendTelemetryEvent(EventNames.PET_PROCESS_RESTART, sw.elapsedTime, { attempt, result: 'success' });

// Reset restart attempts on successful start (process didn't immediately fail)
// We'll reset this only after a successful request completes
} catch (ex) {
sendTelemetryEvent(
EventNames.PET_PROCESS_RESTART,
sw.elapsedTime,
{ attempt, result: 'error', errorType: classifyError(ex) },
ex instanceof Error ? ex : undefined,
);
this.outputChannel.error('[pet] Failed to restart Python Environment Tools:', ex);
this.outputChannel.error(
'[pet] To debug, run "Python Environments: Run Python Environment Tool (PET) in Terminal" from the Command Palette.',
Expand Down Expand Up @@ -634,13 +656,18 @@ class NativePythonFinderImpl implements NativePythonFinder {
const disposables: Disposable[] = [];
const unresolved: Promise<void>[] = [];
const nativeInfo: NativeInfo[] = [];
const sw = new StopWatch();
let unresolvedCount = 0;
try {
await this.configure();
const refreshOptions = this.getRefreshOptions(options);
const workspaceDirCount = this.lastConfiguration?.workspaceDirectories.length ?? 0;
const searchPathCount = this.lastConfiguration?.environmentDirectories.length ?? 0;
disposables.push(
this.connection.onNotification('environment', (data: NativeEnvInfo) => {
this.outputChannel.info(`Discovered env: ${data.executable || data.prefix}`);
if (data.executable && (!data.version || !data.prefix)) {
unresolvedCount++;
unresolved.push(
sendRequestWithTimeout<NativeEnvInfo>(
this.connection,
Expand Down Expand Up @@ -680,7 +707,29 @@ class NativePythonFinderImpl implements NativePythonFinder {
if (attempt > 0) {
this.outputChannel.info(`[pet] Refresh succeeded on retry attempt ${attempt + 1}`);
}

sendTelemetryEvent(EventNames.PET_REFRESH, sw.elapsedTime, {
result: 'success',
envCount: nativeInfo.filter((e) => isNativeEnvInfo(e)).length,
unresolvedCount,
workspaceDirCount,
searchPathCount,
attempt,
});
} catch (ex) {
const errorType = classifyError(ex);
sendTelemetryEvent(
EventNames.PET_REFRESH,
sw.elapsedTime,
{
result: errorType === 'spawn_timeout' ? 'timeout' : 'error',
envCount: nativeInfo.filter((e) => isNativeEnvInfo(e)).length,
unresolvedCount,
attempt,
errorType,
},
ex instanceof Error ? ex : undefined,
);
// On refresh timeout or connection error (not configure — configure handles its own timeout),
// kill the hung process so next request triggers restart
if ((ex instanceof RpcTimeoutError && ex.method !== 'configure') || ex instanceof rpc.ConnectionError) {
Expand Down Expand Up @@ -709,6 +758,7 @@ class NativePythonFinderImpl implements NativePythonFinder {
// No need to send a configuration request if there are no changes.
if (this.lastConfiguration && this.configurationEquals(options, this.lastConfiguration)) {
this.outputChannel.debug('[pet] configure: No changes detected, skipping configuration update.');
sendTelemetryEvent(EventNames.PET_CONFIGURE, 0, { result: 'skipped', retryCount: 0 });
return;
}
this.outputChannel.info('[pet] configure: Sending configuration update:', JSON.stringify(options));
Expand All @@ -719,12 +769,33 @@ class NativePythonFinderImpl implements NativePythonFinder {
`[pet] configure: Using extended timeout of ${timeoutMs}ms (retry ${this.configureRetry.timeoutCount})`,
);
}
const sw = new StopWatch();
const retryCount = this.configureRetry.timeoutCount;
const workspaceDirCount = options.workspaceDirectories.length;
const envDirCount = options.environmentDirectories.length;
try {
await sendRequestWithTimeout(this.connection, 'configure', options, timeoutMs);
// Only cache after success so failed/timed-out calls will retry
this.lastConfiguration = options;
this.configureRetry.onSuccess();
sendTelemetryEvent(EventNames.PET_CONFIGURE, sw.elapsedTime, {
result: 'success',
workspaceDirCount,
envDirCount,
retryCount,
});
} catch (ex) {
sendTelemetryEvent(
EventNames.PET_CONFIGURE,
sw.elapsedTime,
{
result: ex instanceof RpcTimeoutError ? 'timeout' : 'error',
workspaceDirCount,
envDirCount,
retryCount,
},
ex instanceof Error ? ex : undefined,
);
// Clear cached config so the next call retries instead of short-circuiting via configurationEquals
this.lastConfiguration = undefined;
if (ex instanceof RpcTimeoutError) {
Expand Down
Loading