Skip to content

Commit 455c98a

Browse files
Improve telemetry instrumentation and error diagnostics for typespec-vscode (#10847)
# Improve telemetry instrumentation and error diagnostics ## Summary Based on analysis of 2 weeks of telemetry data (71K+ events, 2,327 unique users), this PR improves telemetry instrumentation gaps and error diagnostics across the typespec-vscode extension and the compiler LSP server. ## Changes ### Extension telemetry improvements (`packages/typespec-vscode`) **`install-tsp-compiler.ts`** — Added `lastStep` tracking and `logOperationDetailTelemetry` for failure/timeout cases. Previously, all 3 install failures in the dataset had `lastStep=undefined` and zero error detail. **`openapi3-preview.ts`** — Refactored `getOpenApi3OutputFilePath` to return `Result<string>` instead of `string | undefined` to properly distinguish compile failures from user cancellations. Added `lastStep` for the success path (`"Preview panel opened"`). Added compile error details to telemetry. **`extension.ts` (start-server)** — Added `lastStep` for the "compiler not found" state before the install prompt, and for the cancelled-install path. These covered 66 events (34 fail + 32 cancelled) that previously had `lastStep=undefined`. **`extension.ts` (server-path-changed)** — Added `lastStep` for the config change handler. All 30 events previously had `lastStep=undefined`. ### Better error message for node/tsp not on PATH (`packages/typespec-vscode`) **`tsp-executable-resolver.ts`** — When the compiler is found locally but neither `node` nor `tsp` is available on PATH, the extension now shows an actionable error message explaining the likely cause (nvm/fnm/volta PATH not inherited) and three concrete fixes. Previously, this silently fell through to `spawn tsp ENOENT`. This was the #1 root cause of blocked users — 107 failure events from users who never recovered. ### Server-side error detail preservation (`packages/compiler`) **`serverlib.ts`** — Added `wrapUnhandledError` wrapper around all LSP server handlers. When a handler crashes, the wrapper catches the error and re-throws with the full server-side stack trace in the error message (via `inspect(e)`). Previously, the JSON-RPC layer only forwarded `error.message` to the client, so the server-side crash location was completely lost in telemetry. This addresses the 297 `Cannot read properties of undefined (reading 'kind')` errors that were previously opaque. ## Telemetry data highlights - **2,327 total users** in the 2-week window - **101 users (4.3%) completely blocked** — never had a successful `start-server` in 2 weeks - **#1 root cause**: `spawn tsp ENOENT` (node not on PATH) — 107 events from never-recovered users, disproportionately macOS (60%) - **#2 root cause**: compiler not found — 50 events - **297 `reading 'kind'` unhandled errors** — now will include server-side stack for future diagnosis --------- Co-authored-by: Timothee Guerin <timothee.guerin@outlook.com>
1 parent 123882f commit 455c98a

7 files changed

Lines changed: 177 additions & 62 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/compiler"
5+
---
6+
7+
[Language Server] 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.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "typespec-vscode"
5+
---
6+
7+
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.

packages/compiler/src/server/serverlib.ts

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -182,44 +182,99 @@ export function createServer(
182182
let isInitialized = false;
183183
let pendingMessages: ServerLog[] = [];
184184

185+
/**
186+
* Wraps an LSP handler to preserve the server-side error details when it crashes.
187+
*
188+
* By default, the JSON-RPC layer (vscode-languageserver) catches handler errors and
189+
* creates a new ResponseError using only `error.message`, discarding the original stack
190+
* trace. On the client side, the telemetry framework then captures this as an unhandled
191+
* error, but the `unhandled_error_stack` only shows the client-side message handling code:
192+
*
193+
* ```
194+
* Error: Request textDocument/hover failed with message: Cannot read properties of undefined (reading 'kind')
195+
* at handleResponse (extension.cjs:2104:40) // <-- client-side LSP message handler
196+
* at handleMessage (extension.cjs:1914:11)
197+
* at processMessageQueue (extension.cjs:1929:13)
198+
* at Immediate.<anonymous> (extension.cjs:1905:11)
199+
* ```
200+
*
201+
* The actual server-side crash location (e.g., in the checker or parser) is completely lost.
202+
*
203+
* This wrapper catches the error first and re-throws a new Error whose message includes
204+
* the full original error details (stack trace for Error instances, String() for others).
205+
* The JSON-RPC layer then forwards this enriched message to the client, so the
206+
* telemetry `unhandled_error_message` will contain the server-side crash location:
207+
*
208+
* ```
209+
* [getHover] TypeError: Cannot read properties of undefined (reading 'kind')
210+
* at Checker.getTypeForNode (checker.ts:1234:15) // <-- actual crash location
211+
* at getHover (serverlib.ts:826:52)
212+
* ...
213+
* ```
214+
*/
215+
function wrapUnhandledError<T extends (...args: any[]) => any>(fn: T): T {
216+
const name = fn.name || "anonymous";
217+
return (async (...args: any[]) => {
218+
try {
219+
return await fn(...args);
220+
} catch (e) {
221+
if (e instanceof Error) {
222+
const detail = e.stack ? `${e.message}\n${e.stack}` : e.message;
223+
throw new Error(`[${name}] ${detail}`, { cause: e });
224+
} else if (typeof e === "string") {
225+
throw new Error(`[${name}] ${e}`, { cause: e });
226+
} else if (typeof e === "object" && e !== null) {
227+
let detail: string;
228+
try {
229+
detail = JSON.stringify(e);
230+
} catch {
231+
throw e;
232+
}
233+
throw new Error(`[${name}] ${detail}`, { cause: e });
234+
}
235+
throw e;
236+
}
237+
}) as T;
238+
}
239+
185240
return {
186241
get pendingMessages() {
187242
return pendingMessages;
188243
},
189244
get workspaceFolders() {
190245
return workspaceFolders;
191246
},
192-
compile,
193-
initialize,
194-
initialized,
195-
workspaceFoldersChanged,
196-
watchedFilesChanged,
197-
formatDocument,
198-
gotoDefinition,
199-
documentClosed,
200-
documentOpened,
201-
complete,
202-
findReferences,
203-
findDocumentHighlight,
204-
prepareRename,
205-
rename,
206-
renameFiles,
207-
getSemanticTokens: getSemanticTokensForDocument,
208-
buildSemanticTokens,
209-
checkChange,
210-
getFoldingRanges,
211-
getHover,
212-
getSignatureHelp,
213-
getDocumentSymbols,
214-
getCodeActions,
215-
resolveCodeAction,
247+
compile: wrapUnhandledError(compile),
248+
initialize: wrapUnhandledError(initialize),
249+
initialized: wrapUnhandledError(initialized),
250+
workspaceFoldersChanged: wrapUnhandledError(workspaceFoldersChanged),
251+
watchedFilesChanged: wrapUnhandledError(watchedFilesChanged),
252+
formatDocument: wrapUnhandledError(formatDocument),
253+
gotoDefinition: wrapUnhandledError(gotoDefinition),
254+
documentClosed: wrapUnhandledError(documentClosed),
255+
documentOpened: wrapUnhandledError(documentOpened),
256+
complete: wrapUnhandledError(complete),
257+
findReferences: wrapUnhandledError(findReferences),
258+
findDocumentHighlight: wrapUnhandledError(findDocumentHighlight),
259+
prepareRename: wrapUnhandledError(prepareRename),
260+
rename: wrapUnhandledError(rename),
261+
renameFiles: wrapUnhandledError(renameFiles),
262+
getSemanticTokens: wrapUnhandledError(getSemanticTokensForDocument),
263+
buildSemanticTokens: wrapUnhandledError(buildSemanticTokens),
264+
checkChange: wrapUnhandledError(checkChange),
265+
getFoldingRanges: wrapUnhandledError(getFoldingRanges),
266+
getHover: wrapUnhandledError(getHover),
267+
getSignatureHelp: wrapUnhandledError(getSignatureHelp),
268+
getDocumentSymbols: wrapUnhandledError(getDocumentSymbols),
269+
getCodeActions: wrapUnhandledError(getCodeActions),
270+
resolveCodeAction: wrapUnhandledError(resolveCodeAction),
216271
log,
217-
reportDiagnostics,
272+
reportDiagnostics: wrapUnhandledError(reportDiagnostics),
218273

219-
getInitProjectContext,
220-
validateInitProjectTemplate,
221-
initProject,
222-
internalCompile,
274+
getInitProjectContext: wrapUnhandledError(getInitProjectContext),
275+
validateInitProjectTemplate: wrapUnhandledError(validateInitProjectTemplate),
276+
initProject: wrapUnhandledError(initProject),
277+
internalCompile: wrapUnhandledError(internalCompile),
223278
};
224279

225280
async function initialize(params: InitializeParams): Promise<InitializeResult> {

packages/typespec-vscode/src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export async function activate(context: ExtensionContext) {
245245
await telemetryClient.doOperationWithTelemetry(
246246
TelemetryEventName.ServerPathSettingChanged,
247247
async (tel) => {
248+
tel.lastStep = "Recreate LSP client for path change";
248249
return await recreateLSPClient(context, tel.activityId);
249250
},
250251
undefined,
@@ -316,6 +317,7 @@ export async function activate(context: ExtensionContext) {
316317
}
317318
// client will be undefined only when we can't find compiler locally or globally
318319
// otherwise, the client should always be created though the start command may fail which is a different case
320+
ssTel.lastStep = "Compiler not found (prompting to install)";
319321
const choice: "Yes" | "Ignore" | undefined = await vscode.window.showWarningMessage(
320322
"No TypeSpec compiler found which is required to start TypeSpec language server. Do you want to install TypeSpec compiler?",
321323
"Yes",
@@ -355,6 +357,8 @@ export async function activate(context: ExtensionContext) {
355357
{ showPopup: true },
356358
);
357359
ssTel.lastStep = "Failed to install TypeSpec compiler.";
360+
} else {
361+
ssTel.lastStep = "Install TypeSpec compiler cancelled.";
358362
}
359363
return installResult.code;
360364
},

packages/typespec-vscode/src/tsp-executable-resolver.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SettingName } from "./types.js";
88
import {
99
checkInstalledExecutable,
1010
checkInstalledNode,
11+
checkInstalledTspCli,
1112
isFile,
1213
loadModule,
1314
useShellInExec,
@@ -159,10 +160,32 @@ export async function resolveTypeSpecServer(
159160
});
160161
return { command: "node", args: [serverPath, ...args], options };
161162
} else {
162-
// otherwise the local compiler should be installed by standalone tsp cli
163-
logger.debug("Start tsp server using standalone tsp cli");
163+
const tspCliPath = await checkInstalledTspCli();
164+
if (tspCliPath.length > 0) {
165+
logger.debug("Start tsp server using standalone tsp cli");
166+
telemetryClient.logOperationDetailTelemetry(activityId, {
167+
compilerStartType: "standalone-tsp-cli",
168+
});
169+
return { command: "tsp", args: ["--server", serverPath, ...args], options };
170+
}
171+
// Neither node nor tsp is on PATH. Show an actionable error but still try tsp
172+
// as a last resort — it may work in some environments where `which` fails but
173+
// the shell can still resolve the command.
174+
logger.error(
175+
[
176+
`TypeSpec compiler was found at '${serverPath}', but it cannot be started because neither 'node' nor 'tsp' is available in PATH.`,
177+
"This commonly happens when Node.js is installed via a version manager (nvm, fnm, volta) whose PATH is not inherited by VS Code.",
178+
"To fix this, try one of the following:",
179+
" - Launch VS Code from a terminal where 'node' is available (e.g. run 'code .' after activating nvm).",
180+
" - Set the 'typespec.tsp-server.path' setting to the full path of your tsp-server.js file.",
181+
" - Install Node.js system-wide so it's available to all processes.",
182+
].join("\n"),
183+
[],
184+
{ showPopup: true, showOutput: true },
185+
);
164186
telemetryClient.logOperationDetailTelemetry(activityId, {
165-
compilerStartType: "standalone-tsp-cli",
187+
compilerStartType: "standalone-tsp-cli-fallback",
188+
error: "Neither node nor tsp is available in PATH. Compiler found but cannot be started.",
166189
});
167190
return { command: "tsp", args: ["--server", serverPath, ...args], options };
168191
}

packages/typespec-vscode/src/vscode-cmd/install-tsp-compiler.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { inspect } from "util";
12
import logger from "../log/logger.js";
23
import telemetryClient from "../telemetry/telemetry-client.js";
34
import { TelemetryEventName } from "../telemetry/telemetry-event.js";
@@ -11,6 +12,7 @@ export async function installCompilerGlobally(
1112
TelemetryEventName.InstallGlobalCompilerCli,
1213
async (tel) => {
1314
const showPopup = args?.silentMode !== true;
15+
tel.lastStep = "Call installCompilerWithUi";
1416
const result = await installCompilerWithUi(
1517
{
1618
confirmNeeded: args?.confirm !== false,
@@ -20,13 +22,26 @@ export async function installCompilerGlobally(
2022
[] /*localPath, empty for global*/,
2123
);
2224
if (result.code === ResultCode.Success) {
25+
tel.lastStep = "Compiler installed successfully";
2326
logger.info(`Compiler installed successfully`, [], { showPopup });
24-
} else if (result.code === ResultCode.Fail || result.code === ResultCode.Timeout) {
25-
logger.error(
26-
`Installing compiler ${result.code === ResultCode.Fail ? "failed" : "timeout"}. Please check previous logs for details`,
27-
[],
28-
{ showPopup },
29-
);
27+
} else if (result.code === ResultCode.Cancelled) {
28+
tel.lastStep = "User cancelled installation";
29+
} else if (result.code === ResultCode.Timeout) {
30+
tel.lastStep = "Installation timeout";
31+
telemetryClient.logOperationDetailTelemetry(tel.activityId, {
32+
error: `Installing compiler globally timeout`,
33+
});
34+
logger.error(`Installing compiler timeout. Please check previous logs for details`, [], {
35+
showPopup,
36+
});
37+
} else if (result.code === ResultCode.Fail) {
38+
tel.lastStep = "Installation failed";
39+
telemetryClient.logOperationDetailTelemetry(tel.activityId, {
40+
error: `Installing compiler globally failed: ${inspect(result.details)}`,
41+
});
42+
logger.error(`Installing compiler failed. Please check previous logs for details`, [], {
43+
showPopup,
44+
});
3045
}
3146
return result;
3247
},

packages/typespec-vscode/src/vscode-cmd/openapi3-preview.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getBaseFileName, getDirectoryPath, joinPaths } from "../path-utils.js";
77
import telemetryClient from "../telemetry/telemetry-client.js";
88
import { OperationTelemetryEvent } from "../telemetry/telemetry-event.js";
99
import { TspLanguageClient } from "../tsp-language-client.js";
10-
import { ResultCode } from "../types.js";
10+
import { Result, ResultCode } from "../types.js";
1111
import { getEntrypointTspFile, TraverseMainTspFileInWorkspace } from "../typespec-utils.js";
1212
import { createTempDir, throttle } from "../utils.js";
1313

@@ -134,15 +134,13 @@ async function loadOpenApi3PreviewPanel(
134134
});
135135
panel.reveal();
136136
} else {
137-
const getOpenApi3OutputFilePath = async (
138-
selectOutput: boolean,
139-
): Promise<string | undefined> => {
137+
const getOpenApi3OutputFilePath = async (selectOutput: boolean): Promise<Result<string>> => {
140138
return await vscode.window.withProgress(
141139
{
142140
location: vscode.ProgressLocation.Notification,
143141
title: "Loading OpenAPI3 files...",
144142
},
145-
async (): Promise<string | undefined> => {
143+
async (): Promise<Result<string>> => {
146144
const srcFolder = getDirectoryPath(mainTspFile);
147145
const outputFolder = await getOutputFolder(mainTspFile, tmpRoot);
148146
if (!outputFolder) {
@@ -153,7 +151,7 @@ async function loadOpenApi3PreviewPanel(
153151
telemetryClient.logOperationDetailTelemetry(tel.activityId, {
154152
error: "Failed to create temporary folder for OpenAPI3 files",
155153
});
156-
return undefined;
154+
return { code: ResultCode.Fail };
157155
}
158156
await clearOutputFolder(outputFolder);
159157

@@ -163,27 +161,32 @@ async function loadOpenApi3PreviewPanel(
163161
"Failed to generate OpenAPI3 files.",
164162
result?.stderr ? [result.stderr] : [],
165163
);
166-
return;
167-
} else {
168-
return await selectAndGetOpenApi3FilePath(
169-
mainTspFile,
170-
outputFolder,
171-
selectOutput,
172-
context,
173-
);
164+
telemetryClient.logOperationDetailTelemetry(tel.activityId, {
165+
error: `Failed to compile OpenAPI3: exitCode=${result?.exitCode ?? "N/A"}, stderr=${result?.stderr ?? "N/A"}`,
166+
});
167+
return { code: ResultCode.Fail };
168+
}
169+
const filePath = await selectAndGetOpenApi3FilePath(
170+
mainTspFile,
171+
outputFolder,
172+
selectOutput,
173+
context,
174+
);
175+
if (filePath === undefined) {
176+
return { code: ResultCode.Cancelled };
174177
}
178+
return { code: ResultCode.Success, value: filePath };
175179
},
176180
);
177181
};
178182

179-
const filePath = await getOpenApi3OutputFilePath(true);
180-
if (filePath === undefined) {
181-
telemetryClient.logOperationDetailTelemetry(tel.activityId, {
182-
error: "Failed to get generated OpenAPI3 file",
183-
});
184-
tel.lastStep = "Get OpenAPI3 output";
185-
return ResultCode.Cancelled;
183+
const outputResult = await getOpenApi3OutputFilePath(true);
184+
if (outputResult.code !== ResultCode.Success) {
185+
tel.lastStep =
186+
outputResult.code === ResultCode.Fail ? "Compile OpenAPI3 failed" : "Get OpenAPI3 output";
187+
return outputResult.code;
186188
}
189+
const filePath = outputResult.value;
187190

188191
const panel = vscode.window.createWebviewPanel(
189192
"webview",
@@ -199,11 +202,11 @@ async function loadOpenApi3PreviewPanel(
199202

200203
const watch = vscode.workspace.createFileSystemWatcher("**/*.{tsp}");
201204
const throttledChangeHandler = throttle(async () => {
202-
const outputFilePath = await getOpenApi3OutputFilePath(false);
203-
if (outputFilePath) {
205+
const refreshResult = await getOpenApi3OutputFilePath(false);
206+
if (refreshResult.code === ResultCode.Success) {
204207
void panel.webview.postMessage({
205208
command: "load",
206-
param: panel.webview.asWebviewUri(vscode.Uri.file(outputFilePath)).toString(),
209+
param: panel.webview.asWebviewUri(vscode.Uri.file(refreshResult.value)).toString(),
207210
});
208211
}
209212
}, 1000);
@@ -233,6 +236,7 @@ async function loadOpenApi3PreviewPanel(
233236

234237
loadHtml(context.extensionUri, panel);
235238
}
239+
tel.lastStep = "Preview panel opened";
236240
return ResultCode.Success;
237241
}
238242

0 commit comments

Comments
 (0)