diff --git a/src/api/authInterceptor.ts b/src/api/authInterceptor.ts index 7f62f278c7..4341c6d3b5 100644 --- a/src/api/authInterceptor.ts +++ b/src/api/authInterceptor.ts @@ -95,7 +95,7 @@ export class AuthInterceptor implements vscode.Disposable { } this.logger.debug("Received 401 response, attempting recovery"); - return this.authTelemetry.traceAuthRecovery(async (recorder) => { + return this.authTelemetry.traceRecovery(async (recorder) => { recorder.logReceived(); // 1) OAuth refresh path. diff --git a/src/commands.ts b/src/commands.ts index e0aee69368..eb7c795b29 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,6 +19,20 @@ import { type AuthLoginOutcome, type AuthLogoutOutcome, } from "./instrumentation/auth"; +import { + DiagnosticTelemetry, + type DiagnosticTrace, +} from "./instrumentation/diagnostics"; +import { WorkspaceOperationTelemetry } from "./instrumentation/workspace"; +import { + type DevContainerMode, + type WorkspaceOpenSource, + WorkspaceOpenTelemetry, + type WorkspaceOpenTrace, + type WorkspacePickerErrorCategory, + type WorkspacePickerResult, + type WorkspacePickerSource, +} from "./instrumentation/workspaceOpen"; import { reportElapsedProgress, withCancellableProgress, @@ -68,6 +82,7 @@ interface OpenOptions { agentName?: string; folderPath?: string; openRecent?: boolean; + source?: WorkspaceOpenSource; /** When false, an absent folderPath opens a bare remote window instead of * falling back to the agent's expanded_directory. Defaults to true. */ useDefaultDirectory?: boolean; @@ -78,6 +93,32 @@ interface LoginArgs { readonly autoLogin?: boolean; } +type WorkspaceResolution = + | { + readonly status: "selected"; + readonly client: CoderApi; + readonly workspaceId: string; + } + | { readonly status: "cancelled" } + | { + readonly status: "failed"; + readonly category: WorkspacePickerErrorCategory; + }; + +type OpenWorkspaceResult = + | { readonly status: "opened"; readonly handoff: "folder" | "empty_window" } + | { readonly status: "cancelled"; readonly stage: "recent_folder_picker" }; + +class SupportBundleUnsupportedCliError extends Error { + public constructor() { + super( + "Support bundles require Coder CLI v2.10.0 or later. Please update your Coder deployment.", + ); + } +} + +const UPDATE_AND_RESTART_ACTION = "Update and Restart"; + const openDefaults = { openRecent: false, useDefaultDirectory: true, @@ -94,6 +135,8 @@ export class Commands { private readonly speedtestPanelFactory: SpeedtestPanelFactory; private readonly telemetryService: TelemetryService; private readonly authTelemetry: AuthTelemetry; + private readonly diagnosticTelemetry: DiagnosticTelemetry; + private readonly workspaceOpenTelemetry: WorkspaceOpenTelemetry; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not @@ -112,6 +155,11 @@ export class Commands { private readonly deploymentManager: DeploymentManager, ) { this.telemetryService = serviceContainer.getTelemetryService(); + this.authTelemetry = new AuthTelemetry(this.telemetryService); + this.diagnosticTelemetry = new DiagnosticTelemetry(this.telemetryService); + this.workspaceOpenTelemetry = new WorkspaceOpenTelemetry( + this.telemetryService, + ); this.logger = serviceContainer.getLogger(); this.pathResolver = serviceContainer.getPathResolver(); this.mementoManager = serviceContainer.getMementoManager(); @@ -120,7 +168,6 @@ export class Commands { this.loginCoordinator = serviceContainer.getLoginCoordinator(); this.duplicateWorkspaceIpc = serviceContainer.getDuplicateWorkspaceIpc(); this.speedtestPanelFactory = serviceContainer.getSpeedtestPanelFactory(); - this.authTelemetry = new AuthTelemetry(this.telemetryService); } /** @@ -223,8 +270,22 @@ export class Commands { * editor document. Can be triggered from the sidebar or command palette. */ public async speedTest(item?: OpenableTreeItem): Promise { + await this.diagnosticTelemetry.trace("speed_test", (telemetry) => + this.runSpeedTest(item, telemetry), + ); + } + + private async runSpeedTest( + item: OpenableTreeItem | undefined, + telemetry: DiagnosticTrace, + ): Promise { const resolved = await this.resolveClientAndWorkspace(item); - if (!resolved) { + if (resolved.status === "cancelled") { + telemetry.abort("workspace_picker"); + return; + } + if (resolved.status === "failed") { + telemetry.fail(resolved.category); return; } @@ -243,9 +304,11 @@ export class Commands { }, }); if (input === undefined) { + telemetry.abort("input"); return; } const seconds = Number(input.trim()); + telemetry.setRequestedDuration(seconds); const result = await withCancellableProgress( async ({ signal, progress }) => { @@ -278,38 +341,61 @@ export class Commands { }, ); - if (result.ok) { - try { - const parsed = parseSpeedtestResult(result.value); - this.speedtestPanelFactory.show({ - result: parsed, - rawJson: result.value, - workspaceId, - }); - } catch (err) { - this.logger.error("Failed to parse speedtest output", err); - const message = - err instanceof ZodError - ? "Speed test output did not match the expected format. Check `Output > Coder` for details." - : `Speed test returned unexpected output: ${toError(err).message}`; - vscode.window.showErrorMessage(message); + if (!result.ok) { + if (result.cancelled) { + telemetry.abort("progress"); + return; } + telemetry.fail(); + this.logger.error("Speed test failed", result.error); + vscode.window.showErrorMessage( + `Speed test failed: ${toError(result.error).message}`, + ); return; } - if (result.cancelled) { - return; + try { + const parsed = parseSpeedtestResult(result.value); + telemetry.succeedSpeedtest(parsed); + this.speedtestPanelFactory.show({ + result: parsed, + rawJson: result.value, + workspaceId, + }); + } catch (err) { + if (err instanceof ZodError || err instanceof SyntaxError) { + telemetry.fail("parse_error"); + this.logger.error("Failed to parse speedtest output", err); + vscode.window.showErrorMessage( + "Speed test output did not match the expected format. Check `Output > Coder` for details.", + ); + return; + } + telemetry.fail(); + this.logger.error("Failed to display speedtest results", err); + vscode.window.showErrorMessage( + `Speed test returned unexpected output: ${toError(err).message}`, + ); } + } - this.logger.error("Speed test failed", result.error); - vscode.window.showErrorMessage( - `Speed test failed: ${toError(result.error).message}`, + public async supportBundle(item?: OpenableTreeItem): Promise { + await this.diagnosticTelemetry.trace("support_bundle", (telemetry) => + this.runSupportBundle(item, telemetry), ); } - public async supportBundle(item?: OpenableTreeItem): Promise { + private async runSupportBundle( + item: OpenableTreeItem | undefined, + telemetry: DiagnosticTrace, + ): Promise { const resolved = await this.resolveClientAndWorkspace(item); - if (!resolved) { + if (resolved.status === "cancelled") { + telemetry.abort("workspace_picker"); + return; + } + if (resolved.status === "failed") { + telemetry.fail(resolved.category); return; } @@ -317,6 +403,7 @@ export class Commands { const outputUri = await this.promptSupportBundlePath(); if (!outputUri) { + telemetry.abort("save_dialog"); return; } @@ -325,9 +412,7 @@ export class Commands { progress.report({ message: "Resolving CLI..." }); const env = await this.resolveCliEnv(client); if (!env.featureSet.supportBundle) { - throw new Error( - "Support bundles require Coder CLI v2.10.0 or later. Please update your Coder deployment.", - ); + throw new SupportBundleUnsupportedCliError(); } progress.report({ message: "Collecting diagnostics..." }); @@ -354,27 +439,31 @@ export class Commands { }, ); - if (result.ok) { - const action = await vscode.window.showInformationMessage( - `Support bundle saved to ${result.value.fsPath}`, - "Reveal in File Explorer", - ); - if (action === "Reveal in File Explorer") { - await vscode.commands.executeCommand("revealFileInOS", result.value); + if (!result.ok) { + if (result.cancelled) { + telemetry.abort("progress"); + return; } + telemetry.fail( + result.error instanceof SupportBundleUnsupportedCliError + ? "unsupported_cli" + : undefined, + ); + this.logger.error("Support bundle failed", result.error); + vscode.window.showErrorMessage( + `Support bundle failed: ${toError(result.error).message}`, + ); return; } - if (result.cancelled) { - return; - } - - this.logger.error("Support bundle failed", result.error); - vscode.window.showErrorMessage( - `Support bundle failed: ${toError(result.error).message}`, + const action = await vscode.window.showInformationMessage( + `Support bundle saved to ${result.value.fsPath}`, + "Reveal in File Explorer", ); + if (action === "Reveal in File Explorer") { + await vscode.commands.executeCommand("revealFileInOS", result.value); + } } - private promptSupportBundlePath(): Thenable { const defaultName = `coder-support-${Math.floor(Date.now() / 1000)}.zip`; return vscode.window.showSaveDialog({ @@ -385,11 +474,18 @@ export class Commands { } public async exportTelemetry(): Promise { + await this.diagnosticTelemetry.trace("export_telemetry", (telemetry) => + this.runExportTelemetry(telemetry), + ); + } + + private async runExportTelemetry(telemetry: DiagnosticTrace): Promise { await runExportTelemetryCommand( this.pathResolver.getTelemetryPath(), this.logger, () => this.telemetryService.flush(), this.telemetryService.getContext(), + telemetry, ); } @@ -685,32 +781,66 @@ export class Commands { */ public async openFromSidebar(item: OpenableTreeItem): Promise { if (item) { - const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; - if (!baseUrl) { - throw new Error("You are not logged in"); - } + const baseUrl = this.requireExtensionBaseUrl(); if (item instanceof AgentTreeItem) { - await this.openWorkspace(baseUrl, item.workspace, item.agent, { - openRecent: true, - }); + await this.workspaceOpenTelemetry.traceOpen( + "sidebar_agent", + { workspace: item.workspace, agent: item.agent }, + (telemetry) => this.runOpenAgentItem(baseUrl, item, telemetry), + ); } else if (item instanceof WorkspaceTreeItem) { - const agents = await this.extractAgentsWithFallback(item.workspace); - const agent = await maybeAskAgent(agents); - if (!agent) { - // User declined to pick an agent. - return; - } - await this.openWorkspace(baseUrl, item.workspace, agent, { - openRecent: true, - }); + await this.workspaceOpenTelemetry.traceOpen( + "sidebar_workspace", + { workspace: item.workspace }, + (telemetry) => this.runOpenWorkspaceItem(baseUrl, item, telemetry), + ); } else { throw new TypeError("Unable to open unknown sidebar item"); } } else { // If there is no tree item, then the user manually ran this command. // Default to the regular open instead. - await this.open(); + await this.open({ source: "sidebar_fallback" }); + } + } + + private async runOpenAgentItem( + baseUrl: string, + item: AgentTreeItem, + telemetry: WorkspaceOpenTrace, + ): Promise { + const result = await this.openWorkspace( + baseUrl, + item.workspace, + item.agent, + { + openRecent: true, + }, + ); + return recordOpenResult( + telemetry, + { workspace: item.workspace, agent: item.agent }, + result, + ); + } + + private async runOpenWorkspaceItem( + baseUrl: string, + item: WorkspaceTreeItem, + telemetry: WorkspaceOpenTrace, + ): Promise { + const agents = await this.extractAgentsWithFallback(item.workspace); + const agent = await maybeAskAgent(agents); + if (!agent) { + telemetry.abort("agent_picker", { workspace: item.workspace }); + return false; } + const selection = { workspace: item.workspace, agent }; + telemetry.select(selection); + const result = await this.openWorkspace(baseUrl, item.workspace, agent, { + openRecent: true, + }); + return recordOpenResult(telemetry, selection, result); } public async openAppStatus(app: { @@ -750,6 +880,18 @@ export class Commands { * cannot be found. */ public async open(options: OpenOptions = {}): Promise { + const { source = "command", ...rest } = { ...openDefaults, ...options }; + return this.workspaceOpenTelemetry.traceOpen( + source, + undefined, + (telemetry) => this.runOpen(rest, telemetry), + ); + } + + private async runOpen( + options: Omit, + telemetry: WorkspaceOpenTrace, + ): Promise { const { workspaceOwner, workspaceName, @@ -757,39 +899,43 @@ export class Commands { folderPath, openRecent, useDefaultDirectory, - } = { ...openDefaults, ...options }; - - const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; - if (!baseUrl) { - throw new Error("You are not logged in"); - } + } = options; + const baseUrl = this.requireExtensionBaseUrl(); - let workspace: Workspace | undefined; + let workspace: Workspace; if (workspaceOwner && workspaceName) { workspace = await this.extensionClient.getWorkspaceByOwnerAndName( workspaceOwner, workspaceName, ); } else { - workspace = await this.pickWorkspace(); - if (!workspace) { - // User declined to pick a workspace. + const pick = await this.pickWorkspace("workspace_open"); + if (pick.status === "cancelled") { + telemetry.abort("workspace_picker"); + return false; + } + if (pick.status === "failed") { + telemetry.fail(pick.category); return false; } + workspace = pick.workspace; } const agents = await this.extractAgentsWithFallback(workspace); const agent = await maybeAskAgent(agents, agentName); if (!agent) { - // User declined to pick an agent. + telemetry.abort("agent_picker", { workspace }); return false; } + const selection = { workspace, agent }; + telemetry.select(selection); - return this.openWorkspace(baseUrl, workspace, agent, { + const result = await this.openWorkspace(baseUrl, workspace, agent, { folderPath, openRecent, useDefaultDirectory, }); + return recordOpenResult(telemetry, selection, result); } /** @@ -806,10 +952,32 @@ export class Commands { localWorkspaceFolder = "", localConfigFile = "", ): Promise { - const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; - if (!baseUrl) { - throw new Error("You are not logged in"); - } + const mode: DevContainerMode = localWorkspaceFolder + ? "dev_container" + : "attached_container"; + await this.workspaceOpenTelemetry.traceDevContainer(mode, () => + this.runOpenDevContainer( + workspaceOwner, + workspaceName, + workspaceAgent, + devContainerName, + devContainerFolder, + localWorkspaceFolder, + localConfigFile, + ), + ); + } + + private async runOpenDevContainer( + workspaceOwner: string, + workspaceName: string, + workspaceAgent: string, + devContainerName: string, + devContainerFolder: string, + localWorkspaceFolder: string, + localConfigFile: string, + ): Promise { + const baseUrl = this.requireExtensionBaseUrl(); const remoteAuthority = toRemoteAuthority( baseUrl, @@ -874,16 +1042,23 @@ export class Commands { showUpToDate(); return; } - const action = await vscodeProposed.window.showWarningMessage( - "Update Workspace", - { - useCustom: true, - modal: true, - detail: `Update ${createWorkspaceIdentifier(this.workspace)} to the latest version?\n\nUpdating will restart your workspace which stops any running processes and may result in the loss of unsaved work.`, - }, - "Update and Restart", + const workspaceName = createWorkspaceIdentifier(this.workspace); + const operationTelemetry = new WorkspaceOperationTelemetry( + this.telemetryService, + workspaceName, + ); + const action = await operationTelemetry.traceConfirmationPrompt(async () => + vscodeProposed.window.showWarningMessage( + "Update Workspace", + { + useCustom: true, + modal: true, + detail: `Update ${workspaceName} to the latest version?\n\nUpdating will restart your workspace which stops any running processes and may result in the loss of unsaved work.`, + }, + UPDATE_AND_RESTART_ACTION, + ), ); - if (action !== "Update and Restart") { + if (action !== UPDATE_AND_RESTART_ACTION) { return; } @@ -896,16 +1071,14 @@ export class Commands { return; } - this.logger.info( - `Updating workspace ${createWorkspaceIdentifier(this.workspace)}`, - ); + this.logger.info(`Updating workspace ${workspaceName}`); await this.mementoManager.setStartupMode("update"); await vscode.commands.executeCommand("workbench.action.reloadWindow"); } public async pingWorkspace(item?: OpenableTreeItem): Promise { const resolved = await this.resolveClientAndWorkspace(item); - if (!resolved) { + if (resolved.status !== "selected") { return; } @@ -927,36 +1100,39 @@ export class Commands { /** * Resolve the API client and workspace identifier from a sidebar item, * the currently connected workspace, or by prompting the user to pick one. - * Returns undefined if the user cancels the picker. + * Returns cancelled/failed if the user cancels the picker or the picker cannot load. */ private async resolveClientAndWorkspace( item?: OpenableTreeItem, - ): Promise<{ client: CoderApi; workspaceId: string } | undefined> { + ): Promise { if (item) { return { + status: "selected", client: this.extensionClient, workspaceId: createWorkspaceIdentifier(item.workspace), }; } if (this.workspace && this.remoteWorkspaceClient) { return { + status: "selected", client: this.remoteWorkspaceClient, workspaceId: createWorkspaceIdentifier(this.workspace), }; } - const workspace = await this.pickWorkspace({ + const pick = await this.pickWorkspace("diagnostic", { title: "Select a running workspace", initialValue: "owner:me status:running ", placeholder: "Search running workspaces...", filter: (w) => w.latest_build.status === "running", }); - if (!workspace) { - return undefined; + if (pick.status === "selected") { + return { + status: "selected", + client: this.extensionClient, + workspaceId: createWorkspaceIdentifier(pick.workspace), + }; } - return { - client: this.extensionClient, - workspaceId: createWorkspaceIdentifier(workspace), - }; + return pick; } /** Resolve a CliEnv, preferring a locally cached binary over a network fetch. */ @@ -991,19 +1167,24 @@ export class Commands { /** * Ask the user to select a workspace. Return undefined if canceled. */ - private async pickWorkspace(options?: { - title?: string; - initialValue?: string; - placeholder?: string; - filter?: (w: Workspace) => boolean; - }): Promise { + private async pickWorkspace( + source: WorkspacePickerSource, + options?: { + title?: string; + initialValue?: string; + placeholder?: string; + filter?: (w: Workspace) => boolean; + }, + ): Promise { const quickPick = vscode.window.createQuickPick(); quickPick.value = options?.initialValue ?? "owner:me "; quickPick.placeholder = options?.placeholder ?? "owner:me template:go"; quickPick.title = options?.title ?? "Connect to a workspace"; const filter = options?.filter; - let lastWorkspaces: readonly Workspace[]; + let lastWorkspaces: readonly Workspace[] = []; + let settled = false; + let fetchErrorCategory: WorkspacePickerErrorCategory | undefined; const disposables: vscode.Disposable[] = []; disposables.push( quickPick.onDidChangeValue((value) => { @@ -1013,6 +1194,7 @@ export class Commands { q: value, }) .then((workspaces) => { + fetchErrorCategory = undefined; const filtered = filter ? workspaces.workspaces.filter(filter) : workspaces.workspaces; @@ -1042,6 +1224,7 @@ export class Commands { } }) .catch((ex) => { + fetchErrorCategory = "fetch_failed"; this.logger.error("Failed to fetch workspaces", ex); if (ex instanceof CertificateError) { void ex.showNotification(); @@ -1058,26 +1241,52 @@ export class Commands { ); quickPick.show(); - return new Promise((resolve) => { - disposables.push( - quickPick.onDidHide(() => { - resolve(undefined); - }), - quickPick.onDidChangeSelection((selected) => { - if (selected.length < 1) { - return resolve(undefined); - } - const workspace = - lastWorkspaces[quickPick.items.indexOf(selected[0])]; - resolve(workspace); - }), - ); - }).finally(() => { - for (const d of disposables) { - d.dispose(); - } - quickPick.dispose(); - }); + return this.workspaceOpenTelemetry + .tracePicker( + source, + async (telemetry) => + new Promise((resolve) => { + const finish = (result: WorkspacePickerResult) => { + if (settled) { + return; + } + settled = true; + telemetry.finish(result, lastWorkspaces.length); + resolve(result); + }; + disposables.push( + quickPick.onDidHide(() => { + if (fetchErrorCategory) { + finish({ + status: "failed", + category: fetchErrorCategory, + }); + return; + } + finish({ status: "cancelled" }); + }), + quickPick.onDidChangeSelection((selected) => { + if (selected.length < 1) { + finish({ status: "cancelled" }); + return; + } + const workspace = + lastWorkspaces[quickPick.items.indexOf(selected[0])]; + if (!workspace) { + finish({ status: "cancelled" }); + return; + } + finish({ status: "selected", workspace }); + }), + ); + }), + ) + .finally(() => { + for (const d of disposables) { + d.dispose(); + } + quickPick.dispose(); + }); } /** @@ -1113,7 +1322,7 @@ export class Commands { * If provided, folderPath is always used, otherwise expanded_directory from * the agent is used. */ - async openWorkspace( + private async openWorkspace( baseUrl: string, workspace: Workspace, agent: WorkspaceAgent, @@ -1121,7 +1330,7 @@ export class Commands { OpenOptions, "folderPath" | "openRecent" | "useDefaultDirectory" > = {}, - ): Promise { + ): Promise { const { openRecent, useDefaultDirectory } = { ...openDefaults, ...options, @@ -1169,7 +1378,7 @@ export class Commands { }); if (!folderPath) { // User aborted. - return false; + return { status: "cancelled", stage: "recent_folder_picker" }; } } } @@ -1201,7 +1410,7 @@ export class Commands { // Open this in a new window! newWindow, ); - return true; + return { status: "opened", handoff: "folder" }; } // This opens the workspace without an active folder opened. @@ -1209,7 +1418,7 @@ export class Commands { remoteAuthority: remoteAuthority, reuseWindow: !newWindow, }); - return true; + return { status: "opened", handoff: "empty_window" }; } // VS Code may dismiss a non-modal info message without resolving the @@ -1245,6 +1454,19 @@ export class Commands { } } +function recordOpenResult( + telemetry: WorkspaceOpenTrace, + selection: { readonly workspace: Workspace; readonly agent: WorkspaceAgent }, + result: OpenWorkspaceResult, +): boolean { + if (result.status === "cancelled") { + telemetry.abort(result.stage, selection); + return false; + } + telemetry.handoff(result.handoff); + return true; +} + async function openFile(filePath: string): Promise { const uri = vscode.Uri.file(filePath); await vscode.window.showTextDocument(uri); diff --git a/src/core/cliCredentialManager.ts b/src/core/cliCredentialManager.ts index a6399cc1a6..245f1a5e1a 100644 --- a/src/core/cliCredentialManager.ts +++ b/src/core/cliCredentialManager.ts @@ -281,8 +281,8 @@ export class CliCredentialManager { binPath = await this.resolveKeyringBinary(url, configs, "keyringAuth"); } catch (error) { this.logger.warn("Could not resolve keyring binary for delete:", error); - span.setProperty("failure_category", "binary"); - span.markFailure(); + span.setProperty("error.type", "binary"); + span.markError(); return; } if (!binPath) { @@ -298,8 +298,8 @@ export class CliCredentialManager { throw error; } this.logger.warn("Failed to delete token via CLI:", error); - span.setProperty("failure_category", "cli"); - span.markFailure(); + span.setProperty("error.type", "cli"); + span.markError(); } } diff --git a/src/instrumentation/auth.ts b/src/instrumentation/auth.ts index c4d5f2018b..70643e66ad 100644 --- a/src/instrumentation/auth.ts +++ b/src/instrumentation/auth.ts @@ -100,7 +100,7 @@ export class AuthTelemetry { * Wraps the auth-recovery path triggered by a 401. Initial properties * cover the throw-before-callback case. */ - public traceAuthRecovery( + public traceRecovery( fn: (recorder: AuthRecoveryRecorder) => Promise, ): Promise { return this.telemetry.trace( @@ -117,9 +117,9 @@ export class AuthTelemetry { } /** - * Records `auth.login_prompted`. `auth_failed` marks the span as failure; + * Records `auth.login_prompted`. `auth_failed` marks the span as error; * other non-success reasons mark it as aborted. The reason is copied to the - * span's `reason` property on failure/abort only. + * span's `reason` property on error/abort only. */ public traceLoginPrompt( trigger: AuthLoginPromptTrigger, @@ -139,11 +139,12 @@ export class AuthTelemetry { } } -/** `auth_failed` is a real failure; user/URL dismissals are intentional aborts. */ +/** `auth_failed` is a real error; user/URL dismissals are intentional aborts. */ function recordReason(span: Span, reason: LoginPromptReason): void { span.setProperty("reason", reason); if (reason === "auth_failed") { - span.markFailure(); + span.setProperty("error.type", reason); + span.markError(); } else { span.markAborted(); } diff --git a/src/instrumentation/credentials.ts b/src/instrumentation/credentials.ts index 754b26e1a5..393d6c249e 100644 --- a/src/instrumentation/credentials.ts +++ b/src/instrumentation/credentials.ts @@ -6,13 +6,13 @@ import type { WorkspaceConfiguration } from "vscode"; import type { TelemetryReporter } from "../telemetry/reporter"; import type { Span } from "../telemetry/span"; -export type CredentialFailureCategory = "aborted" | "binary" | "cli" | "file"; +export type CredentialErrorCategory = "aborted" | "binary" | "cli" | "file"; type CredentialEvent = "auth.credential.store" | "auth.credential.clear"; /** * Wraps credential store/clear in a span carrying `keyring_enabled`, the - * `category` of storage involved, and a `failure_category` on failure. The + * `category` of storage involved, and an `error.type` on failure. The * traced operation sets `category` on the span and reports failures by * throwing a categorized error (store) or recording on the span (clear, which * is best-effort). Aborts are recorded and re-thrown so callers still unwind. @@ -47,10 +47,7 @@ export class CredentialTelemetry { try { await fn(span); } catch (error) { - span.setProperty( - "failure_category", - categorizeCredentialError(error), - ); + span.setProperty("error.type", categorizeCredentialError(error)); if (isAbortError(error)) { span.markAborted(); aborted = error; @@ -70,7 +67,7 @@ export class CredentialTelemetry { } } -function categorizeCredentialError(error: unknown): CredentialFailureCategory { +function categorizeCredentialError(error: unknown): CredentialErrorCategory { if (isAbortError(error)) { return "aborted"; } diff --git a/src/instrumentation/diagnostics.ts b/src/instrumentation/diagnostics.ts new file mode 100644 index 0000000000..0b7dfd5e6b --- /dev/null +++ b/src/instrumentation/diagnostics.ts @@ -0,0 +1,77 @@ +import { recordAborted, recordError } from "./outcomes"; + +import type { SpeedtestResult } from "@repo/shared"; + +import type { TelemetryReporter } from "../telemetry/reporter"; +import type { Span } from "../telemetry/span"; + +import type { WorkspacePickerErrorCategory } from "./workspaceOpen"; + +export type DiagnosticCommand = + | "speed_test" + | "support_bundle" + | "export_telemetry"; +export type DiagnosticErrorCategory = + | WorkspacePickerErrorCategory + | "parse_error" + | "unsupported_cli" + | "error"; +export type DiagnosticAbortStage = + | "workspace_picker" + | "input" + | "prompt" + | "save_dialog" + | "progress"; + +export interface DiagnosticTrace { + abort(stage: DiagnosticAbortStage): void; + fail(category?: DiagnosticErrorCategory): void; + setRequestedDuration(seconds: number): void; + succeedSpeedtest(result: SpeedtestResult): void; + succeedExport(format: string, eventCount: number): void; +} + +/** Emits `command.diagnostic.completed` around each diagnostic command. */ +export class DiagnosticTelemetry { + public constructor(private readonly telemetry: TelemetryReporter) {} + + public trace( + command: DiagnosticCommand, + fn: (trace: DiagnosticTrace) => Promise, + ): Promise { + return this.telemetry.trace( + "command.diagnostic.completed", + (span) => fn(new SpanDiagnosticTrace(span)), + { command }, + ); + } +} + +class SpanDiagnosticTrace implements DiagnosticTrace { + public constructor(private readonly span: Span) {} + + public abort(stage: DiagnosticAbortStage): void { + recordAborted(this.span, stage); + } + + public fail(category: DiagnosticErrorCategory = "error"): void { + recordError(this.span, category); + } + + public setRequestedDuration(seconds: number): void { + this.span.setMeasurement("requested_duration_seconds", seconds); + } + + public succeedSpeedtest(result: SpeedtestResult): void { + this.span.setMeasurement("interval_count", result.intervals.length); + this.span.setMeasurement( + "throughput_mbits", + result.overall.throughput_mbits, + ); + } + + public succeedExport(format: string, eventCount: number): void { + this.span.setProperty("format", format); + this.span.setMeasurement("event_count", eventCount); + } +} diff --git a/src/instrumentation/outcomes.ts b/src/instrumentation/outcomes.ts new file mode 100644 index 0000000000..9c844d7900 --- /dev/null +++ b/src/instrumentation/outcomes.ts @@ -0,0 +1,23 @@ +import { isAbortError } from "../error/errorUtils"; + +import type { Span } from "../telemetry/span"; + +export type AbortableErrorCategory = "aborted" | "error"; + +/** Records a categorized error without attaching the raw error details. */ +export function recordError(span: Span, category: string): void { + span.setProperty("error.type", category); + span.markError(); +} + +/** Records the stage at which the user backed out and aborts the span. */ +export function recordAborted(span: Span, stage: string): void { + span.setProperty("abort_stage", stage); + span.markAborted(); +} + +export function categorizeAbortableError( + error: unknown, +): AbortableErrorCategory { + return isAbortError(error) ? "aborted" : "error"; +} diff --git a/src/instrumentation/workspace.ts b/src/instrumentation/workspace.ts index 010deec5cf..5bc3750394 100644 --- a/src/instrumentation/workspace.ts +++ b/src/instrumentation/workspace.ts @@ -11,6 +11,7 @@ import type { } from "coder/site/src/api/typesGenerated"; import type { TelemetryReporter } from "../telemetry/reporter"; +import type { Span } from "../telemetry/span"; /** Sentinel for `from*` before any state is observed. `"unknown"` is a real server-reported value, so avoid it. */ const INITIAL_STATE = "none"; @@ -24,6 +25,9 @@ const PROVISIONING_STATUSES: ReadonlySet = new Set([ "deleting", ]); +export type WorkspacePromptAction = "start" | "update"; +export type WorkspaceUpdatePrompt = "parameters" | "confirmation"; + interface ObservedWorkspaceState { readonly status: WorkspaceStatus; readonly buildTransition: WorkspaceBuild["transition"]; @@ -162,43 +166,87 @@ export class WorkspaceOperationTelemetry { private readonly workspaceName: string, ) {} - public traceUpdateTriggered(fn: () => Promise): Promise { + public traceUpdate(fn: () => Promise): Promise { return this.telemetry.trace("workspace.update.triggered", fn, { workspaceName: this.workspaceName, }); } - public traceStartTriggered(fn: () => Promise): Promise { + public traceStart(fn: () => Promise): Promise { return this.telemetry.trace("workspace.start.triggered", fn, { workspaceName: this.workspaceName, }); } + public async traceStartPrompt( + outdated: boolean, + fn: () => Promise, + ): Promise { + return this.telemetry.trace( + "workspace.start.prompted", + async (span) => { + const action = await fn(); + if (!action) { + span.markAborted(); + return undefined; + } + span.setProperty("action", action); + return action; + }, + { workspaceName: this.workspaceName, update_offered: outdated }, + ); + } + /** * Records dismissal as `result: "aborted"`. The framework treats any throw * as `result: "error"`, so we return inside the span and rethrow outside. */ - public async traceUpdatePrompted( + public async traceParametersPrompt( fn: () => Promise, ): Promise { - let cancel: WorkspaceUpdateCancelledError | undefined; - const parameters = await this.telemetry.trace( - "workspace.update.prompted", + let cancelled: WorkspaceUpdateCancelledError | undefined; + const parameters = await this.traceUpdatePrompt( + "parameters", async (span) => { try { return await fn(); } catch (error) { if (error instanceof WorkspaceUpdateCancelledError) { span.markAborted(); - cancel = error; + cancelled = error; return []; } throw error; } }, - { workspaceName: this.workspaceName }, ); - if (cancel) throw cancel; + if (cancelled) { + throw cancelled; + } return parameters; } + + public traceConfirmationPrompt( + fn: () => Promise, + ): Promise { + return this.traceUpdatePrompt("confirmation", async (span) => { + const value = await fn(); + if (value === undefined) { + span.markAborted(); + return undefined; + } + span.setProperty("action", "update"); + return value; + }); + } + + private traceUpdatePrompt( + prompt: WorkspaceUpdatePrompt, + fn: (span: Span) => Promise, + ): Promise { + return this.telemetry.trace("workspace.update.prompted", fn, { + prompt, + workspaceName: this.workspaceName, + }); + } } diff --git a/src/instrumentation/workspaceOpen.ts b/src/instrumentation/workspaceOpen.ts new file mode 100644 index 0000000000..69e716af4e --- /dev/null +++ b/src/instrumentation/workspaceOpen.ts @@ -0,0 +1,208 @@ +import { extractAgents } from "../api/api-helper"; + +import { + type AbortableErrorCategory, + categorizeAbortableError, + recordAborted, + recordError, +} from "./outcomes"; + +import type { + Workspace, + WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; + +import type { CallerProperties } from "../telemetry/event"; +import type { TelemetryReporter } from "../telemetry/reporter"; +import type { Span } from "../telemetry/span"; + +export type WorkspaceOpenSource = + | "command" + | "sidebar_agent" + | "sidebar_workspace" + | "sidebar_fallback" + | "uri"; + +export type WorkspacePickerSource = "workspace_open" | "diagnostic"; +export type WorkspacePickerErrorCategory = "fetch_failed"; +export type WorkspaceOpenErrorCategory = + | WorkspacePickerErrorCategory + | AbortableErrorCategory; +export type WorkspacePickerResult = + | { readonly status: "selected"; readonly workspace: Workspace } + | { readonly status: "cancelled" } + | { + readonly status: "failed"; + readonly category: WorkspacePickerErrorCategory; + }; +export type DevContainerMode = "dev_container" | "attached_container"; +export type WorkspaceOpenAbortStage = + | "workspace_picker" + | "agent_picker" + | "recent_folder_picker"; + +export interface WorkspaceOpenSelection { + readonly workspace: Workspace; + readonly agent?: WorkspaceAgent; +} + +export interface WorkspacePickerTrace { + finish(result: WorkspacePickerResult, resultCount: number): void; +} + +export interface WorkspaceOpenTrace { + select(selection: WorkspaceOpenSelection): void; + abort( + stage: WorkspaceOpenAbortStage, + selection?: WorkspaceOpenSelection, + ): void; + fail(category: WorkspaceOpenErrorCategory): void; + handoff(kind: "folder" | "empty_window"): void; +} + +/** + * Emits the spans around opening a workspace: `workspace.open`, + * `workspace.picker.prompted`, and `workspace.dev_container.open`. + */ +export class WorkspaceOpenTelemetry { + public constructor(private readonly telemetry: TelemetryReporter) {} + + public traceOpen( + source: WorkspaceOpenSource, + selection: WorkspaceOpenSelection | undefined, + fn: (trace: WorkspaceOpenTrace) => Promise, + ): Promise { + return this.traceRethrowing( + "workspace.open", + { source }, + false, + async (span) => { + const trace = new SpanWorkspaceOpenTrace(span); + if (selection) { + trace.select(selection); + } + const opened = await fn(trace); + if (!opened) { + span.markAborted(); + } + return opened; + }, + ); + } + + public tracePicker( + source: WorkspacePickerSource, + fn: (trace: WorkspacePickerTrace) => Promise, + ): Promise { + return this.telemetry.trace( + "workspace.picker.prompted", + (span) => fn(new SpanWorkspacePickerTrace(span)), + { source }, + ); + } + + public async traceDevContainer( + mode: DevContainerMode, + fn: () => Promise, + ): Promise { + await this.traceRethrowing( + "workspace.dev_container.open", + { mode }, + undefined, + fn, + ); + } + + /** + * Runs `fn` inside the span, recording a thrown error as a categorized + * error without its raw details, then rethrows outside the span. + */ + private async traceRethrowing( + eventName: string, + properties: CallerProperties, + fallback: T, + fn: (span: Span) => Promise, + ): Promise { + let thrown: { readonly error: unknown } | undefined; + const result = await this.telemetry.trace( + eventName, + async (span) => { + try { + return await fn(span); + } catch (error) { + thrown = { error }; + recordError(span, categorizeAbortableError(error)); + return fallback; + } + }, + properties, + ); + if (thrown) { + throw thrown.error; + } + return result; + } +} + +class SpanWorkspacePickerTrace implements WorkspacePickerTrace { + public constructor(private readonly span: Span) {} + + public finish(result: WorkspacePickerResult, resultCount: number): void { + this.span.setMeasurement("workspace_count", resultCount); + if (result.status === "selected") { + recordWorkspaceContext(this.span, result.workspace); + return; + } + if (result.status === "failed") { + recordError(this.span, result.category); + return; + } + this.span.markAborted(); + } +} + +class SpanWorkspaceOpenTrace implements WorkspaceOpenTrace { + public constructor(private readonly span: Span) {} + + public select(selection: WorkspaceOpenSelection): void { + recordWorkspaceContext(this.span, selection.workspace, selection.agent); + } + + public abort( + stage: WorkspaceOpenAbortStage, + selection?: WorkspaceOpenSelection, + ): void { + if (selection) { + recordWorkspaceContext(this.span, selection.workspace, selection.agent); + } + recordAborted(this.span, stage); + } + + public fail(category: WorkspaceOpenErrorCategory): void { + recordError(this.span, category); + } + + public handoff(kind: "folder" | "empty_window"): void { + this.span.setProperty("handoff", kind); + } +} + +function recordWorkspaceContext( + span: Span, + workspace: Workspace, + agent?: WorkspaceAgent, +): void { + const agents = extractAgents(workspace.latest_build.resources); + span.setProperty("workspace_status", workspace.latest_build.status); + span.setProperty("workspace_outdated", workspace.outdated); + span.setMeasurement("agent_count", agents.length); + span.setMeasurement( + "connected_agent_count", + agents.filter((candidate) => candidate.status === "connected").length, + ); + if (!agent) { + return; + } + span.setProperty("agent_status", agent.status); + span.setProperty("agent_lifecycle_state", agent.lifecycle_state); +} diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index 4ce44e0fa7..c459aa5026 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -289,7 +289,7 @@ export class WorkspaceStateMachine implements vscode.Disposable { mode: this.startupMode, status: workspace.latest_build.status, }); - await this.operationTelemetry.traceStartTriggered(() => + await this.operationTelemetry.traceStart(() => startWorkspace(this.buildCliContext(workspace)), ); this.logger.info(`${workspaceName} start initiated`); @@ -309,10 +309,10 @@ export class WorkspaceStateMachine implements vscode.Disposable { status: workspace.latest_build.status, }); try { - const parameters = await this.operationTelemetry.traceUpdatePrompted(() => - collectUpdateParameters(this.workspaceClient, workspace), + const parameters = await this.operationTelemetry.traceParametersPrompt( + () => collectUpdateParameters(this.workspaceClient, workspace), ); - this.workspace = await this.operationTelemetry.traceUpdateTriggered(() => + this.workspace = await this.operationTelemetry.traceUpdate(() => updateWorkspace(this.buildCliContext(workspace), parameters), ); this.logger.info(`${workspaceName} update initiated`); @@ -337,18 +337,22 @@ export class WorkspaceStateMachine implements vscode.Disposable { workspaceName: string, outdated: boolean, ): Promise<"start" | "update" | undefined> { - const buttons = outdated ? ["Start", "Update and Start"] : ["Start"]; - const action = await vscodeProposed.window.showInformationMessage( - `The workspace ${workspaceName} is not running. How would you like to proceed?`, - { - useCustom: true, - modal: true, - }, - ...buttons, - ); - if (action === "Start") return "start"; - if (action === "Update and Start") return "update"; - return undefined; + return this.operationTelemetry.traceStartPrompt(outdated, async () => { + const buttons = outdated + ? (["Start", "Update and Start"] as const) + : (["Start"] as const); + const action = await vscodeProposed.window.showInformationMessage( + `The workspace ${workspaceName} is not running. How would you like to proceed?`, + { + useCustom: true, + modal: true, + }, + ...buttons, + ); + if (action === "Start") return "start"; + if (action === "Update and Start") return "update"; + return undefined; + }); } public getAgentId(): string | undefined { diff --git a/src/telemetry/export/command.ts b/src/telemetry/export/command.ts index 1186e1bb97..5248f3671a 100644 --- a/src/telemetry/export/command.ts +++ b/src/telemetry/export/command.ts @@ -19,6 +19,8 @@ import type { Logger } from "../../logging/logger"; import type { TelemetryContext } from "../event"; import type { FlushStatus } from "../service"; +import type { ExportFormat } from "./writers/types"; + const REVEAL_ACTION = "Reveal in File Explorer"; const PROGRESS_OPTIONS = { @@ -27,14 +29,26 @@ const PROGRESS_OPTIONS = { cancellable: true, } as const; +/** + * Outcome hooks for the caller's telemetry span. `DiagnosticTrace` satisfies + * this shape, so command callers can pass their trace directly. + */ +export interface ExportTelemetryObserver { + abort(stage: "prompt" | "progress"): void; + fail(): void; + succeedExport(format: ExportFormat, eventCount: number): void; +} + export async function runExportTelemetryCommand( telemetryDir: string, logger: Logger, flushTelemetry: () => Promise, context: TelemetryContext, + observer: ExportTelemetryObserver, ): Promise { const choice = await promptForExport(); if (!choice) { + observer.abort("prompt"); return; } @@ -53,7 +67,7 @@ export async function runExportTelemetryCommand( PROGRESS_OPTIONS, ); - await reportOutcome(result, choice, logger); + await reportOutcome(result, choice, logger, observer); } /** Wires the pipeline's host hooks to the progress UI and the logger. */ @@ -81,11 +95,14 @@ async function reportOutcome( result: ProgressResult, choice: ExportChoice, logger: Logger, + observer: ExportTelemetryObserver, ): Promise { if (!result.ok) { if (result.cancelled) { + observer.abort("progress"); return; } + observer.fail(); logger.error("Telemetry export failed", result.error); void vscode.window.showErrorMessage( `Telemetry export failed: ${toError(result.error).message}`, @@ -94,6 +111,7 @@ async function reportOutcome( } const eventCount = result.value; + observer.succeedExport(choice.format, eventCount); if (eventCount === 0) { void vscode.window.showInformationMessage( `No telemetry events found for ${choice.range.label}.`, diff --git a/src/telemetry/export/writers/otlp/records.ts b/src/telemetry/export/writers/otlp/records.ts index 11a4e93865..04c97d7f28 100644 --- a/src/telemetry/export/writers/otlp/records.ts +++ b/src/telemetry/export/writers/otlp/records.ts @@ -52,6 +52,9 @@ const AGGREGATION_TEMPORALITY_CUMULATIVE = 2; // start at 0 (INTERNAL), so shift by 1 when encoding for proto. const OTLP_SPAN_KIND_OFFSET = 1; +// OtlpStatus.code is a wire-format int, so compare against a numeric alias. +const OTLP_STATUS_ERROR: number = SpanStatusCode.ERROR; + export function envelopePrefix( envelope: EnvelopeSpec, resource: string, @@ -141,6 +144,22 @@ export function spanRecord( const startNano = endNano - nanosFromMs(event.measurements.durationMs ?? 0); const endTimeUnixNano = String(endNano); const { durationMs: _, ...measurements } = event.measurements; + const status = spanStatus(event); + const attributes: Record = { + ...event.properties, + ...measurements, + "coder.event_name": event.eventName, + ...eventContextAttributes(event), + }; + // OTel wants every error span to carry a low-cardinality error.type, so + // backfill the exception type or code, falling back to OTel's _OTHER sentinel. + if ( + status.code === OTLP_STATUS_ERROR && + attributes["error.type"] === undefined + ) { + attributes["error.type"] = + event.error?.type ?? event.error?.code ?? "_OTHER"; + } return { traceId: event.traceId, spanId: event.eventId, @@ -151,13 +170,8 @@ export function spanRecord( kind: SpanKind.INTERNAL + OTLP_SPAN_KIND_OFFSET, startTimeUnixNano: String(startNano), endTimeUnixNano, - attributes: keyValues({ - ...event.properties, - ...measurements, - "coder.event_name": event.eventName, - ...eventContextAttributes(event), - }), - status: spanStatus(event), + attributes: keyValues(attributes), + status, ...(event.error && { events: [ { @@ -174,6 +188,9 @@ function spanStatus(event: TelemetryEvent): OtlpStatus { switch (event.properties.result) { case "success": return { code: SpanStatusCode.OK }; + case "aborted": + // OTel treats intentional cancellation as non-error, so leave it unset. + return { code: SpanStatusCode.UNSET }; case "error": return { code: SpanStatusCode.ERROR, diff --git a/src/telemetry/service.ts b/src/telemetry/service.ts index f3c69501e1..86bd89361a 100644 --- a/src/telemetry/service.ts +++ b/src/telemetry/service.ts @@ -208,7 +208,7 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter { const spanMeasurements = { ...measurements }; const { traceId, traceLevel } = spanOpts; let completed = false; - // `markFailure` wins over `markAborted` regardless of call order. + // `markError` wins over `markAborted` regardless of call order. let mark: "aborted" | "error" | undefined; const warnPostEmit = (op: string, name: string): void => { this.logger.warn( @@ -298,9 +298,9 @@ export class TelemetryService implements vscode.Disposable, TelemetryReporter { } mark ??= "aborted"; }, - markFailure(): void { + markError(): void { if (completed) { - warnPostEmit("markFailure", ""); + warnPostEmit("markError", ""); return; } mark = "error"; diff --git a/src/telemetry/span.ts b/src/telemetry/span.ts index 89d1284aaa..af08c5780c 100644 --- a/src/telemetry/span.ts +++ b/src/telemetry/span.ts @@ -41,8 +41,8 @@ export interface Span { setMeasurement(name: string, value: number): void; /** Flip this span's `result` from `success` to `aborted` on normal return. */ markAborted(): void; - /** Flip `result` to `error` for a failure captured in the return value. See `SpanResult`. */ - markFailure(): void; + /** Flip `result` to `error` for an error captured in the return value. See `SpanResult`. */ + markError(): void; } /** No-op `Span` used when telemetry is off. Runs phase fns but emits nothing. */ @@ -56,7 +56,7 @@ export const NOOP_SPAN: Span = { log: () => undefined, logError: () => undefined, markAborted: () => undefined, - markFailure: () => undefined, + markError: () => undefined, setProperty: () => undefined, setMeasurement: () => undefined, }; diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 861eeff244..d6772cda83 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -86,6 +86,7 @@ async function handleOpen(ctx: UriRouteContext): Promise { agentName: agent ?? undefined, folderPath: folder ?? undefined, openRecent, + source: "uri", useDefaultDirectory: false, }); } diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index a111a5c42c..8eee245214 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -65,6 +65,20 @@ export class ThemeColor { } } +export class TreeItem { + id?: string; + contextValue?: string; + description?: string; + tooltip?: string; + command?: unknown; + iconPath?: unknown; + + constructor( + public label: string, + public collapsibleState?: number, + ) {} +} + export class Uri { constructor( public scheme: string, @@ -213,6 +227,7 @@ const vscode = { EventEmitter, MarkdownString, ThemeColor, + TreeItem, window, commands, workspace, diff --git a/test/unit/command/updateWorkspace.telemetry.test.ts b/test/unit/command/updateWorkspace.telemetry.test.ts new file mode 100644 index 0000000000..3374a85fea --- /dev/null +++ b/test/unit/command/updateWorkspace.telemetry.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { Commands } from "@/commands"; +import { MementoManager } from "@/core/mementoManager"; + +import { workspace } from "@repo/mocks"; + +import { createTelemetryHarness } from "../../mocks/telemetry"; +import { createMockLogger, InMemoryMemento } from "../../mocks/testHelpers"; + +import type { CoderApi } from "@/api/coderApi"; +import type { ServiceContainer } from "@/core/container"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; + +const UPDATE_ACTION = "Update and Restart"; + +function setup() { + const { sink, service } = createTelemetryHarness(); + const mementoManager = new MementoManager(new InMemoryMemento()); + const logger = createMockLogger(); + const container = { + getTelemetryService: () => service, + getLogger: () => logger, + getPathResolver: () => ({}), + getMementoManager: () => mementoManager, + getSecretsManager: () => ({}), + getCliManager: () => ({}), + getLoginCoordinator: () => ({}), + getDuplicateWorkspaceIpc: () => ({}), + getSpeedtestPanelFactory: () => ({}), + } as unknown as ServiceContainer; + const commands = new Commands( + container, + {} as CoderApi, + {} as DeploymentManager, + ); + commands.workspace = workspace({ outdated: true }); + commands.remoteWorkspaceClient = {} as CoderApi; + return { commands, sink }; +} + +describe("Commands.updateWorkspace", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("records an aborted update confirmation when the prompt is dismissed", async () => { + const { commands, sink } = setup(); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined); + + await commands.updateWorkspace(); + + expect(sink.expectOne("workspace.update.prompted")).toMatchObject({ + properties: { + prompt: "confirmation", + result: "aborted", + }, + }); + expect(vscode.commands.executeCommand).not.toHaveBeenCalled(); + }); + + it("records success and reloads when the update confirmation is accepted", async () => { + const { commands, sink } = setup(); + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue( + UPDATE_ACTION as never, + ); + + await commands.updateWorkspace(); + + expect(sink.expectOne("workspace.update.prompted")).toMatchObject({ + properties: { + action: "update", + prompt: "confirmation", + result: "success", + }, + }); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + "workbench.action.reloadWindow", + ); + }); +}); diff --git a/test/unit/core/cliCredentialManager.test.ts b/test/unit/core/cliCredentialManager.test.ts index 200eb8801a..91799edf96 100644 --- a/test/unit/core/cliCredentialManager.test.ts +++ b/test/unit/core/cliCredentialManager.test.ts @@ -237,7 +237,7 @@ describe("CliCredentialManager", () => { ).rejects.toThrow("Credential CLI operation failed"); expect(sink.expectOne("auth.credential.store")).toMatchObject({ properties: { - failure_category: "cli", + "error.type": "cli", result: "error", }, }); @@ -298,7 +298,7 @@ describe("CliCredentialManager", () => { ).rejects.toThrow("The operation was aborted"); expect(sink.expectOne("auth.credential.store")).toMatchObject({ properties: { - failure_category: "aborted", + "error.type": "aborted", result: "aborted", }, }); @@ -443,7 +443,7 @@ describe("CliCredentialManager", () => { ).resolves.not.toThrow(); expect(sink.expectOne("auth.credential.clear")).toMatchObject({ properties: { - failure_category: "cli", + "error.type": "cli", result: "error", }, }); @@ -460,7 +460,7 @@ describe("CliCredentialManager", () => { expect(sink.expectOne("auth.credential.clear")).toMatchObject({ properties: { category: "keyring", - failure_category: "binary", + "error.type": "binary", result: "error", }, }); @@ -513,7 +513,7 @@ describe("CliCredentialManager", () => { ).rejects.toThrow("The operation was aborted"); expect(sink.expectOne("auth.credential.clear")).toMatchObject({ properties: { - failure_category: "aborted", + "error.type": "aborted", result: "aborted", }, }); diff --git a/test/unit/instrumentation/diagnostics.test.ts b/test/unit/instrumentation/diagnostics.test.ts new file mode 100644 index 0000000000..86aeee7b20 --- /dev/null +++ b/test/unit/instrumentation/diagnostics.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { DiagnosticTelemetry } from "@/instrumentation/diagnostics"; + +import { createTelemetryHarness } from "../../mocks/telemetry"; + +function setup() { + const { sink, service } = createTelemetryHarness(); + return { sink, telemetry: new DiagnosticTelemetry(service) }; +} + +describe("DiagnosticTelemetry", () => { + it("records diagnostic cancellation and failure categories", async () => { + const { sink, telemetry } = setup(); + + await telemetry.trace("support_bundle", (trace) => { + trace.abort("save_dialog"); + return Promise.resolve(); + }); + await telemetry.trace("support_bundle", (trace) => { + trace.fail("unsupported_cli"); + return Promise.resolve(); + }); + + const [cancelled, failed] = sink.eventsNamed( + "command.diagnostic.completed", + ); + expect(cancelled.properties).toMatchObject({ + abort_stage: "save_dialog", + result: "aborted", + }); + expect(failed.properties).toMatchObject({ + "error.type": "unsupported_cli", + result: "error", + }); + expect(failed.error).toBeUndefined(); + }); + + it("records bounded speed test measurements", async () => { + const { sink, telemetry } = setup(); + + await telemetry.trace("speed_test", (trace) => { + trace.succeedSpeedtest({ + overall: { + start_time_seconds: 0, + end_time_seconds: 5, + throughput_mbits: 42, + }, + intervals: [ + { + start_time_seconds: 0, + end_time_seconds: 5, + throughput_mbits: 42, + }, + ], + }); + return Promise.resolve(); + }); + + expect(sink.expectOne("command.diagnostic.completed")).toMatchObject({ + measurements: { + interval_count: 1, + throughput_mbits: 42, + }, + properties: { result: "success" }, + }); + }); +}); diff --git a/test/unit/instrumentation/workspace.test.ts b/test/unit/instrumentation/workspace.test.ts index f219fa1c80..03bbdca3e6 100644 --- a/test/unit/instrumentation/workspace.test.ts +++ b/test/unit/instrumentation/workspace.test.ts @@ -33,11 +33,11 @@ const newAgentTelemetry = (svc: TelemetryService, name: string) => describe("WorkspaceOperationTelemetry", () => { it.each([ { - method: "traceStartTriggered" as const, + method: "traceStart" as const, event: "workspace.start.triggered", }, { - method: "traceUpdateTriggered" as const, + method: "traceUpdate" as const, event: "workspace.update.triggered", }, ])("$method emits $event with result=success", async ({ method, event }) => { @@ -52,11 +52,11 @@ describe("WorkspaceOperationTelemetry", () => { it.each([ { - method: "traceStartTriggered" as const, + method: "traceStart" as const, event: "workspace.start.triggered", }, { - method: "traceUpdateTriggered" as const, + method: "traceUpdate" as const, event: "workspace.update.triggered", }, ])("$method emits result=error and rethrows", async ({ method, event }) => { @@ -70,18 +70,58 @@ describe("WorkspaceOperationTelemetry", () => { }); }); - describe("traceUpdatePrompted", () => { + describe("traceStartPrompt", () => { + it("emits result=success with accepted action", async () => { + const { sink, instance: ops } = setup(newOps); + + const result = await ops.traceStartPrompt(true, () => + Promise.resolve("update"), + ); + + expect(result).toBe("update"); + expect(sink.expectOne("workspace.start.prompted")).toMatchObject({ + properties: { + workspaceName: WORKSPACE_NAME, + update_offered: "true", + action: "update", + result: "success", + }, + }); + }); + + it("emits result=aborted when dismissed", async () => { + const { sink, instance: ops } = setup(newOps); + + const result = await ops.traceStartPrompt(false, () => + Promise.resolve(undefined), + ); + + expect(result).toBeUndefined(); + expect(sink.expectOne("workspace.start.prompted")).toMatchObject({ + properties: { + update_offered: "false", + result: "aborted", + }, + }); + }); + }); + + describe("traceParametersPrompt", () => { it("returns the collected parameters and emits result=success", async () => { const { sink, instance: ops } = setup(newOps); const collected = [{ name: "region", value: "us-east" }]; - const result = await ops.traceUpdatePrompted(() => + const result = await ops.traceParametersPrompt(() => Promise.resolve(collected), ); expect(result).toEqual(collected); expect(sink.expectOne("workspace.update.prompted")).toMatchObject({ - properties: { workspaceName: WORKSPACE_NAME, result: "success" }, + properties: { + workspaceName: WORKSPACE_NAME, + prompt: "parameters", + result: "success", + }, }); }); @@ -90,7 +130,7 @@ describe("WorkspaceOperationTelemetry", () => { const cancel = new WorkspaceUpdateCancelledError(); await expect( - ops.traceUpdatePrompted(() => Promise.reject(cancel)), + ops.traceParametersPrompt(() => Promise.reject(cancel)), ).rejects.toBe(cancel); const event = sink.expectOne("workspace.update.prompted"); @@ -103,7 +143,7 @@ describe("WorkspaceOperationTelemetry", () => { const boom = new Error("rest call failed"); await expect( - ops.traceUpdatePrompted(() => Promise.reject(boom)), + ops.traceParametersPrompt(() => Promise.reject(boom)), ).rejects.toBe(boom); expect(sink.expectOne("workspace.update.prompted")).toMatchObject({ @@ -112,6 +152,42 @@ describe("WorkspaceOperationTelemetry", () => { }); }); }); + + describe("traceConfirmationPrompt", () => { + it("emits result=success with accepted action", async () => { + const { sink, instance: ops } = setup(newOps); + + const result = await ops.traceConfirmationPrompt(() => + Promise.resolve("Update and Restart"), + ); + + expect(result).toBe("Update and Restart"); + expect(sink.expectOne("workspace.update.prompted")).toMatchObject({ + properties: { + workspaceName: WORKSPACE_NAME, + action: "update", + prompt: "confirmation", + result: "success", + }, + }); + }); + + it("emits result=aborted when dismissed", async () => { + const { sink, instance: ops } = setup(newOps); + + const result = await ops.traceConfirmationPrompt(() => + Promise.resolve(undefined), + ); + + expect(result).toBeUndefined(); + expect(sink.expectOne("workspace.update.prompted")).toMatchObject({ + properties: { + prompt: "confirmation", + result: "aborted", + }, + }); + }); + }); }); describe("WorkspaceStateTelemetry.observe", () => { diff --git a/test/unit/instrumentation/workspaceOpen.test.ts b/test/unit/instrumentation/workspaceOpen.test.ts new file mode 100644 index 0000000000..3d3962266e --- /dev/null +++ b/test/unit/instrumentation/workspaceOpen.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { WorkspaceOpenTelemetry } from "@/instrumentation/workspaceOpen"; + +import { agent, resource, workspace } from "@repo/mocks"; + +import { createTelemetryHarness } from "../../mocks/telemetry"; + +function setup() { + const { sink, service } = createTelemetryHarness(); + return { sink, telemetry: new WorkspaceOpenTelemetry(service) }; +} + +function workspaceWithAgents() { + const connected = agent({ + status: "connected", + lifecycle_state: "ready", + }); + const disconnected = agent({ + id: "agent-2", + name: "secondary", + status: "disconnected", + lifecycle_state: "off", + }); + return { + connected, + disconnected, + workspace: workspace({ + outdated: true, + latest_build: { + status: "running", + resources: [resource({ agents: [connected, disconnected] })], + }, + }), + }; +} + +describe("WorkspaceOpenTelemetry", () => { + it("records workspace selection without workspace or agent names", async () => { + const { sink, telemetry } = setup(); + const selection = workspaceWithAgents(); + + await telemetry.traceOpen( + "command", + { workspace: selection.workspace, agent: selection.connected }, + () => Promise.resolve(true), + ); + + const event = sink.expectOne("workspace.open"); + expect(event.properties).toMatchObject({ + agent_lifecycle_state: "ready", + agent_status: "connected", + workspace_outdated: "true", + workspace_status: "running", + result: "success", + }); + expect(event.measurements).toMatchObject({ + agent_count: 2, + connected_agent_count: 1, + }); + expect(event.properties.workspaceName).toBeUndefined(); + expect(event.properties.agentName).toBeUndefined(); + }); + + it("counts every connected agent on the workspace", async () => { + const { sink, telemetry } = setup(); + const first = agent({ status: "connected", lifecycle_state: "ready" }); + const second = agent({ + id: "agent-2", + name: "secondary", + status: "connected", + lifecycle_state: "ready", + }); + const offline = agent({ + id: "agent-3", + name: "tertiary", + status: "disconnected", + lifecycle_state: "off", + }); + const selection = workspace({ + latest_build: { + status: "running", + resources: [resource({ agents: [first, second, offline] })], + }, + }); + + await telemetry.traceOpen("command", { workspace: selection }, () => + Promise.resolve(true), + ); + + const event = sink.expectOne("workspace.open"); + expect(event.measurements).toMatchObject({ + agent_count: 3, + connected_agent_count: 2, + }); + }); + + it("records workspace picker cancellation and failure distinctly", async () => { + const { sink, telemetry } = setup(); + + await telemetry.tracePicker("workspace_open", (trace) => { + const result = { status: "cancelled" } as const; + trace.finish(result, 3); + return Promise.resolve(result); + }); + await telemetry.tracePicker("workspace_open", (trace) => { + const result = { status: "failed", category: "fetch_failed" } as const; + trace.finish(result, 0); + return Promise.resolve(result); + }); + + const [cancelled, failed] = sink.eventsNamed("workspace.picker.prompted"); + expect(cancelled.properties).toMatchObject({ result: "aborted" }); + expect(cancelled.measurements.workspace_count).toBe(3); + expect(failed.properties).toMatchObject({ + "error.type": "fetch_failed", + result: "error", + }); + expect(failed.measurements.workspace_count).toBe(0); + }); + + it("records workspace open cancellation and handled failure distinctly", async () => { + const { sink, telemetry } = setup(); + const selection = workspaceWithAgents(); + + await telemetry.traceOpen("command", undefined, (trace) => { + trace.abort("agent_picker", { workspace: selection.workspace }); + return Promise.resolve(false); + }); + await telemetry.traceOpen("command", undefined, (trace) => { + trace.fail("fetch_failed"); + return Promise.resolve(false); + }); + + const [cancelled, failed] = sink.eventsNamed("workspace.open"); + expect(cancelled.properties).toMatchObject({ + abort_stage: "agent_picker", + workspace_status: "running", + result: "aborted", + }); + expect(failed.properties).toMatchObject({ + "error.type": "fetch_failed", + result: "error", + }); + }); + + it("records thrown workspace open failures without raw error details", async () => { + const { sink, telemetry } = setup(); + + await expect( + telemetry.traceOpen("command", undefined, () => + Promise.reject(new Error("secret path /tmp/workspace")), + ), + ).rejects.toThrow("secret path /tmp/workspace"); + + const event = sink.expectOne("workspace.open"); + expect(event.properties).toMatchObject({ + "error.type": "error", + result: "error", + }); + expect(event.error).toBeUndefined(); + }); + + it("records thrown devcontainer failures without raw error details", async () => { + const { sink, telemetry } = setup(); + + await expect( + telemetry.traceDevContainer("dev_container", () => + Promise.reject(new Error("secret local path /tmp/workspace")), + ), + ).rejects.toThrow("secret local path /tmp/workspace"); + + const event = sink.expectOne("workspace.dev_container.open"); + expect(event.properties).toMatchObject({ + "error.type": "error", + mode: "dev_container", + result: "error", + }); + expect(event.error).toBeUndefined(); + }); +}); diff --git a/test/unit/telemetry/export/command.test.ts b/test/unit/telemetry/export/command.test.ts index c660fb22e2..a1e02d72ed 100644 --- a/test/unit/telemetry/export/command.test.ts +++ b/test/unit/telemetry/export/command.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { runExportTelemetryCommand } from "@/telemetry/export/command"; +import { + runExportTelemetryCommand, + type ExportTelemetryObserver, +} from "@/telemetry/export/command"; import { collectTelemetryExport } from "@/telemetry/export/pipeline"; import { promptForExport, type ExportChoice } from "@/telemetry/export/prompts"; @@ -56,24 +59,34 @@ function setup( vi.mocked(collectTelemetryExport).mockResolvedValue(outcome.count); } + const observer: ExportTelemetryObserver = { + abort: vi.fn(), + fail: vi.fn(), + succeedExport: vi.fn(), + }; + return { + observer, run: () => runExportTelemetryCommand( TELEMETRY_DIR, createMockLogger(), vi.fn(() => Promise.resolve(OK_FLUSH)), context, + observer, ), }; } describe("runExportTelemetryCommand", () => { it("does nothing when the user cancels the prompts", async () => { - const { run } = setup(); + const { observer, run } = setup(); vi.mocked(promptForExport).mockResolvedValue(undefined); await run(); + expect(observer.abort).toHaveBeenCalledWith("prompt"); + expect(collectTelemetryExport).not.toHaveBeenCalled(); expect(vscode.window.withProgress).not.toHaveBeenCalled(); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); @@ -119,10 +132,12 @@ describe("runExportTelemetryCommand", () => { [1, "Exported 1 telemetry event to"], [3, "Exported 3 telemetry events to"], ])("notifies with a pluralized %i-event count", async (count, message) => { - const { run } = setup({ outcome: { count } }); + const { observer, run } = setup({ outcome: { count } }); await run(); + expect(observer.succeedExport).toHaveBeenCalledWith("json", count); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( `${message} ${OUTPUT_PATH}.`, REVEAL_ACTION, @@ -154,7 +169,7 @@ describe("runExportTelemetryCommand", () => { new Error("no command"), ); - await expect(run()).resolves.toBeUndefined(); + await run(); expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); }); @@ -162,10 +177,12 @@ describe("runExportTelemetryCommand", () => { describe("nothing to export", () => { it("reports that no events were found", async () => { - const { run } = setup({ outcome: { count: 0 } }); + const { observer, run } = setup({ outcome: { count: 0 } }); await run(); + expect(observer.succeedExport).toHaveBeenCalledWith("json", 0); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( "No telemetry events found for Last 24 hours.", ); @@ -174,9 +191,12 @@ describe("runExportTelemetryCommand", () => { describe("failure", () => { it("shows an error notification without throwing", async () => { - const { run } = setup({ outcome: { error: new Error("disk full") } }); + const error = new Error("disk full"); + const { observer, run } = setup({ outcome: { error } }); - await expect(run()).resolves.toBeUndefined(); + await run(); + + expect(observer.fail).toHaveBeenCalledOnce(); expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( "Telemetry export failed: disk full", @@ -188,10 +208,12 @@ describe("runExportTelemetryCommand", () => { const aborted = Object.assign(new Error("Aborted"), { name: "AbortError", }); - const { run } = setup({ outcome: { error: aborted } }); + const { observer, run } = setup({ outcome: { error: aborted } }); await run(); + expect(observer.abort).toHaveBeenCalledWith("progress"); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); }); diff --git a/test/unit/telemetry/export/writers/otlp/__golden__/envelope-traces.json b/test/unit/telemetry/export/writers/otlp/__golden__/envelope-traces.json index 9ffdc4b11e..d1e2141574 100644 --- a/test/unit/telemetry/export/writers/otlp/__golden__/envelope-traces.json +++ b/test/unit/telemetry/export/writers/otlp/__golden__/envelope-traces.json @@ -173,6 +173,12 @@ "value": { "stringValue": "https://dev.coder.com" } + }, + { + "key": "error.type", + "value": { + "stringValue": "Error" + } } ], "status": { diff --git a/test/unit/telemetry/export/writers/otlp/__golden__/span-records.json b/test/unit/telemetry/export/writers/otlp/__golden__/span-records.json index 9a0f3ee949..d6e61005f0 100644 --- a/test/unit/telemetry/export/writers/otlp/__golden__/span-records.json +++ b/test/unit/telemetry/export/writers/otlp/__golden__/span-records.json @@ -98,6 +98,12 @@ "value": { "stringValue": "https://dev.coder.com" } + }, + { + "key": "error.type", + "value": { + "stringValue": "Error" + } } ], "status": { diff --git a/test/unit/telemetry/export/writers/otlp/records.test.ts b/test/unit/telemetry/export/writers/otlp/records.test.ts index 58cdebfa82..4487481fcd 100644 --- a/test/unit/telemetry/export/writers/otlp/records.test.ts +++ b/test/unit/telemetry/export/writers/otlp/records.test.ts @@ -196,6 +196,8 @@ describe("spanRecord", () => { it.each([ [{ properties: { result: "success" } }, { code: 1 }], + // Intentional cancellation stays unset per OTel. + [{ properties: { result: "aborted" } }, { code: 0 }], [{ properties: { result: "error" } }, { code: 2 }], [ { properties: { result: "error" }, error: { message: "boom" } }, @@ -208,6 +210,42 @@ describe("spanRecord", () => { expect(span.status).toEqual(expected); }); + it.each([ + // The exception type wins when present. + [ + { + properties: { result: "error" }, + error: { message: "boom", type: "TypeError" }, + }, + "TypeError", + ], + // The error code stands in when there is no type. + [ + { + properties: { result: "error" }, + error: { message: "boom", code: "ECONNREFUSED" }, + }, + "ECONNREFUSED", + ], + // Neither type nor code falls back to OTel's _OTHER sentinel. + [{ properties: { result: "error" } }, "_OTHER"], + // An instrumentation-set category is never overwritten. + [ + { properties: { result: "error", "error.type": "fetch_failed" } }, + "fetch_failed", + ], + ])("backfills error.type on error spans: %j -> %s", (overrides, expected) => { + const span = spanRecord(makeSpanEvent(overrides)); + expect(attrs(span.attributes)["error.type"]).toBe(expected); + }); + + it("never adds error.type to a non-error span", () => { + const span = spanRecord( + makeSpanEvent({ properties: { result: "success" } }), + ); + expect(attrs(span.attributes)).not.toHaveProperty("error.type"); + }); + it("attaches an `exception` event to errored spans", () => { const span = spanRecord( makeSpanEvent({ diff --git a/test/unit/telemetry/service.test.ts b/test/unit/telemetry/service.test.ts index ca5264b56e..f18f93f0c6 100644 --- a/test/unit/telemetry/service.test.ts +++ b/test/unit/telemetry/service.test.ts @@ -379,7 +379,7 @@ describe("TelemetryService", () => { expect(phase.eventName).toBe("op.bad_name"); }); - it("drops setProperty/setMeasurement/markAborted/markFailure called after emit", async () => { + it("drops setProperty/setMeasurement/markAborted/markError called after emit", async () => { let escapedSpan: Span | undefined; await h.service.trace("op", (span) => { escapedSpan = span; @@ -391,7 +391,7 @@ describe("TelemetryService", () => { escapedSpan?.setProperty("late", "ignored"); escapedSpan?.setMeasurement("lateMs", 99); escapedSpan?.markAborted(); - escapedSpan?.markFailure(); + escapedSpan?.markError(); expect(h.sink.events[0].properties.late).toBeUndefined(); expect(h.sink.events[0].measurements.lateMs).toBeUndefined(); @@ -438,7 +438,7 @@ describe("TelemetryService", () => { escapedSpan?.setProperty("late", "ignored"); escapedSpan?.setMeasurement("lateMs", 99); escapedSpan?.markAborted(); - escapedSpan?.markFailure(); + escapedSpan?.markError(); escapedSpan?.log("late_log"); escapedSpan?.logError("late_log_error", new Error("ignored")); await escapedSpan?.phase("late_phase", () => Promise.resolve()); @@ -474,9 +474,9 @@ describe("TelemetryService", () => { }); }); - it("markFailure flips result to 'error' on normal return without an error block", async () => { + it("markError flips result to 'error' on normal return without an error block", async () => { await h.service.trace("op", (span) => { - span.markFailure(); + span.markError(); return Promise.resolve(); }); @@ -487,21 +487,21 @@ describe("TelemetryService", () => { expect(h.sink.events[0].error).toBeUndefined(); }); - it("markFailure overrides markAborted (failure wins over abort)", async () => { + it("markError overrides markAborted (error wins over abort)", async () => { await h.service.trace("op", (span) => { span.markAborted(); - span.markFailure(); + span.markError(); return Promise.resolve(); }); expect(h.sink.events[0].properties.result).toBe("error"); }); - it("thrown errors take precedence over markFailure (error block is preserved)", async () => { + it("thrown errors take precedence over markError (error block is preserved)", async () => { const boom = new Error("kaboom"); await expect( h.service.trace("op", (span) => { - span.markFailure(); + span.markError(); return Promise.reject(boom); }), ).rejects.toBe(boom); diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index 4c87ae72f6..82d186e575 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -126,6 +126,9 @@ describe("uriHandler", () => { }); describe("/open", () => { + // Fields the URI handler always supplies; per-test overrides spread on top. + const OPEN_DEFAULTS = { source: "uri", useDefaultDirectory: false }; + it("opens workspace with parameters", async () => { const { handleUri, commands, deploymentManager } = createTestContext(); await handleUri( @@ -142,7 +145,7 @@ describe("uriHandler", () => { agentName: "a", folderPath: "/f", openRecent: true, - useDefaultDirectory: false, + ...OPEN_DEFAULTS, }); }); @@ -161,7 +164,7 @@ describe("uriHandler", () => { agentName: undefined, folderPath: undefined, openRecent: expected, - useDefaultDirectory: false, + ...OPEN_DEFAULTS, }); }); @@ -178,7 +181,7 @@ describe("uriHandler", () => { agentName: undefined, folderPath: undefined, openRecent: false, - useDefaultDirectory: false, + ...OPEN_DEFAULTS, }); expect(showErrorMessage).not.toHaveBeenCalled(); }); @@ -194,7 +197,7 @@ describe("uriHandler", () => { agentName: undefined, folderPath: undefined, openRecent: false, - useDefaultDirectory: false, + ...OPEN_DEFAULTS, }); expect(showErrorMessage).not.toHaveBeenCalled(); });