Skip to content
Draft
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
25 changes: 25 additions & 0 deletions src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ export enum EventNames {
* - hasPersistedSelection: boolean (whether a persisted env path existed in workspace state)
*/
ENV_SELECTION_RESULT = 'ENV_SELECTION.RESULT',
/**
* Telemetry event fired when applyInitialEnvironmentSelection returns.
* Duration measures the blocking time (excludes deferred global scope).
* Properties:
* - globalScopeDeferred: boolean (true = global scope fired in background, false = awaited)
* - workspaceFolderCount: number (total workspace folders)
* - resolvedFolderCount: number (folders that resolved with a non-undefined env)
* - settingErrorCount: number (user-configured settings that could not be applied)
*/
ENV_SELECTION_COMPLETED = 'ENV_SELECTION.COMPLETED',
/**
* Telemetry event fired when a lazily-registered manager completes its first initialization.
* Replaces MANAGER_REGISTRATION_SKIPPED and MANAGER_REGISTRATION_FAILED for managers
Expand Down Expand Up @@ -386,6 +396,21 @@ export interface IEventNamePropertyMapping {
hasPersistedSelection: boolean;
};

/* __GDPR__
"env_selection.completed": {
"globalScopeDeferred": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"workspaceFolderCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"resolvedFolderCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"settingErrorCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.ENV_SELECTION_COMPLETED]: {
globalScopeDeferred: boolean;
workspaceFolderCount: number;
resolvedFolderCount: number;
settingErrorCount: number;
};
Comment on lines +399 to +412
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Please verify the GDPR annotation key string ("env_selection.completed") matches the repository’s established telemetry/GDPR naming convention for the corresponding EventNames value ('ENV_SELECTION.COMPLETED'). If the extractor expects an exact match (or a specific normalization), a mismatch can lead to missing/invalid GDPR metadata for this event.

Copilot uses AI. Check for mistakes.

/* __GDPR__
"manager.lazy_init": {
"managerName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
Expand Down
99 changes: 77 additions & 22 deletions src/features/interpreterSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ export async function applyInitialEnvironmentSelection(
});

const allErrors: SettingResolutionError[] = [];
let workspaceFolderResolved = false;
let resolvedFolderCount = 0;
const selectionStopWatch = new StopWatch();

for (const folder of folders) {
try {
Expand Down Expand Up @@ -328,6 +331,11 @@ export async function applyInitialEnvironmentSelection(
// Cache only — NO settings.json write (shouldPersistSettings = false)
await envManagers.setEnvironment(folder.uri, env, false);

if (env) {
workspaceFolderResolved = true;
resolvedFolderCount++;
}

traceInfo(
`[interpreterSelection] ${folder.name}: ${env?.displayName ?? 'none'} (source: ${result.source})`,
);
Expand All @@ -336,38 +344,85 @@ export async function applyInitialEnvironmentSelection(
}
}

// Also apply initial selection for global scope (no workspace folder)
// This ensures defaultInterpreterPath is respected even without a workspace
try {
const globalStopWatch = new StopWatch();
const { result, errors } = await resolvePriorityChainCore(undefined, envManagers, undefined, nativeFinder, api);
allErrors.push(...errors);
// Global scope: resolve a fallback Python environment for files opened OUTSIDE all
// workspace folders (e.g., /tmp/script.py). This is NOT a workspace folder — every
// workspace folder was already fully resolved and cached in the for-loop above,
// so switching between workspace folders is unaffected by whether this runs now or later.
//
// When at least one workspace folder resolved, we defer global scope to the background
// so it doesn't block post-selection startup (clearHangWatchdog, terminal init, telemetry).
// Errors inside resolveGlobalScope are handled internally:
// - Setting resolution errors (bad paths, unknown managers) → notifyUserOfSettingErrors
// - Unexpected crashes → logged via traceError, never silently swallowed
const resolveGlobalScope = async () => {
try {
const globalStopWatch = new StopWatch();
const { result, errors: globalErrors } = await resolvePriorityChainCore(
undefined,
envManagers,
undefined,
nativeFinder,
api,
);

const isPathA = result.environment !== undefined;
const isPathA = result.environment !== undefined;
const env = result.environment ?? (await result.manager.get(undefined));

// Get the specific environment if not already resolved
const env = result.environment ?? (await result.manager.get(undefined));
sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, globalStopWatch.elapsedTime, {
scope: 'global',
prioritySource: result.source,
managerId: result.manager.id,
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
hasPersistedSelection: env !== undefined,
});

sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, globalStopWatch.elapsedTime, {
scope: 'global',
prioritySource: result.source,
managerId: result.manager.id,
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
hasPersistedSelection: env !== undefined,
});
await envManagers.setEnvironments('global', env, false);

// Cache only — NO settings.json write (shouldPersistSettings = false)
await envManagers.setEnvironments('global', env, false);
traceInfo(`[interpreterSelection] global: ${env?.displayName ?? 'none'} (source: ${result.source})`);

traceInfo(`[interpreterSelection] global: ${env?.displayName ?? 'none'} (source: ${result.source})`);
} catch (err) {
traceError(`[interpreterSelection] Failed to set global environment: ${err}`);
if (globalErrors.length > 0) {
await notifyUserOfSettingErrors(globalErrors);
}
} catch (err) {
traceError(`[interpreterSelection] Failed to set global environment: ${err}`);
}
};
Comment on lines +357 to +389
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Global-scope setting errors are now always surfaced via notifyUserOfSettingErrors() inside resolveGlobalScope(), while workspace-scope errors are surfaced later via allErrors. This changes user-visible behavior: when global scope is awaited (workspaceFolderResolved=false), users can receive multiple warnings for the same setting (one from globalErrors and one from allErrors), whereas previously errors were aggregated and deduped in a single notifyUserOfSettingErrors(allErrors) call. Consider refactoring resolveGlobalScope to return globalErrors (and not notify internally) when it is awaited, and only notify internally when it is deferred, so awaited flows can aggregate + dedupe workspace/global errors in one place.

Copilot uses AI. Check for mistakes.

if (workspaceFolderResolved) {
// At least one workspace folder got a non-undefined environment (in multi-root,
// ANY folder succeeding is sufficient). ALL workspace folder envs are already
// active via setEnvironment calls in the loop — switching between folders in a
// multi-root workspace is not affected by deferring the global scope.
// Defer global scope to a background task so we don't block post-selection
// startup work in extension.ts (clearHangWatchdog, terminal init, telemetry).
// The outer .catch is a safety net — resolveGlobalScope has its own try/catch,
// so this only fires if the inner handler itself throws unexpectedly.
traceInfo('[interpreterSelection] Workspace env resolved, deferring global scope to background');
resolveGlobalScope().catch((err) =>
traceError(`[interpreterSelection] Background global scope resolution failed: ${err}`),
);
} else {
// Either: (a) no workspace folders are open, (b) every folder resolved with
// env=undefined (no Python found), or (c) every folder threw an error.
// In all cases the global environment is the user's primary fallback,
// so we must await it before returning.
await resolveGlobalScope();
}

// Notify user if any settings could not be applied
// Notify user if any workspace-scoped settings could not be applied
if (allErrors.length > 0) {
await notifyUserOfSettingErrors(allErrors);
}

// Checkpoint 3: env selection function returning — duration measures blocking time only.
// If globalScopeDeferred=true, the global scope is still running in the background
// and its duration is NOT included in this measurement.
sendTelemetryEvent(EventNames.ENV_SELECTION_COMPLETED, selectionStopWatch.elapsedTime, {
globalScopeDeferred: workspaceFolderResolved,
workspaceFolderCount: folders.length,
resolvedFolderCount,
settingErrorCount: allErrors.length,
});
}

/**
Expand Down
Loading
Loading