diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 906d0161e..7e4336af4 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -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, @@ -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, @@ -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, diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupAdoptionManager.test.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupAdoptionManager.test.ts new file mode 100644 index 000000000..a86de60c1 --- /dev/null +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupAdoptionManager.test.ts @@ -0,0 +1,120 @@ +import {expect} from "chai"; +import {PythonSetupAdoption} from "../../telemetry/pythonSetupExtensions"; +import { + PythonSetupAdoptionDeps, + PythonSetupAdoptionManager, +} from "./PythonSetupAdoptionManager"; + +function makeDeps(over: Partial = {}): { + 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; + }); +}); diff --git a/packages/databricks-vscode/src/python-setup/controllers/PythonSetupAdoptionManager.ts b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupAdoptionManager.ts new file mode 100644 index 000000000..6021374be --- /dev/null +++ b/packages/databricks-vscode/src/python-setup/controllers/PythonSetupAdoptionManager.ts @@ -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(); + + 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. + } + } +} diff --git a/packages/databricks-vscode/src/telemetry/README.md b/packages/databricks-vscode/src/telemetry/README.md index c27f455e1..4e9fd9606 100644 --- a/packages/databricks-vscode/src/telemetry/README.md +++ b/packages/databricks-vscode/src/telemetry/README.md @@ -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, diff --git a/packages/databricks-vscode/src/telemetry/constants.ts b/packages/databricks-vscode/src/telemetry/constants.ts index bc04eaad4..50b3e79c1 100644 --- a/packages/databricks-vscode/src/telemetry/constants.ts +++ b/packages/databricks-vscode/src/telemetry/constants.ts @@ -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", @@ -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", + }, + }; } /** diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts index d541d1bf1..f07988316 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.test.ts @@ -466,4 +466,67 @@ describe(__filename, () => { expect(drift.props["event.toEnvKey"]).to.equal("other"); }); }); + + describe("recordPythonSetupAdoption", () => { + it("emits python_env.adoption with the gauge as boolean and compute properties", () => { + const {telemetry, events} = makeTelemetry(); + telemetry.recordPythonSetupAdoption({ + venvPresent: true, + currentTargetType: "serverless", + }); + expect(events).to.have.length(1); + expect(events[0].name).to.equal("python_env.adoption"); + expect(events[0].props).to.deep.equal({ + "version": "1.0", + // A boolean lands as a "true"/"false" property, never a metric. + "event.venvPresent": "true", + "event.currentTargetType": "serverless", + }); + expect(events[0].metrics).to.not.have.property("event.venvPresent"); + }); + + it("records an absent venv as the boolean 'false', not an omitted field", () => { + const {telemetry, events} = makeTelemetry(); + telemetry.recordPythonSetupAdoption({ + venvPresent: false, + currentTargetType: "cluster", + }); + // venvPresent=false is a real value (the venv is gone) — it must be + // emitted, not dropped like an absent optional would be. + expect(events[0].props["event.venvPresent"]).to.equal("false"); + expect(events[0].props["event.currentTargetType"]).to.equal( + "cluster" + ); + }); + + it("carries currentTargetType 'none' when no compute is attached", () => { + const {telemetry, events} = makeTelemetry(); + telemetry.recordPythonSetupAdoption({ + venvPresent: true, + currentTargetType: "none", + }); + expect(events[0].props["event.currentTargetType"]).to.equal("none"); + }); + + it("emits only the schema's fields, never extra ones on the caller's object", () => { + const {telemetry, events} = makeTelemetry(); + // A future refactor could widen the payload or route a wider object + // through this seam; the transport must stay an allowlist. + telemetry.recordPythonSetupAdoption({ + venvPresent: true, + currentTargetType: "cluster", + projectPath: "/Users/jane/projects/acme", + clusterId: "0710-142042-secretcluster", + } as any); + const serialized = JSON.stringify(events[0].props); + expect(serialized).to.not.contain("jane"); + expect(serialized).to.not.contain("acme"); + expect(serialized).to.not.contain("0710"); + expect(Object.keys(events[0].props).sort()).to.deep.equal([ + "event.currentTargetType", + "event.venvPresent", + "version", + ]); + }); + }); }); diff --git a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts index 24490ac20..529af3ac7 100644 --- a/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts +++ b/packages/databricks-vscode/src/telemetry/pythonSetupExtensions.ts @@ -8,6 +8,7 @@ import { PythonSetupMode, PythonSetupOutcome, PythonSetupRunTrigger, + TargetCompute, } from "./constants"; import {PythonSetupWarning} from "../python-setup/models/PythonSetupResult"; @@ -45,6 +46,18 @@ export interface PythonSetupDrift { toEnvKey: string; } +/** + * A once-per-session adoption reading for a project with a Python setup on + * record: whether the managed environment is still in place, and the compute + * kind attached when the reading was taken. Both categorical/boolean. + */ +export interface PythonSetupAdoption { + /** Whether the project's managed `.venv` interpreter still exists on disk. */ + venvPresent: boolean; + /** The compute kind attached at the time of the reading. */ + currentTargetType: TargetCompute; +} + /** How a setup run ended, reduced to the categorical fields we report. */ export interface PythonSetupOutcomeReport { outcome: PythonSetupOutcome; @@ -205,6 +218,15 @@ declare module "." { * constrained to the categorical envKey vocabulary before emission. */ recordPythonSetupDrift(report: PythonSetupDrift): void; + + /** + * Record the once-per-session adoption gauge for a project with a Python + * setup on record: whether its managed `.venv` still exists and the + * compute kind attached at the time. Emitted only when the project is + * VPEX-active (a setup state is persisted), so the event's presence is + * itself the adoption-rate denominator. + */ + recordPythonSetupAdoption(report: PythonSetupAdoption): void; } } @@ -304,3 +326,16 @@ Telemetry.prototype.recordPythonSetupDrift = function ( toEnvKey: categoricalEnvKey(report.toEnvKey)!, }); }; + +Telemetry.prototype.recordPythonSetupAdoption = function ( + report: PythonSetupAdoption +): void { + // Named explicitly (not spread) for the same allowlist reason as the emitters + // above. Both fields are required, so there is no optional to spread: a + // boolean becomes a "true"/"false" property and the categorical target type a + // property, per recordEvent's serialization. + this.recordEvent(Events.PYTHON_ENV_ADOPTION, { + venvPresent: report.venvPresent, + currentTargetType: report.currentTargetType, + }); +};