Skip to content

Commit 6ce3dbc

Browse files
committed
feat: add global environment cache telemetry and improve cache management
1 parent dddc872 commit 6ce3dbc

6 files changed

Lines changed: 175 additions & 11 deletions

File tree

src/common/telemetry/constants.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ export enum EventNames {
123123
* - errorType: string (classified error category, on failure only)
124124
*/
125125
MANAGER_LAZY_INIT = 'MANAGER.LAZY_INIT',
126+
/**
127+
* Telemetry event fired when a manager's fast path attempts to resolve
128+
* a cached global environment (cross-session cache).
129+
* Properties:
130+
* - managerLabel: string (the manager's label, e.g. 'system')
131+
* - result: 'hit' | 'miss' | 'stale' ('hit' = cached path resolved successfully,
132+
* 'miss' = no cached path, 'stale' = cached path found but resolve failed)
133+
*/
134+
GLOBAL_ENV_CACHE = 'GLOBAL_ENV.CACHE',
126135
}
127136

128137
// Map all events to their properties
@@ -428,4 +437,16 @@ export interface IEventNamePropertyMapping {
428437
toolSource: string;
429438
errorType?: string;
430439
};
440+
441+
/* __GDPR__
442+
"global_env.cache": {
443+
"managerLabel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
444+
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
445+
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
446+
}
447+
*/
448+
[EventNames.GLOBAL_ENV_CACHE]: {
449+
managerLabel: string;
450+
result: 'hit' | 'miss' | 'stale';
451+
};
431452
}

src/managers/builtin/cache.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { ENVS_EXTENSION_ID } from '../../common/constants';
2-
import { getWorkspacePersistentState } from '../../common/persistentState';
2+
import { getGlobalPersistentState, getWorkspacePersistentState } from '../../common/persistentState';
33

44
export const SYSTEM_WORKSPACE_KEY = `${ENVS_EXTENSION_ID}:system:WORKSPACE_SELECTED`;
55
export const SYSTEM_GLOBAL_KEY = `${ENVS_EXTENSION_ID}:system:GLOBAL_SELECTED`;
66

77
export async function clearSystemEnvCache(): Promise<void> {
8-
const keys = [SYSTEM_WORKSPACE_KEY, SYSTEM_GLOBAL_KEY];
9-
const state = await getWorkspacePersistentState();
10-
await state.clear(keys);
8+
const workspaceState = await getWorkspacePersistentState();
9+
await workspaceState.clear([SYSTEM_WORKSPACE_KEY]);
10+
const globalState = await getGlobalPersistentState();
11+
await globalState.clear([SYSTEM_GLOBAL_KEY]);
1112
}
1213

1314
export async function getSystemEnvForWorkspace(fsPath: string): Promise<string | undefined> {
@@ -48,11 +49,11 @@ export async function setSystemEnvForWorkspaces(fsPath: string[], envPath: strin
4849
}
4950

5051
export async function getSystemEnvForGlobal(): Promise<string | undefined> {
51-
const state = await getWorkspacePersistentState();
52+
const state = await getGlobalPersistentState();
5253
return await state.get(SYSTEM_GLOBAL_KEY);
5354
}
5455

5556
export async function setSystemEnvForGlobal(envPath: string | undefined): Promise<void> {
56-
const state = await getWorkspacePersistentState();
57+
const state = await getGlobalPersistentState();
5758
await state.set(SYSTEM_GLOBAL_KEY, envPath);
5859
}

src/managers/builtin/sysPythonManager.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ export class SysPythonManager implements EnvironmentManager {
115115
const discard = this.collection.map((c) => c);
116116

117117
// hit here is fine...
118-
this.collection = (await refreshPythons(hardRefresh, this.nativeFinder, this.api, this.log, this)) ?? [];
118+
this.collection =
119+
(await refreshPythons(hardRefresh, this.nativeFinder, this.api, this.log, this)) ?? [];
119120
await this.loadEnvMap();
120121

121122
const args = [
@@ -155,6 +156,7 @@ export class SysPythonManager implements EnvironmentManager {
155156
label: 'system',
156157
getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s),
157158
getPersistedPath: (fsPath) => getSystemEnvForWorkspace(fsPath),
159+
getGlobalPersistedPath: () => getSystemEnvForGlobal(),
158160
resolve: (p) => resolveSystemPythonEnvironmentPath(p, this.nativeFinder, this.api, this),
159161
startBackgroundInit: () => this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering),
160162
});

src/managers/common/fastPath.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import { Uri } from 'vscode';
55
import { GetEnvironmentScope, PythonEnvironment, PythonEnvironmentApi } from '../../api';
66
import { traceError, traceVerbose, traceWarn } from '../../common/logging';
7+
import { StopWatch } from '../../common/stopWatch';
8+
import { EventNames } from '../../common/telemetry/constants';
9+
import { sendTelemetryEvent } from '../../common/telemetry/sender';
710
import { createDeferred, Deferred } from '../../common/utils/deferred';
811

912
/**
@@ -26,6 +29,8 @@ export interface FastPathOptions {
2629
resolve: (persistedPath: string) => Promise<PythonEnvironment | undefined>;
2730
/** Starts background initialization (full discovery). Returns a promise that completes when init is done. */
2831
startBackgroundInit: () => Promise<void> | Thenable<void>;
32+
/** Optional: reads the persisted env path for global scope (when scope is undefined). */
33+
getGlobalPersistedPath?: () => Promise<string | undefined>;
2934
}
3035

3136
/**
@@ -52,7 +57,10 @@ export function getProjectFsPathForScope(api: Pick<PythonEnvironmentApi, 'getPyt
5257
* to fall through to the normal init path.
5358
*/
5459
export async function tryFastPathGet(opts: FastPathOptions): Promise<FastPathResult | undefined> {
55-
if (!(opts.scope instanceof Uri)) {
60+
const isGlobalScope = !(opts.scope instanceof Uri);
61+
62+
// Global scope is only supported when the caller provides getGlobalPersistedPath
63+
if (isGlobalScope && !opts.getGlobalPersistedPath) {
5664
return undefined;
5765
}
5866

@@ -82,22 +90,52 @@ export async function tryFastPathGet(opts: FastPathOptions): Promise<FastPathRes
8290
}
8391
}
8492

85-
const fsPath = opts.getProjectFsPath(opts.scope);
86-
const persistedPath = await opts.getPersistedPath(fsPath);
93+
// Look up the persisted path — either from workspace cache or global cache
94+
const persistedPath = isGlobalScope
95+
? await opts.getGlobalPersistedPath!()
96+
: await opts.getPersistedPath(opts.getProjectFsPath(opts.scope as Uri));
97+
98+
// Track cross-session cache performance for global scope
99+
const cacheStopWatch = isGlobalScope ? new StopWatch() : undefined;
87100

88101
if (persistedPath) {
89102
try {
90103
const resolved = await opts.resolve(persistedPath);
91104
if (resolved) {
105+
if (isGlobalScope && cacheStopWatch) {
106+
sendTelemetryEvent(EventNames.GLOBAL_ENV_CACHE, cacheStopWatch.elapsedTime, {
107+
managerLabel: opts.label,
108+
result: 'hit',
109+
});
110+
}
92111
return { env: resolved };
93112
}
113+
// Cached path found but resolve returned undefined (e.g., Python was uninstalled)
114+
if (isGlobalScope && cacheStopWatch) {
115+
sendTelemetryEvent(EventNames.GLOBAL_ENV_CACHE, cacheStopWatch.elapsedTime, {
116+
managerLabel: opts.label,
117+
result: 'stale',
118+
});
119+
}
94120
} catch (err) {
121+
if (isGlobalScope && cacheStopWatch) {
122+
sendTelemetryEvent(EventNames.GLOBAL_ENV_CACHE, cacheStopWatch.elapsedTime, {
123+
managerLabel: opts.label,
124+
result: 'stale',
125+
});
126+
}
95127
traceWarn(
96128
`[${opts.label}] Fast path resolve failed for '${persistedPath}', falling back to full init:`,
97129
err,
98130
);
99131
}
100132
} else {
133+
if (isGlobalScope && cacheStopWatch) {
134+
sendTelemetryEvent(EventNames.GLOBAL_ENV_CACHE, cacheStopWatch.elapsedTime, {
135+
managerLabel: opts.label,
136+
result: 'miss',
137+
});
138+
}
101139
traceVerbose(`[${opts.label}] Fast path: no persisted path, falling through to slow path`);
102140
}
103141

src/test/managers/common/fastPath.unit.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import * as path from 'path';
66
import * as sinon from 'sinon';
77
import { Uri } from 'vscode';
88
import { PythonEnvironment } from '../../../api';
9+
import { EventNames } from '../../../common/telemetry/constants';
10+
import * as telemetrySender from '../../../common/telemetry/sender';
911
import { createDeferred } from '../../../common/utils/deferred';
1012
import { FastPathOptions, tryFastPathGet } from '../../../managers/common/fastPath';
1113

@@ -47,22 +49,121 @@ function createOpts(overrides?: Partial<FastPathOptions>): FastPathTestOptions {
4749
}
4850

4951
suite('tryFastPathGet', () => {
52+
let sendTelemetryStub: sinon.SinonStub;
53+
54+
setup(() => {
55+
sendTelemetryStub = sinon.stub(telemetrySender, 'sendTelemetryEvent');
56+
});
57+
58+
teardown(() => {
59+
sinon.restore();
60+
});
61+
5062
test('returns resolved env when persisted path exists and init not started', async () => {
5163
const { opts } = createOpts();
5264
const result = await tryFastPathGet(opts);
5365

5466
assert.ok(result, 'Should return a result');
5567
assert.strictEqual(result!.env.envId.id, 'test-env');
68+
assert.ok(sendTelemetryStub.notCalled, 'Should not emit global cache telemetry for workspace scope');
5669
});
5770

58-
test('returns undefined when scope is undefined', async () => {
71+
test('returns undefined when scope is undefined and no getGlobalPersistedPath', async () => {
5972
const { opts } = createOpts({ scope: undefined });
6073
const result = await tryFastPathGet(opts);
6174

6275
assert.strictEqual(result, undefined);
6376
assert.ok((opts.getPersistedPath as sinon.SinonStub).notCalled);
6477
});
6578

79+
test('returns resolved env for global scope when getGlobalPersistedPath returns a path', async () => {
80+
const globalPath = path.resolve('usr', 'bin', 'python3');
81+
const resolve = sinon.stub().resolves(createMockEnv(globalPath));
82+
const { opts } = createOpts({
83+
scope: undefined,
84+
getGlobalPersistedPath: sinon.stub().resolves(globalPath),
85+
resolve,
86+
});
87+
const result = await tryFastPathGet(opts);
88+
89+
assert.ok(result, 'Should return a result for global scope');
90+
assert.strictEqual(result!.env.envId.id, 'test-env');
91+
assert.ok(resolve.calledOnceWith(globalPath), 'Should resolve the global persisted path');
92+
assert.ok((opts.getPersistedPath as sinon.SinonStub).notCalled, 'Should not call workspace getPersistedPath');
93+
94+
// Verify cache hit telemetry
95+
assert.ok(sendTelemetryStub.calledOnce, 'Should send telemetry for global cache hit');
96+
const [eventName, , props] = sendTelemetryStub.firstCall.args;
97+
assert.strictEqual(eventName, EventNames.GLOBAL_ENV_CACHE);
98+
assert.strictEqual(props.result, 'hit');
99+
assert.strictEqual(props.managerLabel, 'test');
100+
});
101+
102+
test('returns undefined for global scope when getGlobalPersistedPath returns undefined', async () => {
103+
const { opts } = createOpts({
104+
scope: undefined,
105+
getGlobalPersistedPath: sinon.stub().resolves(undefined),
106+
});
107+
const result = await tryFastPathGet(opts);
108+
109+
assert.strictEqual(result, undefined);
110+
111+
// Verify cache miss telemetry
112+
assert.ok(sendTelemetryStub.calledOnce, 'Should send telemetry for global cache miss');
113+
const [eventName, , props] = sendTelemetryStub.firstCall.args;
114+
assert.strictEqual(eventName, EventNames.GLOBAL_ENV_CACHE);
115+
assert.strictEqual(props.result, 'miss');
116+
});
117+
118+
test('reports stale when global cached path resolves to undefined', async () => {
119+
const globalPath = path.resolve('usr', 'bin', 'python3');
120+
const { opts } = createOpts({
121+
scope: undefined,
122+
getGlobalPersistedPath: sinon.stub().resolves(globalPath),
123+
resolve: sinon.stub().resolves(undefined),
124+
});
125+
const result = await tryFastPathGet(opts);
126+
127+
assert.strictEqual(result, undefined, 'Should fall through when cached env resolves to undefined');
128+
assert.ok(sendTelemetryStub.calledOnce, 'Should send telemetry for stale cache');
129+
const [eventName, , props] = sendTelemetryStub.firstCall.args;
130+
assert.strictEqual(eventName, EventNames.GLOBAL_ENV_CACHE);
131+
assert.strictEqual(props.result, 'stale');
132+
});
133+
134+
test('returns undefined for global scope when cached path resolve fails', async () => {
135+
const globalPath = path.resolve('usr', 'bin', 'python3');
136+
const { opts } = createOpts({
137+
scope: undefined,
138+
getGlobalPersistedPath: sinon.stub().resolves(globalPath),
139+
resolve: sinon.stub().rejects(new Error('python was uninstalled')),
140+
});
141+
const result = await tryFastPathGet(opts);
142+
143+
assert.strictEqual(result, undefined, 'Should fall through when cached global env is stale');
144+
145+
// Verify cache stale telemetry
146+
assert.ok(sendTelemetryStub.calledOnce, 'Should send telemetry for stale global cache');
147+
const [eventName, , props] = sendTelemetryStub.firstCall.args;
148+
assert.strictEqual(eventName, EventNames.GLOBAL_ENV_CACHE);
149+
assert.strictEqual(props.result, 'stale');
150+
});
151+
152+
test('global scope fast path starts background init when initialized is undefined', async () => {
153+
const globalPath = path.resolve('usr', 'bin', 'python3');
154+
const startBackgroundInit = sinon.stub().resolves();
155+
const { opts, setInitialized } = createOpts({
156+
scope: undefined,
157+
getGlobalPersistedPath: sinon.stub().resolves(globalPath),
158+
startBackgroundInit,
159+
});
160+
const result = await tryFastPathGet(opts);
161+
162+
assert.ok(result, 'Should return fast-path result');
163+
assert.ok(startBackgroundInit.calledOnce, 'Should start background init for global scope');
164+
assert.ok(setInitialized.calledOnce, 'Should set initialized for global scope');
165+
});
166+
66167
test('returns undefined when init is already completed', async () => {
67168
const deferred = createDeferred<void>();
68169
deferred.resolve();

src/test/managers/fastPath.get.unit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ function createManagerCases(): ManagerCase[] {
139139
createContext: (sandbox: sinon.SinonSandbox) => {
140140
const getPersistedStub = sandbox.stub(sysCache, 'getSystemEnvForWorkspace');
141141
const resolveStub = sandbox.stub(sysUtils, 'resolveSystemPythonEnvironmentPath');
142+
sandbox.stub(sysCache, 'getSystemEnvForGlobal').resolves(undefined);
142143
sandbox.stub(sysUtils, 'refreshPythons').resolves([]);
143144
const manager = new SysPythonManager(createMockNativeFinder(), createMockApi(testUri), createMockLog());
144145
return { manager, getPersistedStub, resolveStub };

0 commit comments

Comments
 (0)