Skip to content
Open
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
57 changes: 57 additions & 0 deletions packages/databricks-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import {CustomWhenContext} from "./vscode-objs/CustomWhenContext";
import {StateStorage} from "./vscode-objs/StateStorage";
import path from "node:path";
import {existsSync} from "node:fs";
import {
FeatureId,
FeatureManager,
Expand All @@ -58,7 +59,9 @@ import {
resolveComputeFrom,
} from "./python-setup/controllers/pythonSetupDeps";
import {PythonSetupDriftManager} from "./python-setup/controllers/PythonSetupDriftManager";
import {PythonSetupAdoptionManager} from "./python-setup/controllers/PythonSetupAdoptionManager";
import {SetupCompute} from "./python-setup/controllers/PythonSetupEnvironmentSetup";
import {venvInterpreterPath} from "./python-setup/utils/venvInterpreterPath";
import {resolveCliPath} from "./python-setup/utils/setupLocalArgs";
import {
isPythonSetupEnabled,
Expand Down Expand Up @@ -1148,6 +1151,60 @@ export async function activate(
);
context.subscriptions.push(pythonSetupEntry);

// Once-per-session adoption gauge: for a project with a uv-native setup on
// record, whether its managed .venv still exists. Distinct from drift above
// (which compares compute env keys) — drift never checks that the venv is
// actually present. Measurement only, best-effort, and it derives no env key
// of its own: drift already reports env-key mismatch via python_env.drift.
const pythonSetupAdoption = new PythonSetupAdoptionManager({
projectRoot: () => {
try {
return workspaceFolderManager.activeProjectUri.fsPath;
} catch {
return undefined;
}
},
// In a multi-root workspace the single workspace-scoped setupState key
// can't be pinned to the active root, so a reading could be a spurious
// venvPresent=false; skip rather than emit an untrustworthy one. (The
// drift detector shares this single-key limitation; the real fix is the
// deferred per-project storage schema.)
isAttributable: () => (workspace.workspaceFolders?.length ?? 0) <= 1,
isVpexActive: () =>
stateStorage.get("databricks.pythonSetup.setupState") !== undefined,
getTargetType: () =>
connectionManager.serverless
? "serverless"
: connectionManager.cluster
? "cluster"
: "none",
venvExists: (root) =>
existsSync(venvInterpreterPath(path.join(root, ".venv"))),
record: (report) => telemetry.recordPythonSetupAdoption(report),
});
// A connect-time reading: report once the connection is CONNECTED (so the
// compute is attached, though it may be "none" — auth-connected with nothing
// selected is a real slice). The manager dedupes per session, so repeated
// transitions are safe; firing before connect would latch "none".
//
// Deliberately NOT fired on setup completion. A first-ever setup's state
// write is fire-and-forget and lands on a later microtask, but the setup
// controller's state event fires synchronously — so a report() there would
// still read the project as not-yet-VPEX-active and emit nothing. Such a
// session is instead measured from its next connect; its just-provisioned
// venv is already implied by python_env.setup.result = ok.
const reportAdoptionIfConnected = () => {
if (connectionManager.state === "CONNECTED") {
pythonSetupAdoption.report();
}
};
context.subscriptions.push(
connectionManager.onDidChangeState(reportAdoptionIfConnected)
);
// Cover activation while already connected (a reload with a live session),
// where onDidChangeState may not fire again.
reportAdoptionIfConnected();

const environmentCommands = new EnvironmentCommands(
featureManager,
pythonExtensionWrapper,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {expect} from "chai";
import {PythonSetupAdoption} from "../../telemetry/pythonSetupExtensions";
import {
PythonSetupAdoptionDeps,
PythonSetupAdoptionManager,
} from "./PythonSetupAdoptionManager";

function makeDeps(over: Partial<PythonSetupAdoptionDeps> = {}): {
deps: PythonSetupAdoptionDeps;
recorded: PythonSetupAdoption[];
} {
const recorded: PythonSetupAdoption[] = [];
const deps: PythonSetupAdoptionDeps = {
projectRoot: () => "/ws/project",
isAttributable: () => true,
isVpexActive: () => true,
getTargetType: () => "serverless",
venvExists: () => true,
record: (r) => recorded.push(r),
...over,
};
return {deps, recorded};
}

describe("PythonSetupAdoptionManager", () => {
it("emits the adoption gauge once for a VPEX-active project", () => {
const {deps, recorded} = makeDeps();
new PythonSetupAdoptionManager(deps).report();
expect(recorded).to.deep.equal([
{venvPresent: true, currentTargetType: "serverless"},
]);
});

it("reports an absent venv as venvPresent=false", () => {
const {deps, recorded} = makeDeps({venvExists: () => false});
new PythonSetupAdoptionManager(deps).report();
expect(recorded).to.deep.equal([
{venvPresent: false, currentTargetType: "serverless"},
]);
});

it("passes through the attached compute kind, including none", () => {
const {deps, recorded} = makeDeps({getTargetType: () => "none"});
new PythonSetupAdoptionManager(deps).report();
expect(recorded[0].currentTargetType).to.equal("none");
});

it("dedupes: repeated report() calls emit at most once per session", () => {
const {deps, recorded} = makeDeps();
const reporter = new PythonSetupAdoptionManager(deps);
reporter.report();
reporter.report();
reporter.report();
expect(recorded).to.have.length(1);
});

it("does not emit when no project root is resolvable", () => {
const {deps, recorded} = makeDeps({projectRoot: () => undefined});
new PythonSetupAdoptionManager(deps).report();
expect(recorded).to.be.empty;
});

it("does not emit when the project is not VPEX-active (no setup on record)", () => {
const {deps, recorded} = makeDeps({isVpexActive: () => false});
new PythonSetupAdoptionManager(deps).report();
expect(recorded).to.be.empty;
});

it("does not emit when attribution is ambiguous (multi-root workspace)", () => {
// The shared setupState key could belong to another root, so emitting
// here would risk a spurious venvPresent=false. It must not latch either,
// so the workspace becoming single-root later can still report.
let attributable = false;
const {deps, recorded} = makeDeps({
isAttributable: () => attributable,
});
const reporter = new PythonSetupAdoptionManager(deps);
reporter.report();
expect(recorded).to.be.empty;
attributable = true;
reporter.report();
expect(recorded).to.have.length(1);
});

it("re-checks VPEX-active on later calls until it can emit, then dedupes", () => {
// A setup can complete mid-session: the not-active early return must not
// latch the dedup, or a project that becomes VPEX-active would never be
// reported.
let active = false;
const {deps, recorded} = makeDeps({isVpexActive: () => active});
const reporter = new PythonSetupAdoptionManager(deps);
reporter.report();
expect(recorded).to.be.empty;
active = true;
reporter.report();
reporter.report();
expect(recorded).to.have.length(1);
});

it("emits per distinct project root (multi-root)", () => {
let root = "/ws/a";
const {deps, recorded} = makeDeps({projectRoot: () => root});
const reporter = new PythonSetupAdoptionManager(deps);
reporter.report();
root = "/ws/b";
reporter.report();
expect(recorded).to.have.length(2);
});

it("is best-effort: a throwing seam never propagates into the caller", () => {
const {deps, recorded} = makeDeps({
venvExists: () => {
throw new Error("fs blew up");
},
});
const reporter = new PythonSetupAdoptionManager(deps);
expect(() => reporter.report()).to.not.throw();
expect(recorded).to.be.empty;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {TargetCompute} from "../../telemetry/constants";
import {PythonSetupAdoption} from "../../telemetry/pythonSetupExtensions";

export interface PythonSetupAdoptionDeps {
/** The active project's root, or undefined when none is resolvable. */
projectRoot: () => string | undefined;
/**
* Whether the persisted setup state can be attributed to the active project.
* `setupState` is a single workspace-scoped key with no per-project
* namespacing, so in a multi-root workspace it may belong to a sibling root:
* checking a different root's `.venv` against it would emit a spurious
* `venvPresent: false` and inflate the denominator. False there suppresses the
* reading rather than emit an untrustworthy one.
*/
isAttributable: () => boolean;
/**
* Whether a uv-native setup is on record for the workspace (the persisted
* `databricks.pythonSetup.setupState` exists). The gauge is emitted only
* when true, so the event's presence is the adoption-rate denominator.
*/
isVpexActive: () => boolean;
/** The compute kind attached right now (cluster / serverless / none). */
getTargetType: () => TargetCompute;
/** Whether the project's managed `.venv` interpreter exists on disk. */
venvExists: (root: string) => boolean;
/** Emit the gauge. Wraps telemetry; must be best-effort (never throw). */
record: (report: PythonSetupAdoption) => void;
}

/**
* Emits the once-per-session {@link PythonSetupAdoption} gauge: for a project
* that has a uv-native Python setup on record, whether its managed `.venv` is
* still present and what compute is attached. Purely a measurement — it reads
* state and records, never changing the flow it observes.
*
* Deliberately thin: the caller fires {@link report} on a trigger where the
* compute is known (a `CONNECTED` transition), and this dedupes so repeats are
* safe. Distinct from {@link PythonSetupDriftManager}, which measures compute
* env-key drift; this measures whether the environment still exists at all.
*/
export class PythonSetupAdoptionManager {
/** Project roots already reported this session, to emit at most once each. */
private readonly reported = new Set<string>();

constructor(private readonly deps: PythonSetupAdoptionDeps) {}

/**
* Read and emit the gauge for the active project if it is VPEX-active and
* has not been reported yet this session. Wrapped so telemetry can never
* throw into the caller (best-effort); the reported latch is set only after
* a successful record, so a transient read failure retries next time rather
* than silently swallowing the session's one reading.
*/
report(): void {
try {
const root = this.deps.projectRoot();
if (root === undefined || this.reported.has(root)) {
return;
}
// Ambiguous attribution (multi-root workspace): the shared setupState
// key may describe a different root, so a reading here could be a
// spurious venvPresent=false. Suppress without latching.
if (!this.deps.isAttributable()) {
return;
}
// Not VPEX-active: no setup on record, so there is nothing to gauge.
// Do NOT latch — a setup completing later this session should still
// get reported on a subsequent call.
if (!this.deps.isVpexActive()) {
return;
}
this.deps.record({
venvPresent: this.deps.venvExists(root),
currentTargetType: this.deps.getTargetType(),
});
this.reported.add(root);
} catch {
// Measurement must never break the observed flow. A throw here (a
// failed read, a telemetry error) is swallowed and, because the latch
// was not set, retried on the next trigger.
}
}
}
55 changes: 55 additions & 0 deletions packages/databricks-vscode/src/telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,61 @@ this flow: its `explicit_command` trigger fires only from the _legacy_
`databricks.environment.setupPythonEnv`, and the config view renders the two
mutually exclusively. A user who sees this entry never emits that event.

## Python environment adoption (VPEX)

`python_env.adoption`, emitted by `pythonSetupExtensions.ts`
(`recordPythonSetupAdoption`) and driven by
`python-setup/controllers/PythonSetupAdoptionManager.ts`. A once-per-session
gauge, read on the first `CONNECTED` transition (so a compute is attached — possibly
`none`, i.e. auth-connected with nothing selected) and deduped per project root.

It is deliberately **not** fired on setup completion. A first-ever setup's state
write is fire-and-forget and lands on a later microtask, while the setup
controller's state event fires synchronously — so a reading taken there would still
see the project as not-yet-VPEX-active and emit nothing. Such a session is measured
from its next connect instead; the venv it just provisioned is already implied by a
`python_env.setup.result` with `outcome: ok`.

### Why it is emitted only when VPEX-active

The event is recorded only for a project that has a uv-native setup on record
(`databricks.pythonSetup.setupState` is present). That gate is deliberate: the
event's mere presence is the **denominator** — one reading per session per project
that ever completed a setup — so `venvPresent` over all such events is a true
adoption rate, not a count without a base.

### Why it is separate from `python_env.drift`

Drift (from the drift detector) compares the selected compute's environment key
against the recorded one. It never checks that the `.venv` still exists: a user who
deletes the environment while compute is unchanged is not "drifted", yet has plainly
stopped using the managed env. `venvPresent` measures exactly that — whether the
managed interpreter is still on disk — which is orthogonal to drift. `venvPresent:
false` is a real value (the env is gone), not an omitted-because-unknown field.

### Why it derives no environment key

This event reports the compute _kind_ (`currentTargetType`) only, read straight from
the connection — it never derives an environment key. The CLI is the authority on env
keys (resolved via a `--dry-run`), and the drift detector already emits
`python_env.drift` off that authoritative value; deriving a key here would be a
second, divergent source of truth.

### Multi-root workspaces are skipped

`setupState` is a single workspace-scoped key with no per-project namespacing, so in
a multi-root workspace it can't be pinned to the active root: a never-set-up sibling
root would emit a spurious `venvPresent: false` and inflate the denominator. So the
gauge is skipped when the workspace has more than one root, rather than record an
untrustworthy reading.

The one-root guard is a heuristic, not proof of provenance — the key records no root,
so a rare edge (a multi-root workspace reduced to one root mid-session, leaving the
prior root's key) can still mis-attribute. Eliminating that needs the per-project
storage schema, deferred (the drift detector shares the single-key limitation and
does not even guard multi-root). Multi-root Databricks workspaces are uncommon, so
the residual skew is negligible.

## Privacy

Only categorical/enum values and durations — no file paths, cluster names or IDs,
Expand Down
23 changes: 23 additions & 0 deletions packages/databricks-vscode/src/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export enum Events {
PYTHON_ENV_SETUP_ATTEMPT = "python_env.setup.attempt",
PYTHON_ENV_SETUP_RESULT = "python_env.setup.result",
PYTHON_ENV_DRIFT = "python_env.drift",
PYTHON_ENV_ADOPTION = "python_env.adoption",
AITOOLS_INSTALL = "aitoolsInstall",
AITOOLS_UPDATE = "aitoolsUpdate",
AITOOLS_UNINSTALL = "aitoolsUninstall",
Expand Down Expand Up @@ -594,6 +595,28 @@ export class EventTypes {
'vocabulary as fromEnvKey (else "other")',
},
};
[Events.PYTHON_ENV_ADOPTION]: EventType<{
venvPresent: boolean;
currentTargetType: TargetCompute;
}> = {
comment:
"A once-per-session adoption gauge for a project that has a uv-native Python setup on " +
"record (databricks.pythonSetup.setupState is present). Emitted only in that case, so " +
"its mere presence is a per-session denominator of VPEX-managed projects; it then " +
"reports whether the managed environment is still in place. Distinct from " +
"python_env.drift, which compares compute env keys — this reports whether the .venv " +
"still physically exists, which drift never checks. Categorical/boolean data only.",
venvPresent: {
comment:
"Whether the project's managed .venv interpreter still exists on disk. False means " +
"the environment was provisioned once but is now gone (deleted or never restored)",
},
currentTargetType: {
comment:
"The compute kind attached when the session check ran (cluster | serverless | " +
"none), so adoption can be sliced by compute. No cluster IDs or names",
},
};
}

/**
Expand Down
Loading
Loading