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
28 changes: 25 additions & 3 deletions packages/databricks-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {BundleVariableTreeDataProvider} from "./ui/bundle-variables/BundleVariab
import {ConfigurationTreeViewManager} from "./ui/configuration-view/ConfigurationTreeViewManager";
import {getCLIDependenciesEnvVars} from "./utils/envVarGenerators";
import {EnvironmentCommands} from "./language/EnvironmentCommands";
import {PackageManagerTelemetry} from "./language/PackageManagerTelemetry";
import {WorkspaceFolderManager} from "./vscode-objs/WorkspaceFolderManager";
import {SyncCommands} from "./sync/SyncCommands";
import {CodeSynchronizer} from "./sync";
Expand Down Expand Up @@ -335,6 +336,24 @@ export async function activate(
customWhenContext,
telemetry
);
const packageManagerTelemetry = new PackageManagerTelemetry(
telemetry,
pythonExtensionWrapper,
() => {
try {
return workspaceFolderManager.activeProjectUri.fsPath;
} catch (e) {
return undefined;
}
},
() => {
if (connectionManager.serverless) {
return "serverless";
}
return connectionManager.cluster ? "cluster" : "none";
},
() => connectionManager.state === "CONNECTED"
);
context.subscriptions.push(
bundleFileWatcher,
bundleValidateModel,
Expand Down Expand Up @@ -619,13 +638,15 @@ export async function activate(
connectionManager,
pythonExtensionWrapper,
environmentDependenciesInstaller,
configureAutocomplete
configureAutocomplete,
packageManagerTelemetry
)
);
const environmentCommands = new EnvironmentCommands(
featureManager,
pythonExtensionWrapper,
environmentDependenciesInstaller
environmentDependenciesInstaller,
packageManagerTelemetry
);
context.subscriptions.push(
telemetry.registerCommand(
Expand Down Expand Up @@ -1003,7 +1024,8 @@ export async function activate(
featureManager,
context,
customWhenContext,
telemetry
telemetry,
packageManagerTelemetry
);
const debugFactory = new DatabricksDebugAdapterFactory(
connectionManager,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ import {Cluster} from "../sdk-extensions";
import {EnvironmentDependenciesInstaller} from "./EnvironmentDependenciesInstaller";
import {Environment} from "./MsPythonExtensionApi";
import {environmentName} from "../utils/environmentUtils";
import {PackageManagerTelemetry} from "./PackageManagerTelemetry";

export class EnvironmentCommands {
constructor(
private featureManager: FeatureManager,
private pythonExtension: MsPythonExtensionWrapper,
private installer: EnvironmentDependenciesInstaller
private installer: EnvironmentDependenciesInstaller,
private packageManagerTelemetry: PackageManagerTelemetry
) {}

async setup(stepId?: string) {
commands.executeCommand("configurationView.focus");
void this.packageManagerTelemetry.emitDetection("explicit_command");
await window.withProgress(
{location: {viewId: "configurationView"}},
() => this._setup(stepId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {ResolvedEnvironment} from "./MsPythonExtensionApi";
import {NamedLogger} from "@databricks/sdk-experimental/dist/logging";
import {ConfigureAutocomplete} from "./ConfigureAutocomplete";
import {workspaceConfigs} from "../vscode-objs/WorkspaceConfigs";
import {PackageManagerTelemetry} from "./PackageManagerTelemetry";

export class EnvironmentDependenciesVerifier extends MultiStepAccessVerifier {
private readonly logger = NamedLogger.getOrCreate(Loggers.Extension);
Expand All @@ -18,7 +19,8 @@ export class EnvironmentDependenciesVerifier extends MultiStepAccessVerifier {
private readonly connectionManager: ConnectionManager,
private readonly pythonExtension: MsPythonExtensionWrapper,
private readonly installer: EnvironmentDependenciesInstaller,
private readonly configureAutocomplete: ConfigureAutocomplete
private readonly configureAutocomplete: ConfigureAutocomplete,
private readonly packageManagerTelemetry: PackageManagerTelemetry
) {
super([
"checkCluster",
Expand Down Expand Up @@ -404,6 +406,10 @@ export class EnvironmentDependenciesVerifier extends MultiStepAccessVerifier {

override async check() {
await this.connectionManager.waitForConnect();
// Emit package-manager detection only once connected (waitForConnect
// resolves on CONNECTED), so unauthenticated sessions are not reported.
// Deduplicated per session; never throws.
void this.packageManagerTelemetry.emitDetection("auto_open");
await Promise.all([
this.checkCluster(this.connectionManager.cluster),
this.checkWorkspaceHasUc(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import {expect} from "chai";
import * as tmp from "tmp";
import path from "node:path";
import {writeFileSync} from "node:fs";
import {Telemetry} from "../telemetry";
import {MsPythonExtensionWrapper} from "./MsPythonExtensionWrapper";
import {PackageManagerTelemetry, SetupTrigger} from "./PackageManagerTelemetry";

type RecordedEvent = {
name: string;
props: Record<string, string>;
metrics: Record<string, number>;
};

/** A Telemetry backed by a fake reporter that captures sent events. */
function makeTelemetry(level: "all" | "error" | "crash" | "off" = "all") {
const events: RecordedEvent[] = [];
const reporter = {
telemetryLevel: level,
sendTelemetryEvent: (
name: string,
props?: Record<string, string>,
metrics?: Record<string, number>
) => {
events.push({name, props: props ?? {}, metrics: metrics ?? {}});
},
sendTelemetryErrorEvent: () => {},
sendDangerousTelemetryEvent: () => {},
sendDangerousTelemetryErrorEvent: () => {},
dispose: () => Promise.resolve(),
};
return {telemetry: new Telemetry(reporter as any), events};
}

describe(__filename, () => {
const cleanups: Array<() => void> = [];

afterEach(() => {
while (cleanups.length) {
cleanups.pop()!();
}
});

/**
* Create a throwaway project dir populated with the given files, passed as
* [name, contents] tuples (file names aren't valid identifiers, so a tuple
* list avoids object-literal key lint noise).
*/
function makeProject(files: Array<[string, string]>): string {
const dir = tmp.dirSync({unsafeCleanup: true});
cleanups.push(dir.removeCallback);
for (const [name, contents] of files) {
writeFileSync(path.join(dir.name, name), contents);
}
return dir.name;
}

// Interpreter is irrelevant to these disk-signal tests; report none.
const noInterpreter = {
get pythonEnvironment() {
return Promise.resolve(undefined);
},
} as unknown as MsPythonExtensionWrapper;

function makePmt(
telemetry: Telemetry,
opts: {
projectRoot: string;
compute?: "cluster" | "serverless" | "none";
connected?: boolean;
}
) {
return new PackageManagerTelemetry(
telemetry,
noInterpreter,
() => opts.projectRoot,
() => opts.compute ?? "none",
() => opts.connected ?? true
);
}

const emit = async (pmt: PackageManagerTelemetry, t: SetupTrigger) =>
pmt.emitDetection(t);

it("emits a detection event for a connected project (uv + pip)", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([
["uv.lock", "version = 1\n"],
["pyproject.toml", "[project]\nname='x'\n[tool.uv]\n"],
["requirements-dev.txt", "requests\n"],
]);
const pmt = makePmt(telemetry, {projectRoot, compute: "cluster"});

await emit(pmt, "explicit_command");

expect(events).to.have.length(1);
const e = events[0];
expect(e.name).to.equal("python_env.setup.detected");
expect(e.props["event.primaryManager"]).to.equal("uv");
expect(e.props["event.managersDetected"]).to.equal('["uv","pip"]');
expect(e.props["event.hasLockfile"]).to.equal("true");
expect(e.props["event.targetCompute"]).to.equal("cluster");
expect(e.props["event.setupTrigger"]).to.equal("explicit_command");
expect(e.props["event.interpreterSource"]).to.equal("unknown");
});

it("deduplicates per (trigger, projectRoot) within a session", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([["uv.lock", "version = 1\n"]]);
const pmt = makePmt(telemetry, {projectRoot});

await emit(pmt, "auto_open");
await emit(pmt, "auto_open");

expect(events).to.have.length(1);
});

it("does not emit while disconnected, and does not burn the dedupe slot", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([["uv.lock", "version = 1\n"]]);

const disconnected = makePmt(telemetry, {
projectRoot,
connected: false,
});
await emit(disconnected, "auto_open");
expect(events).to.have.length(0);

// A later connected emit for the same (trigger, project) still fires --
// i.e. the disconnected attempt did not consume the dedupe key.
const connected = makePmt(telemetry, {projectRoot, connected: true});
await emit(connected, "auto_open");
expect(events).to.have.length(1);
});

it("does not emit when telemetry is disabled", async () => {
const {telemetry, events} = makeTelemetry("error");
const projectRoot = makeProject([["uv.lock", "version = 1\n"]]);
const pmt = makePmt(telemetry, {projectRoot});

await emit(pmt, "auto_open");

expect(events).to.have.length(0);
});

it("reports unknown for a project with no recognizable signals", async () => {
const {telemetry, events} = makeTelemetry("all");
// `requirementsfoo.txt` (no separator) is NOT a requirements file, so
// pip must not be attributed.
const projectRoot = makeProject([["requirementsfoo.txt", "x\n"]]);
const pmt = makePmt(telemetry, {projectRoot});

await emit(pmt, "auto_open");

expect(events).to.have.length(1);
expect(events[0].props["event.managersDetected"]).to.equal("[]");
expect(events[0].props["event.primaryManager"]).to.equal("unknown");
});

it("attributes pip from a separator-suffixed requirements file", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([
["requirements_test.txt", "pytest\n"],
]);
const pmt = makePmt(telemetry, {projectRoot});

await emit(pmt, "auto_open");

expect(events[0].props["event.managersDetected"]).to.equal('["pip"]');
expect(events[0].props["event.primaryManager"]).to.equal("pip");
});

it("does not attribute pip for a tool-only pyproject", async () => {
const {telemetry, events} = makeTelemetry("all");
// Only linter config, no [project]/[build-system] -- not a pip signal.
const projectRoot = makeProject([
["pyproject.toml", "[tool.ruff]\nline-length = 88\n"],
]);
const pmt = makePmt(telemetry, {projectRoot});

await emit(pmt, "auto_open");

expect(events[0].props["event.managersDetected"]).to.equal("[]");
expect(events[0].props["event.primaryManager"]).to.equal("unknown");
});

it("attributes pip for a pyproject with [project] and no uv/poetry", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([
["pyproject.toml", '[project]\nname = "x"\n'],
]);
const pmt = makePmt(telemetry, {projectRoot});

await emit(pmt, "auto_open");

expect(events[0].props["event.managersDetected"]).to.equal('["pip"]');
});

it("omits pythonVersion from the event when the interpreter is unknown", async () => {
const {telemetry, events} = makeTelemetry("all");
const projectRoot = makeProject([["uv.lock", "version = 1\n"]]);
const pmt = makePmt(telemetry, {projectRoot});

await emit(pmt, "auto_open");

// The key must be absent, not the string "undefined".
expect(events[0].props).to.not.have.property("event.pythonVersion");
});
});
Loading
Loading