diff --git a/.chronus/changes/tel-improve-compiler-2026-6-1-19-0-0.md b/.chronus/changes/tel-improve-compiler-2026-6-1-19-0-0.md new file mode 100644 index 00000000000..a14452432bd --- /dev/null +++ b/.chronus/changes/tel-improve-compiler-2026-6-1-19-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Wrapped LSP server handlers with `wrapUnhandledError` to preserve server-side stack traces in error messages forwarded to the client. Previously, the JSON-RPC layer discarded the original stack trace, making unhandled errors in telemetry opaque. diff --git a/.chronus/changes/tel-improve-vscode-2026-6-1-19-0-0.md b/.chronus/changes/tel-improve-vscode-2026-6-1-19-0-0.md new file mode 100644 index 00000000000..f10896d25eb --- /dev/null +++ b/.chronus/changes/tel-improve-vscode-2026-6-1-19-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "typespec-vscode" +--- + +Improved telemetry instrumentation for `install-global-compiler-cli`, `preview-openapi3`, `start-server`, and `server-path-changed` events by adding missing `lastStep` tracking and error detail logging. Added actionable error message when compiler is found but neither `node` nor `tsp` is available on PATH, guiding users to fix common nvm/fnm/volta configuration issues. diff --git a/packages/compiler/src/server/serverlib.ts b/packages/compiler/src/server/serverlib.ts index b23b6e29913..21a853098da 100644 --- a/packages/compiler/src/server/serverlib.ts +++ b/packages/compiler/src/server/serverlib.ts @@ -182,6 +182,60 @@ export function createServer( let isInitialized = false; let pendingMessages: ServerLog[] = []; + /** + * Wraps an LSP handler to preserve the server-side error details when it crashes. + * + * By default, the JSON-RPC layer (vscode-languageserver) catches handler errors and + * creates a new ResponseError using only `error.message`, discarding the original stack + * trace. On the client side, the telemetry framework then captures this as an unhandled + * error, but the `unhandled_error_stack` only shows the client-side message handling code: + * + * ``` + * Error: Request textDocument/hover failed with message: Cannot read properties of undefined (reading 'kind') + * at handleResponse (extension.cjs:2104:40) // <-- client-side LSP message handler + * at handleMessage (extension.cjs:1914:11) + * at processMessageQueue (extension.cjs:1929:13) + * at Immediate. (extension.cjs:1905:11) + * ``` + * + * The actual server-side crash location (e.g., in the checker or parser) is completely lost. + * + * This wrapper catches the error first and re-throws a new Error whose message includes + * the full original error details (stack trace for Error instances, String() for others). + * The JSON-RPC layer then forwards this enriched message to the client, so the + * telemetry `unhandled_error_message` will contain the server-side crash location: + * + * ``` + * [getHover] TypeError: Cannot read properties of undefined (reading 'kind') + * at Checker.getTypeForNode (checker.ts:1234:15) // <-- actual crash location + * at getHover (serverlib.ts:826:52) + * ... + * ``` + */ + function wrapUnhandledError any>(name: string, fn: T): T { + return (async (...args: any[]) => { + try { + return await fn(...args); + } catch (e) { + if (e instanceof Error) { + const detail = e.stack ? `${e.message}\n${e.stack}` : e.message; + throw new Error(`[${name}] ${detail}`, { cause: e }); + } else if (typeof e === "string") { + throw new Error(`[${name}] ${e}`, { cause: e }); + } else if (typeof e === "object" && e !== null) { + let detail: string; + try { + detail = JSON.stringify(e); + } catch { + throw e; + } + throw new Error(`[${name}] ${detail}`, { cause: e }); + } + throw e; + } + }) as T; + } + return { get pendingMessages() { return pendingMessages; @@ -189,37 +243,40 @@ export function createServer( get workspaceFolders() { return workspaceFolders; }, - compile, - initialize, - initialized, - workspaceFoldersChanged, - watchedFilesChanged, - formatDocument, - gotoDefinition, - documentClosed, - documentOpened, - complete, - findReferences, - findDocumentHighlight, - prepareRename, - rename, - renameFiles, - getSemanticTokens: getSemanticTokensForDocument, - buildSemanticTokens, - checkChange, - getFoldingRanges, - getHover, - getSignatureHelp, - getDocumentSymbols, - getCodeActions, - resolveCodeAction, + compile: wrapUnhandledError("compile", compile), + initialize: wrapUnhandledError("initialize", initialize), + initialized: wrapUnhandledError("initialized", initialized), + workspaceFoldersChanged: wrapUnhandledError("workspaceFoldersChanged", workspaceFoldersChanged), + watchedFilesChanged: wrapUnhandledError("watchedFilesChanged", watchedFilesChanged), + formatDocument: wrapUnhandledError("formatDocument", formatDocument), + gotoDefinition: wrapUnhandledError("gotoDefinition", gotoDefinition), + documentClosed: wrapUnhandledError("documentClosed", documentClosed), + documentOpened: wrapUnhandledError("documentOpened", documentOpened), + complete: wrapUnhandledError("complete", complete), + findReferences: wrapUnhandledError("findReferences", findReferences), + findDocumentHighlight: wrapUnhandledError("findDocumentHighlight", findDocumentHighlight), + prepareRename: wrapUnhandledError("prepareRename", prepareRename), + rename: wrapUnhandledError("rename", rename), + renameFiles: wrapUnhandledError("renameFiles", renameFiles), + getSemanticTokens: wrapUnhandledError("getSemanticTokens", getSemanticTokensForDocument), + buildSemanticTokens: wrapUnhandledError("buildSemanticTokens", buildSemanticTokens), + checkChange: wrapUnhandledError("checkChange", checkChange), + getFoldingRanges: wrapUnhandledError("getFoldingRanges", getFoldingRanges), + getHover: wrapUnhandledError("getHover", getHover), + getSignatureHelp: wrapUnhandledError("getSignatureHelp", getSignatureHelp), + getDocumentSymbols: wrapUnhandledError("getDocumentSymbols", getDocumentSymbols), + getCodeActions: wrapUnhandledError("getCodeActions", getCodeActions), + resolveCodeAction: wrapUnhandledError("resolveCodeAction", resolveCodeAction), log, - reportDiagnostics, - - getInitProjectContext, - validateInitProjectTemplate, - initProject, - internalCompile, + reportDiagnostics: wrapUnhandledError("reportDiagnostics", reportDiagnostics), + + getInitProjectContext: wrapUnhandledError("getInitProjectContext", getInitProjectContext), + validateInitProjectTemplate: wrapUnhandledError( + "validateInitProjectTemplate", + validateInitProjectTemplate, + ), + initProject: wrapUnhandledError("initProject", initProject), + internalCompile: wrapUnhandledError("internalCompile", internalCompile), }; async function initialize(params: InitializeParams): Promise { diff --git a/packages/typespec-vscode/src/extension.ts b/packages/typespec-vscode/src/extension.ts index 4746b78288a..6a33d931fff 100644 --- a/packages/typespec-vscode/src/extension.ts +++ b/packages/typespec-vscode/src/extension.ts @@ -245,6 +245,7 @@ export async function activate(context: ExtensionContext) { await telemetryClient.doOperationWithTelemetry( TelemetryEventName.ServerPathSettingChanged, async (tel) => { + tel.lastStep = "Recreate LSP client for path change"; return await recreateLSPClient(context, tel.activityId); }, undefined, @@ -316,6 +317,7 @@ export async function activate(context: ExtensionContext) { } // client will be undefined only when we can't find compiler locally or globally // otherwise, the client should always be created though the start command may fail which is a different case + ssTel.lastStep = "Compiler not found (prompting to install)"; const choice: "Yes" | "Ignore" | undefined = await vscode.window.showWarningMessage( "No TypeSpec compiler found which is required to start TypeSpec language server. Do you want to install TypeSpec compiler?", "Yes", @@ -355,6 +357,8 @@ export async function activate(context: ExtensionContext) { { showPopup: true }, ); ssTel.lastStep = "Failed to install TypeSpec compiler."; + } else { + ssTel.lastStep = "Install TypeSpec compiler cancelled."; } return installResult.code; }, diff --git a/packages/typespec-vscode/src/tsp-executable-resolver.ts b/packages/typespec-vscode/src/tsp-executable-resolver.ts index 95c160e49ea..15e8666a56f 100644 --- a/packages/typespec-vscode/src/tsp-executable-resolver.ts +++ b/packages/typespec-vscode/src/tsp-executable-resolver.ts @@ -8,6 +8,7 @@ import { SettingName } from "./types.js"; import { checkInstalledExecutable, checkInstalledNode, + checkInstalledTspCli, isFile, loadModule, useShellInExec, @@ -159,12 +160,32 @@ export async function resolveTypeSpecServer( }); return { command: "node", args: [serverPath, ...args], options }; } else { - // otherwise the local compiler should be installed by standalone tsp cli - logger.debug("Start tsp server using standalone tsp cli"); + const tspCliPath = await checkInstalledTspCli(); + if (tspCliPath.length > 0) { + logger.debug("Start tsp server using standalone tsp cli"); + telemetryClient.logOperationDetailTelemetry(activityId, { + compilerStartType: "standalone-tsp-cli", + }); + return { command: "tsp", args: ["--server", serverPath, ...args], options }; + } + // Neither node nor tsp is on PATH. + logger.error( + [ + `TypeSpec compiler was found at '${serverPath}', but it cannot be started because neither 'node' nor 'tsp' is available in PATH.`, + "This commonly happens when Node.js is installed via a version manager (nvm, fnm, volta) whose PATH is not inherited by VS Code.", + "To fix this, try one of the following:", + " - Launch VS Code from a terminal where 'node' is available (e.g. run 'code .' after activating nvm).", + " - Set the 'typespec.tsp-server.path' setting to the full path of your tsp-server.js file.", + " - Install Node.js system-wide so it's available to all processes.", + ].join("\n"), + [], + { showPopup: true, showOutput: true }, + ); telemetryClient.logOperationDetailTelemetry(activityId, { - compilerStartType: "standalone-tsp-cli", + compilerStartType: "not-available", + error: "Neither node nor tsp is available in PATH. Compiler found but cannot be started.", }); - return { command: "tsp", args: ["--server", serverPath, ...args], options }; + return undefined; } } diff --git a/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts b/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts index 3ef10fa1e1e..d2c04f8d01f 100644 --- a/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts +++ b/packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts @@ -1,3 +1,4 @@ +import { inspect } from "util"; import logger from "../log/logger.js"; import telemetryClient from "../telemetry/telemetry-client.js"; import { TelemetryEventName } from "../telemetry/telemetry-event.js"; @@ -11,6 +12,7 @@ export async function installCompilerGlobally( TelemetryEventName.InstallGlobalCompilerCli, async (tel) => { const showPopup = args?.silentMode !== true; + tel.lastStep = "Call installCompilerWithUi"; const result = await installCompilerWithUi( { confirmNeeded: args?.confirm !== false, @@ -20,13 +22,26 @@ export async function installCompilerGlobally( [] /*localPath, empty for global*/, ); if (result.code === ResultCode.Success) { + tel.lastStep = "Compiler installed successfully"; logger.info(`Compiler installed successfully`, [], { showPopup }); - } else if (result.code === ResultCode.Fail || result.code === ResultCode.Timeout) { - logger.error( - `Installing compiler ${result.code === ResultCode.Fail ? "failed" : "timeout"}. Please check previous logs for details`, - [], - { showPopup }, - ); + } else if (result.code === ResultCode.Cancelled) { + tel.lastStep = "User cancelled installation"; + } else if (result.code === ResultCode.Timeout) { + tel.lastStep = "Installation timeout"; + telemetryClient.logOperationDetailTelemetry(tel.activityId, { + error: `Installing compiler globally timeout`, + }); + logger.error(`Installing compiler timeout. Please check previous logs for details`, [], { + showPopup, + }); + } else if (result.code === ResultCode.Fail) { + tel.lastStep = "Installation failed"; + telemetryClient.logOperationDetailTelemetry(tel.activityId, { + error: `Installing compiler globally failed: ${inspect(result.details)}`, + }); + logger.error(`Installing compiler failed. Please check previous logs for details`, [], { + showPopup, + }); } return result; }, diff --git a/packages/typespec-vscode/src/vscode-cmd/openapi3-preview.ts b/packages/typespec-vscode/src/vscode-cmd/openapi3-preview.ts index a5f9b55416f..681eeae1e93 100644 --- a/packages/typespec-vscode/src/vscode-cmd/openapi3-preview.ts +++ b/packages/typespec-vscode/src/vscode-cmd/openapi3-preview.ts @@ -7,7 +7,7 @@ import { getBaseFileName, getDirectoryPath, joinPaths } from "../path-utils.js"; import telemetryClient from "../telemetry/telemetry-client.js"; import { OperationTelemetryEvent } from "../telemetry/telemetry-event.js"; import { TspLanguageClient } from "../tsp-language-client.js"; -import { ResultCode } from "../types.js"; +import { Result, ResultCode } from "../types.js"; import { getEntrypointTspFile, TraverseMainTspFileInWorkspace } from "../typespec-utils.js"; import { createTempDir, throttle } from "../utils.js"; @@ -134,15 +134,13 @@ async function loadOpenApi3PreviewPanel( }); panel.reveal(); } else { - const getOpenApi3OutputFilePath = async ( - selectOutput: boolean, - ): Promise => { + const getOpenApi3OutputFilePath = async (selectOutput: boolean): Promise> => { return await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: "Loading OpenAPI3 files...", }, - async (): Promise => { + async (): Promise> => { const srcFolder = getDirectoryPath(mainTspFile); const outputFolder = await getOutputFolder(mainTspFile, tmpRoot); if (!outputFolder) { @@ -153,7 +151,7 @@ async function loadOpenApi3PreviewPanel( telemetryClient.logOperationDetailTelemetry(tel.activityId, { error: "Failed to create temporary folder for OpenAPI3 files", }); - return undefined; + return { code: ResultCode.Fail }; } await clearOutputFolder(outputFolder); @@ -163,27 +161,32 @@ async function loadOpenApi3PreviewPanel( "Failed to generate OpenAPI3 files.", result?.stderr ? [result.stderr] : [], ); - return; - } else { - return await selectAndGetOpenApi3FilePath( - mainTspFile, - outputFolder, - selectOutput, - context, - ); + telemetryClient.logOperationDetailTelemetry(tel.activityId, { + error: `Failed to compile OpenAPI3: exitCode=${result?.exitCode ?? "N/A"}, stderr=${result?.stderr ?? "N/A"}`, + }); + return { code: ResultCode.Fail }; + } + const filePath = await selectAndGetOpenApi3FilePath( + mainTspFile, + outputFolder, + selectOutput, + context, + ); + if (filePath === undefined) { + return { code: ResultCode.Cancelled }; } + return { code: ResultCode.Success, value: filePath }; }, ); }; - const filePath = await getOpenApi3OutputFilePath(true); - if (filePath === undefined) { - telemetryClient.logOperationDetailTelemetry(tel.activityId, { - error: "Failed to get generated OpenAPI3 file", - }); - tel.lastStep = "Get OpenAPI3 output"; - return ResultCode.Cancelled; + const outputResult = await getOpenApi3OutputFilePath(true); + if (outputResult.code !== ResultCode.Success) { + tel.lastStep = + outputResult.code === ResultCode.Fail ? "Compile OpenAPI3 failed" : "Get OpenAPI3 output"; + return outputResult.code; } + const filePath = outputResult.value; const panel = vscode.window.createWebviewPanel( "webview", @@ -199,11 +202,11 @@ async function loadOpenApi3PreviewPanel( const watch = vscode.workspace.createFileSystemWatcher("**/*.{tsp}"); const throttledChangeHandler = throttle(async () => { - const outputFilePath = await getOpenApi3OutputFilePath(false); - if (outputFilePath) { + const refreshResult = await getOpenApi3OutputFilePath(false); + if (refreshResult.code === ResultCode.Success) { void panel.webview.postMessage({ command: "load", - param: panel.webview.asWebviewUri(vscode.Uri.file(outputFilePath)).toString(), + param: panel.webview.asWebviewUri(vscode.Uri.file(refreshResult.value)).toString(), }); } }, 1000); @@ -233,6 +236,7 @@ async function loadOpenApi3PreviewPanel( loadHtml(context.extensionUri, panel); } + tel.lastStep = "Preview panel opened"; return ResultCode.Success; }