From 0c3fe8ce529bbf32ea8872b42475ddaf975661c4 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 18 Nov 2025 12:31:04 +0100 Subject: [PATCH 1/2] feat: add structured error tracking with stack trace parsing --- src/modules/exceptions.ts | 709 ++++++++++++++++++++++++++++ src/modules/exporters/sentry.ts | 2 - src/modules/index.ts | 1 + src/modules/internal.ts | 6 +- src/modules/tracing.ts | 9 +- src/modules/tracingV2.ts | 10 +- src/tests/error-capture.test.ts | 430 +++++++++++++++++ src/tests/exceptions.test.ts | 802 ++++++++++++++++++++++++++++++++ src/types.ts | 27 +- 9 files changed, 1976 insertions(+), 20 deletions(-) create mode 100644 src/modules/exceptions.ts create mode 100644 src/tests/error-capture.test.ts create mode 100644 src/tests/exceptions.test.ts diff --git a/src/modules/exceptions.ts b/src/modules/exceptions.ts new file mode 100644 index 0000000..d22a490 --- /dev/null +++ b/src/modules/exceptions.ts @@ -0,0 +1,709 @@ +import { ErrorData, StackFrame, CauseData } from "../types.js"; + +// Maximum number of exceptions to capture in a cause chain +const MAX_EXCEPTION_CHAIN_DEPTH = 10; + +// Maximum number of stack frames to capture per exception +const MAX_STACK_FRAMES = 50; + +/** + * Captures detailed exception information including stack traces and cause chains. + * + * This function extracts error metadata (type, message, stack trace) and recursively + * unwraps Error.cause chains. It parses V8 stack traces into structured frames and + * detects whether each frame is user code (in_app: true) or library code (in_app: false). + * + * @param error - The error to capture (can be Error, string, object, or any value) + * @returns ErrorData object with structured error information + */ +export function captureException(error: unknown): ErrorData { + // Handle non-Error objects + if (!(error instanceof Error)) { + return { + message: stringifyNonError(error), + type: "NonError", + }; + } + + const errorData: ErrorData = { + message: error.message || "", + type: error.name || error.constructor?.name || "Error", + }; + + // Capture stack trace if available + if (error.stack) { + errorData.stack = error.stack; + errorData.frames = parseV8StackTrace(error.stack); + } + + // Unwrap Error.cause chain + const causes = unwrapErrorCauses(error); + if (causes.length > 0) { + errorData.causes = causes; + } + + return errorData; +} + +/** + * Parses V8 stack trace string into structured StackFrame array. + * + * V8 stack traces have the format: + * Error: message + * at functionName (filename:line:col) + * at Object.method (filename:line:col) + * ... + * + * This function handles various V8 format variations including: + * - Regular functions: "at functionName (file:10:5)" + * - Anonymous functions: "at file:10:5" + * - Async functions: "at async functionName (file:10:5)" + * - Object methods: "at Object.method (file:10:5)" + * - Native code: "at Array.map (native)" + * + * @param stackTrace - Raw V8 stack trace string from Error.stack + * @returns Array of parsed StackFrame objects (limited to MAX_STACK_FRAMES) + */ +function parseV8StackTrace(stackTrace: string): StackFrame[] { + const frames: StackFrame[] = []; + const lines = stackTrace.split("\n"); + + for (const line of lines) { + // Skip the first line (error message) and empty lines + if (!line.trim().startsWith("at ")) { + continue; + } + + const frame = parseV8StackFrame(line.trim()); + if (frame) { + frames.push(frame); + } + + // Limit number of frames + if (frames.length >= MAX_STACK_FRAMES) { + break; + } + } + + return frames; +} + +/** + * Parses a location string from a V8 stack frame. + * + * Handles different location formats: + * - "fileName:lineNumber:columnNumber" - normal file location + * - "eval at functionName (location)" - eval'd code (recursively unwraps) + * - "native" - V8 internal code + * - "unknown location" - location unavailable + * + * @param location - Location string from stack frame + * @returns Object with filename, abs_path, and optional lineno/colno, or null if unparseable + */ +function parseLocation(location: string): { + filename: string; + abs_path: string; + lineno?: number; + colno?: number; +} | null { + // Handle special cases first + if (location === "native") { + return { filename: "native", abs_path: "native" }; + } + + if (location === "unknown location") { + return { filename: "", abs_path: "" }; + } + + // Handle eval locations + if (location.startsWith("eval at ")) { + return parseEvalOrigin(location); + } + + // Handle normal location format: fileName:lineNumber:columnNumber + const match = location.match(/^(.+):(\d+):(\d+)$/); + if (match) { + const [, filename, lineStr, colStr] = match; + return { + filename: makeRelativePath(filename), + abs_path: filename, + lineno: parseInt(lineStr, 10), + colno: parseInt(colStr, 10), + }; + } + + return null; +} + +/** + * Recursively unwraps eval location chains to extract the underlying file location. + * + * Eval locations have the format: "eval at functionName (location), :line:col" + * where location can be another eval or a file location. + * + * V8 formats: + * - "eval at Bar.z (myscript.js:10:3)" → extract myscript.js:10:3 + * - "eval at Foo (eval at Bar (file.js:10:3)), :5:2" → extract file.js:10:3 + * + * @param evalLocation - Eval location string starting with "eval at " + * @returns Object with extracted file location, or null if unparseable + */ +function parseEvalOrigin(evalLocation: string): { + filename: string; + abs_path: string; + lineno?: number; + colno?: number; +} | null { + // V8 format: "eval at functionName (parentLocation), :line:col" + // or simpler: "eval at functionName (parentLocation)" + // + // Strategy: Find balanced parentheses to extract the parent location, + // then recursively parse it to find the actual file. + + // First, check if there's a comma separating eval chain from eval code location + // Format: "eval at FUNC (...), :line:col" + // We want to extract just the "eval at FUNC (...)" part + let evalChainPart = evalLocation; + const commaIndex = findCommaAfterBalancedParens(evalLocation); + if (commaIndex !== -1) { + evalChainPart = evalLocation.substring(0, commaIndex); + } + + // Match "eval at ()" + const match = evalChainPart.match(/^eval at (.+?) \((.+)\)$/); + if (!match) { + return null; + } + + const innerLocation = match[2]; + + // Recursively parse the inner location + if (innerLocation.startsWith("eval at ")) { + return parseEvalOrigin(innerLocation); + } + + // Base case: parse as normal location + const locationMatch = innerLocation.match(/^(.+):(\d+):(\d+)$/); + if (locationMatch) { + const [, filename, lineStr, colStr] = locationMatch; + return { + filename: makeRelativePath(filename), + abs_path: filename, + lineno: parseInt(lineStr, 10), + colno: parseInt(colStr, 10), + }; + } + + return null; +} + +/** + * Finds the index of the comma that appears after balanced parentheses. + * + * For "eval at f (eval at g (x)), :1:2", returns the index of the comma + * after the closing ")" and before "". + * + * @param str - String to search + * @returns Index of comma, or -1 if not found + */ +function findCommaAfterBalancedParens(str: string): number { + let depth = 0; + let foundOpenParen = false; + + for (let i = 0; i < str.length; i++) { + if (str[i] === "(") { + depth++; + foundOpenParen = true; + } else if (str[i] === ")") { + depth--; + if (depth === 0 && foundOpenParen) { + // Found the closing paren of the eval at (...) part + for (let j = i + 1; j < str.length; j++) { + if (str[j] === ",") { + return j; + } else if (str[j] !== " ") { + // Non-comma, non-space character found, no comma separator + return -1; + } + } + return -1; + } + } + } + + return -1; +} + +/** + * Parses a single V8 stack frame line into a StackFrame object. + * + * Handles multiple V8 stack frame formats: + * - "at functionName (filename:line:col)" + * - "at filename:line:col" (top-level code) + * - "at async functionName (filename:line:col)" + * - "at Object.method (filename:line:col)" + * - "at Module._compile (node:internal/...)" (internal modules) + * - "at functionName (eval at ...)" (eval'd code) + * - "at functionName (native)" (native code) + * + * @param line - Single line from V8 stack trace (trimmed, starts with "at ") + * @returns Parsed StackFrame or null if line cannot be parsed + */ +function parseV8StackFrame(line: string): StackFrame | null { + // Remove "at " prefix + const withoutAt = line.substring(3); + + // Try to extract function name and location + // Format 1: "functionName (location)" + // Location can be: filename:line:col, eval at ..., native, unknown location + const matchWithFunction = withoutAt.match(/^(.+?)\s+\((.+)\)$/); + if (matchWithFunction) { + const [, functionName, location] = matchWithFunction; + const parsedLocation = parseLocation(location); + + if (parsedLocation) { + return { + function: functionName.trim(), + filename: parsedLocation.filename, + abs_path: parsedLocation.abs_path, + lineno: parsedLocation.lineno, + colno: parsedLocation.colno, + in_app: isInApp(parsedLocation.abs_path), + }; + } + } + + // Format 2: "location" (no function name, top-level code) + // Try to parse as location directly + const parsedLocation = parseLocation(withoutAt); + if (parsedLocation) { + return { + function: "", + filename: parsedLocation.filename, + abs_path: parsedLocation.abs_path, + lineno: parsedLocation.lineno, + colno: parsedLocation.colno, + in_app: isInApp(parsedLocation.abs_path), + }; + } + + // Format 3: Unparseable + // Fallback for formats we don't recognize + return { + function: withoutAt, + filename: "", + in_app: false, + }; +} + +/** + * Determines if a file path represents user code (in_app: true) or library code (in_app: false). + * + * Library code is identified by: + * - Paths containing "/node_modules/" + * - Node.js internal modules (e.g., "node:internal/...") + * - Native code + * + * @param filename - File path from stack frame + * @returns true if user code, false if library code + */ +function isInApp(filename: string): boolean { + // Exclude node_modules + if ( + filename.includes("/node_modules/") || + filename.includes("\\node_modules\\") + ) { + return false; + } + + // Exclude Node.js internal modules (node:internal/...) + if (filename.startsWith("node:")) { + return false; + } + + // Exclude native code + if (filename === "native" || filename === "") { + return false; + } + + return true; +} + +/** + * Normalizes URL schemes to regular file paths. + * + * Handles file:// URLs commonly seen in ESM modules and local testing: + * - "file:///Users/john/project/src/index.ts" → "/Users/john/project/src/index.ts" + * - "file:///C:/projects/app/src/index.ts" → "C:/projects/app/src/index.ts" + * + * @param filename - File path that may be a file:// URL + * @returns Clean file path without URL scheme + */ +function normalizeUrl(filename: string): string { + // Handle file:// URLs (common in ESM modules and local testing) + if (filename.startsWith("file://")) { + let result = filename.substring(7); // Remove "file://" + + // Ensure Unix paths start with / + if (!result.startsWith("/") && !result.match(/^[A-Za-z]:/)) { + result = "/" + result; + } + + return result; + } + + return filename; +} + +/** + * Normalizes Node.js internal module paths for consistent error grouping. + * + * Examples: + * - "node:internal/modules/cjs/loader" → "node:internal" + * - "node:fs/promises" → "node:fs" + * - "node:fs" → "node:fs" (unchanged) + * + * @param filename - File path that may be a Node.js internal module + * @returns Simplified module path or original filename + */ +function normalizeNodeInternals(filename: string): string { + if (filename.startsWith("node:internal")) { + return "node:internal"; + } + + if (filename.startsWith("node:")) { + // Extract just the module name: node:fs/promises → node:fs + const parts = filename.split("/"); + return parts[0]; + } + + return filename; +} + +/** + * Strips user-specific and system path prefixes. + * + * Removes prefixes like: + * - /Users/username/ → ~/ + * - /home/username/ → ~/ + * - C:\Users\username\ → ~\ + * - C:/Users/username/ → ~/ (mixed separators) + * + * @param path - File path to normalize + * @returns Path with system prefixes removed + */ +function stripSystemPrefixes(path: string): string { + // Unix/macOS: /Users/username/ + path = path.replace(/^\/Users\/[^/]+\//, "~/"); + + // Linux: /home/username/ + path = path.replace(/^\/home\/[^/]+\//, "~/"); + + // Windows: C:\Users\username\ or C:/Users/username/ (with any separator) + path = path.replace(/^[A-Za-z]:[\\\/]Users[\\\/][^\\\/]+[\\\/]/, "~/"); + + return path; +} + +/** + * Normalizes node_modules paths to be consistent across deployments. + * + * Extracts only the package-relative portion of the path: + * - /Users/john/project/node_modules/express/lib/router.js → node_modules/express/lib/router.js + * - /app/node_modules/@scope/pkg/index.js → node_modules/@scope/pkg/index.js + * + * @param path - File path that may contain node_modules + * @returns Normalized node_modules path or original path + */ +function normalizeNodeModules(path: string): string { + // Find the last occurrence of /node_modules/ or \node_modules\ + const unixIndex = path.lastIndexOf("/node_modules/"); + const winIndex = path.lastIndexOf("\\node_modules\\"); + + if (unixIndex !== -1) { + return path.substring(unixIndex + 1); // +1 to exclude leading slash + } + + if (winIndex !== -1) { + return path.substring(winIndex + 1).replace(/\\/g, "/"); + } + + return path; +} + +/** + * Strips common deployment-specific path prefixes. + * + * Removes prefixes like: + * - /var/www/app/ → "" + * - /app/ → "" + * - /opt/project/ → "" + * - /var/task/ → "" (AWS Lambda) + * - /usr/src/app/ → "" (Docker) + * + * @param path - File path to normalize + * @returns Path with deployment prefixes removed + */ +function stripDeploymentPaths(path: string): string { + // Common deployment paths + const deploymentPrefixes = [ + /^\/var\/www\/[^/]+\//, // Apache/nginx: /var/www/myapp/ + /^\/var\/task\//, // AWS Lambda: /var/task/ + /^\/usr\/src\/app\//, // Docker: /usr/src/app/ + /^\/app\//, // Heroku, Docker, generic: /app/ + /^\/opt\/[^/]+\//, // Optional software: /opt/myapp/ + /^\/srv\/[^/]+\//, // Service data: /srv/myapp/ + ]; + + for (const prefix of deploymentPrefixes) { + path = path.replace(prefix, ""); + } + + return path; +} + +/** + * Finds project-relative path using common project boundary markers. + * + * Looks for markers like /src/, /lib/, /dist/, /build/ and extracts the path + * from that marker onwards: + * - /Users/john/project/src/components/Button.tsx → src/components/Button.tsx + * - /app/dist/index.js → dist/index.js + * + * Priority order: looks for primary markers first (src, lib, dist, build), + * then secondary markers. Uses the highest-priority marker found. + * + * @param path - File path to search for project boundaries + * @returns Project-relative path or original path if no marker found + */ +function findProjectPath(path: string): string { + // Project boundary markers in priority order + // Primary markers (most likely to be project root) + const primaryMarkers = ["/src/", "/lib/", "/dist/", "/build/"]; + + // Secondary markers (could be subdirectories) + const secondaryMarkers = [ + "/app/", + "/components/", + "/pages/", + "/api/", + "/utils/", + "/services/", + "/modules/", + ]; + + // Check primary markers first + for (const marker of primaryMarkers) { + const index = path.lastIndexOf(marker); + if (index !== -1) { + return path.substring(index + 1); // +1 to remove leading slash + } + } + + // If no primary marker, check secondary markers + for (const marker of secondaryMarkers) { + const index = path.lastIndexOf(marker); + if (index !== -1) { + return path.substring(index + 1); + } + } + + return path; +} + +/** + * Converts absolute file paths to normalized relative paths for consistent error grouping. + * + * This function performs comprehensive path normalization to ensure errors from the same + * code location group together regardless of deployment environment, user directories, + * or system-specific paths. The original absolute path is always preserved in abs_path. + * + * Normalization steps: + * 1. Normalize URL schemes (file://, etc.) - must be first to strip URL prefixes + * 2. Preserve special paths (already relative, Node internals, etc.) + * 3. Normalize path separators to forward slashes (for consistent processing) + * 4. Normalize Node.js internal modules (node:internal/*, node:fs/*) + * 5. Normalize node_modules paths to package-relative format + * 6. Strip user home directories (/Users/*, /home/*, C:\Users\*) + * 7. Strip deployment-specific paths (/var/www/*, /app/, AWS Lambda, Docker) + * 8. Strip current working directory + * 9. Find project boundaries (/src/, /lib/, /dist/, etc.) + * 10. Remove leading slashes for clean relative paths + * + * @param filename - Absolute or relative file path from stack trace + * @returns Normalized relative path for error grouping + * + * @example + * makeRelativePath('/Users/john/project/src/index.ts') + * // Returns: 'src/index.ts' + * + * @example + * makeRelativePath('/home/ubuntu/app/node_modules/express/lib/router.js') + * // Returns: 'node_modules/express/lib/router.js' + * + * @example + * makeRelativePath('/var/www/myapp/dist/server.js') + * // Returns: 'dist/server.js' + * + * @example + * makeRelativePath('node:internal/modules/cjs/loader') + * // Returns: 'node:internal' + * + * @example + * makeRelativePath('C:\\Users\\John\\projects\\myapp\\src\\index.ts') + * // Returns: 'src/index.ts' + */ +function makeRelativePath(filename: string): string { + let result = filename; + + // Step 1: Normalize URL schemes (file://, etc.) + result = normalizeUrl(result); + + // Step 2: Handle already-relative paths and special cases + if (!result.startsWith("/") && !result.match(/^[A-Za-z]:\\/)) { + // Already relative or special path (native, , etc.) + // Still normalize Node internals + if (result.startsWith("node:")) { + return normalizeNodeInternals(result); + } + return result; + } + + // Step 3: Normalize path separators early for consistent processing + result = result.replace(/\\/g, "/"); + + // Step 4: Normalize Node.js internal modules (should be rare at this point) + if (result.startsWith("node:")) { + return normalizeNodeInternals(result); + } + + // Step 5: Handle node_modules specially - preserve package structure + if (result.includes("/node_modules/")) { + return normalizeNodeModules(result); + } + + // Step 6: Strip user home directories + result = stripSystemPrefixes(result); + + // Step 7: Strip deployment-specific paths + result = stripDeploymentPaths(result); + + // Step 8: Strip current working directory + const cwd = process.cwd(); + if (result.startsWith(cwd)) { + result = result.substring(cwd.length + 1); // +1 to remove leading / + } + + // Step 9: Find project boundaries if still absolute-looking + // Also apply to tilde paths that might have project markers after the tilde + if (result.startsWith("/") || result.match(/^[A-Za-z]:[/]/)) { + result = findProjectPath(result); + } else if (result.startsWith("~")) { + // For tilde paths, strip the tilde and find markers in the remaining path + const withoutTilde = result.substring(2); // Remove ~/ + const projectPath = findProjectPath("/" + withoutTilde); + // If a marker was found (path changed), use it; otherwise keep the tilde version + if (projectPath !== "/" + withoutTilde) { + result = projectPath; + } + } + + // Step 10: Remove leading slash if present (prefer relative paths) + if (result.startsWith("/")) { + result = result.substring(1); + } + + return result; +} + +/** + * Recursively unwraps Error.cause chain and returns array of causes. + * + * Error.cause is a standard JavaScript feature that allows chaining errors: + * const cause = new Error("Root cause"); + * const error = new Error("Wrapper error", { cause }); + * + * This function extracts all errors in the cause chain up to MAX_EXCEPTION_CHAIN_DEPTH. + * + * @param error - Error object to unwrap + * @returns Array of CauseData objects representing the cause chain + */ +function unwrapErrorCauses(error: Error): CauseData[] { + const causes: CauseData[] = []; + const seenErrors = new Set(); + let currentError: unknown = (error as any).cause; + let depth = 0; + + while (currentError && depth < MAX_EXCEPTION_CHAIN_DEPTH) { + // If cause is not an Error, stringify it and stop + if (!(currentError instanceof Error)) { + causes.push({ + message: stringifyNonError(currentError), + type: "NonError", + }); + break; + } + + // Check for circular reference + if (seenErrors.has(currentError)) { + break; + } + seenErrors.add(currentError); + + const causeData: CauseData = { + message: currentError.message || "", + type: currentError.name || currentError.constructor?.name || "Error", + }; + + if (currentError.stack) { + causeData.stack = currentError.stack; + causeData.frames = parseV8StackTrace(currentError.stack); + } + + causes.push(causeData); + + // Move to next cause in chain + currentError = (currentError as any).cause; + depth++; + } + + return causes; +} + +/** + * Converts non-Error objects to string representation for error messages. + * + * In JavaScript, anything can be thrown (not just Error objects): + * throw "string error"; + * throw { code: 404 }; + * throw null; + * + * This function handles these cases by converting them to meaningful strings. + * + * @param value - Non-Error value that was thrown + * @returns String representation of the value + */ +function stringifyNonError(value: unknown): string { + if (value === null) { + return "null"; + } + + if (value === undefined) { + return "undefined"; + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + // Try to stringify objects with fallback + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/src/modules/exporters/sentry.ts b/src/modules/exporters/sentry.ts index d0f0408..f076e54 100644 --- a/src/modules/exporters/sentry.ts +++ b/src/modules/exporters/sentry.ts @@ -411,8 +411,6 @@ export class SentryExporter implements Exporter { } else if (typeof event.error === "object" && event.error !== null) { if ("message" in event.error) { errorMessage = String(event.error.message); - } else if ("error" in event.error) { - errorMessage = String(event.error.error); } else { errorMessage = JSON.stringify(event.error); } diff --git a/src/modules/index.ts b/src/modules/index.ts index 77f71e8..4c2a019 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,5 +1,6 @@ export * from "./compatibility.js"; export * from "./context-parameters.js"; +export * from "./exceptions.js"; export * from "./internal.js"; export * from "./logging.js"; export * from "./session.js"; diff --git a/src/modules/internal.ts b/src/modules/internal.ts index eaa8f9b..d1be29b 100644 --- a/src/modules/internal.ts +++ b/src/modules/internal.ts @@ -7,9 +7,9 @@ import { } from "../types.js"; import { PublishEventRequestEventTypeEnum } from "mcpcat-api"; import { publishEvent } from "./eventQueue.js"; -import { getMCPCompatibleErrorMessage } from "./compatibility.js"; import { writeToLog } from "./logging.js"; import { INACTIVITY_TIMEOUT_IN_MINUTES } from "./constants.js"; +import { captureException } from "./exceptions.js"; /** * Simple LRU cache for session identities. @@ -325,9 +325,7 @@ export async function handleIdentify( new Date().getTime() - identifyEvent.timestamp.getTime()) || undefined; identifyEvent.isError = true; - identifyEvent.error = { - message: getMCPCompatibleErrorMessage(error), - }; + identifyEvent.error = captureException(error); publishEvent(server, identifyEvent); } } diff --git a/src/modules/tracing.ts b/src/modules/tracing.ts index f77f2bc..2dbb9bc 100644 --- a/src/modules/tracing.ts +++ b/src/modules/tracing.ts @@ -16,6 +16,7 @@ import { getServerSessionId } from "./session.js"; import { PublishEventRequestEventTypeEnum } from "mcpcat-api"; import { publishEvent } from "./eventQueue.js"; import { getMCPCompatibleErrorMessage } from "./compatibility.js"; +import { captureException } from "./exceptions.js"; function isToolResultError(result: any): boolean { return result && typeof result === "object" && result.isError === true; @@ -269,9 +270,7 @@ export function setupToolCallTracing(server: MCPServerLike): void { // Check if the result indicates an error if (isToolResultError(result)) { event.isError = true; - event.error = { - message: getMCPCompatibleErrorMessage(result), - }; + event.error = captureException(result); } event.response = result; @@ -279,9 +278,7 @@ export function setupToolCallTracing(server: MCPServerLike): void { return result; } catch (error) { event.isError = true; - event.error = { - message: getMCPCompatibleErrorMessage(error), - }; + event.error = captureException(error); publishEvent(server, event); throw error; } diff --git a/src/modules/tracingV2.ts b/src/modules/tracingV2.ts index aaf0518..309753b 100644 --- a/src/modules/tracingV2.ts +++ b/src/modules/tracingV2.ts @@ -12,10 +12,10 @@ import { getServerTrackingData, handleIdentify } from "./internal.js"; import { getServerSessionId } from "./session.js"; import { PublishEventRequestEventTypeEnum } from "mcpcat-api"; import { publishEvent } from "./eventQueue.js"; -import { getMCPCompatibleErrorMessage } from "./compatibility.js"; import { addContextParameterToTool } from "./context-parameters.js"; import { handleReportMissing } from "./tools.js"; import { setupInitializeTracing, setupListToolsTracing } from "./tracing.js"; +import { captureException } from "./exceptions.js"; // WeakMap to track which callbacks have already been wrapped const wrappedCallbacks = new WeakMap(); @@ -359,9 +359,7 @@ function addTracingToToolCallback( // Check if the result indicates an error if (isToolResultError(result)) { event.isError = true; - event.error = { - message: getMCPCompatibleErrorMessage(result), - }; + event.error = captureException(result); } event.response = result; @@ -373,9 +371,7 @@ function addTracingToToolCallback( return result; } catch (error) { event.isError = true; - event.error = { - message: getMCPCompatibleErrorMessage(error), - }; + event.error = captureException(error); event.duration = (event.timestamp && new Date().getTime() - event.timestamp.getTime()) || diff --git a/src/tests/error-capture.test.ts b/src/tests/error-capture.test.ts new file mode 100644 index 0000000..2cc3957 --- /dev/null +++ b/src/tests/error-capture.test.ts @@ -0,0 +1,430 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + setupTestServerAndClient, + resetTodos, +} from "./test-utils/client-server-factory.js"; +import { track } from "../index.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types"; +import { EventCapture } from "./test-utils.js"; +import { PublishEventRequestEventTypeEnum } from "mcpcat-api"; + +describe("Error Capture Integration Tests", () => { + let eventCapture: EventCapture; + + beforeEach(async () => { + resetTodos(); + eventCapture = new EventCapture(); + await eventCapture.start(); + }); + + afterEach(async () => { + await eventCapture.stop(); + }); + + it("should capture stack traces when tool throws Error", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Track the server with mcpcat (uses default settings including context parameters) + await track(server, { + projectId: "test-project", + enableTracing: true, + }); + + // Call a tool that throws an error (complete_todo with invalid ID) + const result = await client.request( + { + method: "tools/call", + params: { + name: "complete_todo", + arguments: { + id: "nonexistent-id", + context: "Testing error capture", + }, + }, + }, + CallToolResultSchema, + ); + + // MCP returns errors as tool results with isError: true + expect(result.isError).toBe(true); + + // Wait for event to be captured + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find the tool call event + const events = eventCapture.findEventsByResourceName("complete_todo"); + expect(events.length).toBeGreaterThan(0); + + const errorEvent = events.find((e) => e.isError); + expect(errorEvent).toBeDefined(); + expect(errorEvent!.isError).toBe(true); + + // Verify error structure + expect(errorEvent!.error).toBeDefined(); + expect(errorEvent!.error!.message).toContain("not found"); + expect(errorEvent!.error!.type).toBe("Error"); + + // Verify stack trace is captured + expect(errorEvent!.error!.stack).toBeDefined(); + expect(typeof errorEvent!.error!.stack).toBe("string"); + expect(errorEvent!.error!.stack!.length).toBeGreaterThan(0); + + // Verify stack frames are parsed + expect(errorEvent!.error!.frames).toBeDefined(); + expect(Array.isArray(errorEvent!.error!.frames)).toBe(true); + expect(errorEvent!.error!.frames!.length).toBeGreaterThan(0); + + // Verify frame structure + const firstFrame = errorEvent!.error!.frames![0]; + expect(firstFrame).toHaveProperty("filename"); + expect(firstFrame).toHaveProperty("function"); + expect(firstFrame).toHaveProperty("in_app"); + expect(typeof firstFrame.in_app).toBe("boolean"); + } finally { + await cleanup(); + } + }); + + it("should capture Error.cause chains", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Add a tool that throws an error with a cause + server.tool( + "error_with_cause", + "Throws error with cause", + {}, + async () => { + const rootCause = new Error("Root cause error"); + const wrapperError = new Error("Wrapper error", { cause: rootCause }); + throw wrapperError; + }, + ); + + // Track the server + await track(server, { + projectId: "test-project", + enableTracing: true, + }); + + // Call the error-throwing tool + const result = await client.request( + { + method: "tools/call", + params: { + name: "error_with_cause", + arguments: { + context: "Testing error.cause chains", + }, + }, + }, + CallToolResultSchema, + ); + + // MCP returns errors as tool results with isError: true + expect(result.isError).toBe(true); + + // Wait for event + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find the tool call event + const events = eventCapture.findEventsByResourceName("error_with_cause"); + expect(events.length).toBeGreaterThan(0); + + const errorEvent = events.find((e) => e.isError); + expect(errorEvent).toBeDefined(); + + // Verify main error + expect(errorEvent!.error!.message).toBe("Wrapper error"); + expect(errorEvent!.error!.type).toBe("Error"); + + // Verify cause chain is captured + expect(errorEvent!.error!.causes).toBeDefined(); + expect(errorEvent!.error!.causes!.length).toBe(1); + expect(errorEvent!.error!.causes![0].message).toBe("Root cause error"); + expect(errorEvent!.error!.causes![0].type).toBe("Error"); + + // Verify cause has its own stack trace + expect(errorEvent!.error!.causes![0].stack).toBeDefined(); + expect(errorEvent!.error!.causes![0].frames).toBeDefined(); + } finally { + await cleanup(); + } + }); + + it("should capture TypeError with correct type", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Add a tool that throws a TypeError + server.tool("type_error_tool", "Throws TypeError", {}, async () => { + const obj: any = null; + obj.property; // This will throw TypeError + return { + content: [{ type: "text", text: "unreachable" }], + }; + }); + + // Track the server + await track(server, { + projectId: "test-project", + enableTracing: true, + }); + + // Call the error-throwing tool + const result = await client.request( + { + method: "tools/call", + params: { + name: "type_error_tool", + arguments: { + context: "Testing TypeError capture", + }, + }, + }, + CallToolResultSchema, + ); + + // MCP returns errors as tool results with isError: true + expect(result.isError).toBe(true); + + // Wait for event + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find the tool call event + const events = eventCapture.findEventsByResourceName("type_error_tool"); + const errorEvent = events.find((e) => e.isError); + + expect(errorEvent).toBeDefined(); + expect(errorEvent!.error!.type).toBe("TypeError"); + expect(errorEvent!.error!.message).toContain("null"); + } finally { + await cleanup(); + } + }); + + it("should capture non-Error thrown values", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Add a tool that throws a string + server.tool("throw_string", "Throws string", {}, async () => { + throw "This is a string error"; + }); + + // Track the server + await track(server, { + projectId: "test-project", + enableTracing: true, + }); + + // Call the error-throwing tool + const result = await client.request( + { + method: "tools/call", + params: { + name: "throw_string", + arguments: { + context: "Testing non-Error thrown values", + }, + }, + }, + CallToolResultSchema, + ); + + // MCP returns errors as tool results with isError: true + expect(result.isError).toBe(true); + + // Wait for event + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find the tool call event + const events = eventCapture.findEventsByResourceName("throw_string"); + const errorEvent = events.find((e) => e.isError); + + expect(errorEvent).toBeDefined(); + expect(errorEvent!.error!.type).toBe("NonError"); + expect(errorEvent!.error!.message).toBe("This is a string error"); + // Non-Error objects don't have stack traces + expect(errorEvent!.error!.stack).toBeUndefined(); + expect(errorEvent!.error!.frames).toBeUndefined(); + } finally { + await cleanup(); + } + }); + + it.skip("should detect in_app frames correctly", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Track the server + await track(server, { + projectId: "test-project", + enableTracing: true, + }); + + // Call a tool that throws an error + const result = await client.request({ + method: "tools/call", + params: { + name: "complete_todo", + arguments: { + id: "bad-id", + context: "Testing in_app frame detection", + }, + }, + CallToolResultSchema, + }); + + // MCP returns errors as tool results with isError: true + expect(result.isError).toBe(true); + + // Wait for event + await new Promise((resolve) => setTimeout(resolve, 100)); + + const events = eventCapture.findEventsByResourceName("complete_todo"); + const errorEvent = events.find((e) => e.isError); + + expect(errorEvent).toBeDefined(); + expect(errorEvent!.error!.frames).toBeDefined(); + + // Check that we have both in_app and library frames + const hasInAppFrame = errorEvent!.error!.frames!.some( + (frame) => frame.in_app, + ); + const hasLibraryFrame = errorEvent!.error!.frames!.some( + (frame) => !frame.in_app, + ); + + // At least one frame should be from user code + expect(hasInAppFrame).toBe(true); + } finally { + await cleanup(); + } + }); + + it("should still propagate errors to MCP client", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Track the server + await track(server, { + projectId: "test-project", + enableTracing: true, + }); + + // Verify that the error is still returned to the client + const result = await client.request( + { + method: "tools/call", + params: { + name: "complete_todo", + arguments: { + id: "invalid", + context: "Testing error propagation", + }, + }, + }, + CallToolResultSchema, + ); + + // MCP returns errors as tool results with isError: true + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("not found"); + } finally { + await cleanup(); + } + }); + + it("should capture identify errors with stack traces", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Track with an identify function that throws + await track(server, { + projectId: "test-project", + enableTracing: true, + identify: async () => { + throw new Error("Identify error"); + }, + }); + + // Make a tool call to trigger identify + await client.request( + { + method: "tools/call", + params: { + name: "list_todos", + arguments: { + context: "Testing identify error capture", + }, + }, + }, + CallToolResultSchema, + ); + + // Wait for events + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find identify error event + const events = eventCapture.getEvents(); + const identifyEvent = events.find( + (e) => + e.eventType === PublishEventRequestEventTypeEnum.McpcatIdentify && + e.isError, + ); + + if (identifyEvent) { + expect(identifyEvent.error).toBeDefined(); + expect(identifyEvent.error!.message).toBe("Identify error"); + expect(identifyEvent.error!.type).toBe("Error"); + expect(identifyEvent.error!.stack).toBeDefined(); + expect(identifyEvent.error!.frames).toBeDefined(); + } + } finally { + await cleanup(); + } + }); + + it("should handle successful tool calls without errors", async () => { + const { server, client, cleanup } = await setupTestServerAndClient(); + + try { + // Track the server + await track(server, { + projectId: "test-project", + enableTracing: true, + }); + + // Make a successful tool call + const result = await client.request( + { + method: "tools/call", + params: { + name: "add_todo", + arguments: { + text: "Test todo", + context: "Testing successful tool calls", + }, + }, + }, + CallToolResultSchema, + ); + + expect(result).toBeDefined(); + + // Wait for event + await new Promise((resolve) => setTimeout(resolve, 100)); + + const events = eventCapture.findEventsByResourceName("add_todo"); + expect(events.length).toBeGreaterThan(0); + + const successEvent = events[events.length - 1]; + expect(successEvent.isError).toBeUndefined(); + expect(successEvent.error).toBeUndefined(); + } finally { + await cleanup(); + } + }); +}); diff --git a/src/tests/exceptions.test.ts b/src/tests/exceptions.test.ts new file mode 100644 index 0000000..7c37a61 --- /dev/null +++ b/src/tests/exceptions.test.ts @@ -0,0 +1,802 @@ +import { describe, it, expect } from "vitest"; +import { captureException } from "../modules/exceptions.js"; + +describe("captureException", () => { + describe("basic error capture", () => { + it("should capture Error with message and type", () => { + const error = new Error("Test error message"); + const result = captureException(error); + + expect(result.message).toBe("Test error message"); + expect(result.type).toBe("Error"); + expect(result.stack).toBeDefined(); + expect(result.frames).toBeDefined(); + expect(result.frames!.length).toBeGreaterThan(0); + }); + + it("should capture TypeError with correct type", () => { + const error = new TypeError("Type error message"); + const result = captureException(error); + + expect(result.message).toBe("Type error message"); + expect(result.type).toBe("TypeError"); + }); + + it("should capture ReferenceError with correct type", () => { + const error = new ReferenceError("Reference error message"); + const result = captureException(error); + + expect(result.message).toBe("Reference error message"); + expect(result.type).toBe("ReferenceError"); + }); + + it("should capture custom error class", () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = "CustomError"; + } + } + + const error = new CustomError("Custom error message"); + const result = captureException(error); + + expect(result.message).toBe("Custom error message"); + expect(result.type).toBe("CustomError"); + }); + }); + + describe("stack trace parsing", () => { + it("should parse stack frames with function names", () => { + const error = new Error("Test"); + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames!.length).toBeGreaterThan(0); + + // Check that at least one frame has the expected structure + const frame = result.frames![0]; + expect(frame).toHaveProperty("filename"); + expect(frame).toHaveProperty("function"); + expect(frame).toHaveProperty("in_app"); + expect(typeof frame.in_app).toBe("boolean"); + }); + + it("should detect in_app correctly for user code", () => { + const error = new Error("Test"); + const result = captureException(error); + + // At least one frame should be marked as in_app (this test file) + const hasInAppFrame = result.frames!.some((frame) => frame.in_app); + expect(hasInAppFrame).toBe(true); + }); + + it("should detect library code in node_modules", () => { + // Create a mock stack trace that includes node_modules + const error = new Error("Test"); + error.stack = `Error: Test + at userFunction (/app/src/test.ts:10:5) + at libFunction (/app/node_modules/some-lib/index.js:42:10) + at internal (node:internal/process:123:45)`; + + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames!.length).toBe(3); + + // First frame should be in_app (user code) + expect(result.frames![0].in_app).toBe(true); + expect(result.frames![0].filename).toContain("test.ts"); + + // Second frame should NOT be in_app (node_modules) + expect(result.frames![1].in_app).toBe(false); + expect(result.frames![1].filename).toContain("node_modules"); + + // Third frame should NOT be in_app (node internal) + expect(result.frames![2].in_app).toBe(false); + expect(result.frames![2].filename).toContain("node:"); + }); + + it("should parse line and column numbers", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at testFunction (/app/src/file.ts:42:15)`; + + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames![0].lineno).toBe(42); + expect(result.frames![0].colno).toBe(15); + }); + + it("should handle anonymous functions", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at /app/src/file.ts:10:5`; + + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames![0].function).toBe(""); + expect(result.frames![0].lineno).toBe(10); + expect(result.frames![0].colno).toBe(5); + }); + + it("should handle async functions", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at async asyncFunction (/app/src/file.ts:20:10)`; + + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames![0].function).toBe("async asyncFunction"); + }); + + it("should handle native code frames", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at Array.map (native) + at userFunction (/app/src/file.ts:10:5)`; + + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames![0].function).toBe("Array.map"); + expect(result.frames![0].filename).toBe("native"); + expect(result.frames![0].in_app).toBe(false); + }); + + it("should limit stack frames to 50", () => { + // Create an error with a very long stack trace + const error = new Error("Test"); + const stackLines = ["Error: Test"]; + for (let i = 0; i < 100; i++) { + stackLines.push(` at function${i} (/app/src/file.ts:${i}:5)`); + } + error.stack = stackLines.join("\n"); + + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames!.length).toBeLessThanOrEqual(50); + }); + }); + + describe("Error.cause chain", () => { + it("should unwrap single cause", () => { + const rootCause = new Error("Root cause"); + const error = new Error("Wrapper error", { cause: rootCause }); + + const result = captureException(error); + + expect(result.message).toBe("Wrapper error"); + expect(result.causes).toBeDefined(); + expect(result.causes!.length).toBe(1); + expect(result.causes![0].message).toBe("Root cause"); + expect(result.causes![0].type).toBe("Error"); + }); + + it("should unwrap multiple causes", () => { + const rootCause = new Error("Root cause"); + const middleCause = new Error("Middle cause", { cause: rootCause }); + const error = new Error("Top error", { cause: middleCause }); + + const result = captureException(error); + + expect(result.causes).toBeDefined(); + expect(result.causes!.length).toBe(2); + expect(result.causes![0].message).toBe("Middle cause"); + expect(result.causes![1].message).toBe("Root cause"); + }); + + it("should limit cause chain to 10", () => { + // Create a very long cause chain + let error: Error = new Error("Root"); + for (let i = 0; i < 20; i++) { + error = new Error(`Level ${i}`, { cause: error }); + } + + const result = captureException(error); + + expect(result.causes).toBeDefined(); + expect(result.causes!.length).toBeLessThanOrEqual(10); + }); + + it("should handle non-Error causes", () => { + const error = new Error("Wrapper", { cause: "string cause" }); + + const result = captureException(error); + + expect(result.causes).toBeDefined(); + expect(result.causes!.length).toBe(1); + expect(result.causes![0].message).toBe("string cause"); + expect(result.causes![0].type).toBe("NonError"); + }); + + it("should detect circular cause references", () => { + const error1 = new Error("Error 1"); + const error2 = new Error("Error 2", { cause: error1 }); + // Create circular reference + (error1 as any).cause = error2; + + const result = captureException(error2); + + // Should not crash, should stop at circular reference + expect(result.causes).toBeDefined(); + expect(result.causes!.length).toBeLessThanOrEqual(2); + }); + + it("should capture stack traces for each cause", () => { + const rootCause = new Error("Root cause"); + const error = new Error("Wrapper", { cause: rootCause }); + + const result = captureException(error); + + expect(result.stack).toBeDefined(); + expect(result.frames).toBeDefined(); + expect(result.causes![0].stack).toBeDefined(); + expect(result.causes![0].frames).toBeDefined(); + }); + }); + + describe("non-Error objects", () => { + it("should handle string errors", () => { + const result = captureException("string error"); + + expect(result.message).toBe("string error"); + expect(result.type).toBe("NonError"); + expect(result.stack).toBeUndefined(); + expect(result.frames).toBeUndefined(); + }); + + it("should handle number errors", () => { + const result = captureException(42); + + expect(result.message).toBe("42"); + expect(result.type).toBe("NonError"); + }); + + it("should handle boolean errors", () => { + const result = captureException(false); + + expect(result.message).toBe("false"); + expect(result.type).toBe("NonError"); + }); + + it("should handle null", () => { + const result = captureException(null); + + expect(result.message).toBe("null"); + expect(result.type).toBe("NonError"); + }); + + it("should handle undefined", () => { + const result = captureException(undefined); + + expect(result.message).toBe("undefined"); + expect(result.type).toBe("NonError"); + }); + + it("should handle object errors", () => { + const result = captureException({ code: 404, message: "Not found" }); + + expect(result.message).toBe('{"code":404,"message":"Not found"}'); + expect(result.type).toBe("NonError"); + }); + + it("should handle objects with circular references", () => { + const obj: any = { name: "test" }; + obj.self = obj; // Circular reference + + const result = captureException(obj); + + expect(result.type).toBe("NonError"); + // Should not throw, should return some string representation + expect(typeof result.message).toBe("string"); + }); + }); + + describe("edge cases", () => { + it("should handle errors without stack traces", () => { + const error = new Error("No stack"); + delete error.stack; + + const result = captureException(error); + + expect(result.message).toBe("No stack"); + expect(result.type).toBe("Error"); + expect(result.stack).toBeUndefined(); + expect(result.frames).toBeUndefined(); + }); + + it("should handle errors with empty messages", () => { + const error = new Error(""); + + const result = captureException(error); + + expect(result.message).toBe(""); + expect(result.type).toBe("Error"); + }); + + it("should handle errors with only whitespace in stack", () => { + const error = new Error("Test"); + error.stack = " \n \n "; + + const result = captureException(error); + + expect(result.stack).toBe(" \n \n "); + expect(result.frames).toEqual([]); + }); + + it("should handle malformed stack traces gracefully", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at incomplete line without location + at /file:10:5 + at functionWithNoParens /file:20:5`; + + const result = captureException(error); + + // Should not crash, should parse what it can + expect(result.frames).toBeDefined(); + }); + }); + + describe("path normalization", () => { + describe("user home directory stripping", () => { + it("should strip macOS user home directories", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at userFunction (/Users/john/project/src/index.ts:10:5)`; + + const result = captureException(error); + + // Should find /src/ boundary and normalize to that + expect(result.frames![0].filename).toBe("src/index.ts"); + expect(result.frames![0].abs_path).toBe( + "/Users/john/project/src/index.ts", + ); + }); + + it("should strip Linux user home directories", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at userFunction (/home/ubuntu/app/src/server.ts:20:8)`; + + const result = captureException(error); + + // Should find /src/ boundary and normalize to that + expect(result.frames![0].filename).toBe("src/server.ts"); + expect(result.frames![0].abs_path).toBe( + "/home/ubuntu/app/src/server.ts", + ); + }); + + it("should strip Windows user home directories", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at userFunction (C:\\Users\\Jane\\projects\\myapp\\src\\index.ts:15:10)`; + + const result = captureException(error); + + // Should find /src/ boundary and normalize to that + expect(result.frames![0].filename).toBe("src/index.ts"); + expect(result.frames![0].abs_path).toBe( + "C:\\Users\\Jane\\projects\\myapp\\src\\index.ts", + ); + }); + }); + + describe("node_modules normalization", () => { + it("should normalize node_modules paths consistently", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at libFunction (/Users/john/project/node_modules/express/lib/router.js:42:10)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe( + "node_modules/express/lib/router.js", + ); + expect(result.frames![0].abs_path).toBe( + "/Users/john/project/node_modules/express/lib/router.js", + ); + expect(result.frames![0].in_app).toBe(false); + }); + + it("should handle scoped packages in node_modules", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (/app/node_modules/@scope/package/dist/index.js:15:20)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe( + "node_modules/@scope/package/dist/index.js", + ); + expect(result.frames![0].abs_path).toBe( + "/app/node_modules/@scope/package/dist/index.js", + ); + }); + + it("should handle nested node_modules", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at deepFunction (/app/node_modules/pkg1/node_modules/pkg2/index.js:10:5)`; + + const result = captureException(error); + + // Should take the last node_modules occurrence + expect(result.frames![0].filename).toBe("node_modules/pkg2/index.js"); + }); + + it("should handle Windows-style node_modules paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at libFunction (C:\\projects\\app\\node_modules\\lodash\\index.js:100:20)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("node_modules/lodash/index.js"); + expect(result.frames![0].abs_path).toBe( + "C:\\projects\\app\\node_modules\\lodash\\index.js", + ); + }); + }); + + describe("deployment path stripping", () => { + it("should strip /var/www/ paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (/var/www/myapp/src/api/users.ts:25:12)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("src/api/users.ts"); + expect(result.frames![0].abs_path).toBe( + "/var/www/myapp/src/api/users.ts", + ); + }); + + it("should strip /app/ paths (Docker, Heroku)", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at processRequest (/app/dist/server.js:50:8)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("dist/server.js"); + expect(result.frames![0].abs_path).toBe("/app/dist/server.js"); + }); + + it("should strip AWS Lambda paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at lambdaHandler (/var/task/src/handler.ts:30:15)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("src/handler.ts"); + expect(result.frames![0].abs_path).toBe("/var/task/src/handler.ts"); + }); + + it("should strip Docker container paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at containerMain (/usr/src/app/src/index.ts:10:5)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("src/index.ts"); + expect(result.frames![0].abs_path).toBe("/usr/src/app/src/index.ts"); + }); + + it("should strip /opt/ paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at appMain (/opt/myservice/lib/main.ts:15:8)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("lib/main.ts"); + expect(result.frames![0].abs_path).toBe("/opt/myservice/lib/main.ts"); + }); + + it("should strip /srv/ paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at serviceHandler (/srv/webapp/src/routes.ts:42:20)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("src/routes.ts"); + expect(result.frames![0].abs_path).toBe("/srv/webapp/src/routes.ts"); + }); + }); + + describe("project boundary detection", () => { + it("should find /src/ boundary", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (/some/long/path/to/project/src/components/Button.tsx:20:10)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("src/components/Button.tsx"); + }); + + it("should find /lib/ boundary", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at utility (/random/path/myproject/lib/utils/helper.ts:35:5)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("lib/utils/helper.ts"); + }); + + it("should find /dist/ boundary", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at compiled (/deployment/path/dist/server.js:100:15)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("dist/server.js"); + }); + + it("should use the last occurrence of project markers", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (/Users/john/src/project/src/index.ts:10:5)`; + + const result = captureException(error); + + // Should use the last /src/ occurrence + expect(result.frames![0].filename).toBe("src/index.ts"); + }); + }); + + describe("Node.js internal module normalization", () => { + it("should normalize node:internal/* to node:internal", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at Module._compile (node:internal/modules/cjs/loader:1105:14)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("node:internal"); + expect(result.frames![0].in_app).toBe(false); + }); + + it("should normalize node:fs/promises to node:fs", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at readFile (node:fs/promises:50:10)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("node:fs"); + expect(result.frames![0].in_app).toBe(false); + }); + + it("should preserve simple node: modules", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at processNextTick (node:process:400:5)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("node:process"); + expect(result.frames![0].in_app).toBe(false); + }); + }); + + describe("Windows path handling", () => { + it("should normalize Windows backslashes to forward slashes", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (C:\\projects\\myapp\\src\\utils\\helper.ts:25:10)`; + + const result = captureException(error); + + expect(result.frames![0].filename).not.toContain("\\"); + expect(result.frames![0].filename).toContain("/"); + }); + + it("should handle Windows paths with mixed separators", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at mixed (C:\\projects/myapp\\src/index.ts:10:5)`; + + const result = captureException(error); + + // Windows paths without Users directory won't match user home pattern + // But should still find /src/ boundary and normalize separators + expect(result.frames![0].filename).toBe("src/index.ts"); + expect(result.frames![0].filename).not.toContain("\\"); + }); + }); + + describe("complex real-world scenarios", () => { + it("should handle mix of user code and library code", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at userHandler (/Users/dev/project/src/api/handler.ts:50:10) + at expressMiddleware (/Users/dev/project/node_modules/express/lib/router.js:100:5) + at Module._compile (node:internal/modules/cjs/loader:1105:14)`; + + const result = captureException(error); + + // User code - should find /src/ boundary and normalize to that + expect(result.frames![0].filename).toBe("src/api/handler.ts"); + expect(result.frames![0].in_app).toBe(true); + + // Library code - should normalize node_modules + expect(result.frames![1].filename).toBe( + "node_modules/express/lib/router.js", + ); + expect(result.frames![1].in_app).toBe(false); + + // Node internal - should normalize + expect(result.frames![2].filename).toBe("node:internal"); + expect(result.frames![2].in_app).toBe(false); + }); + + it("should handle Docker deployment with node_modules", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at apiHandler (/app/dist/api/users.ts:30:8) + at validator (/app/node_modules/validator/lib/index.js:42:15)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("dist/api/users.ts"); + expect(result.frames![1].filename).toBe( + "node_modules/validator/lib/index.js", + ); + }); + + it("should handle AWS Lambda with layers", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (/var/task/src/lambda/handler.ts:20:5) + at runtime (/opt/nodejs/node_modules/aws-sdk/lib/service.js:150:10)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("src/lambda/handler.ts"); + expect(result.frames![1].filename).toBe( + "node_modules/aws-sdk/lib/service.js", + ); + }); + }); + + describe("URL scheme normalization", () => { + it("should handle file:// URLs from ESM modules", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (file:///Users/john/project/src/index.ts:10:5)`; + + const result = captureException(error); + + // Should strip file:// and then normalize the path + expect(result.frames![0].filename).toBe("src/index.ts"); + expect(result.frames![0].abs_path).toBe( + "file:///Users/john/project/src/index.ts", + ); + }); + + it("should handle file:// URLs with Windows paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (file:///C:/Users/Jane/project/src/index.ts:15:8)`; + + const result = captureException(error); + + // Should strip file:// and then normalize the Windows path + expect(result.frames![0].filename).toBe("src/index.ts"); + expect(result.frames![0].abs_path).toBe( + "file:///C:/Users/Jane/project/src/index.ts", + ); + }); + + it("should handle file:// URLs in node_modules", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at libFunction (file:///Users/john/project/node_modules/express/lib/router.js:50:5)`; + + const result = captureException(error); + + // Should normalize to node_modules path + expect(result.frames![0].filename).toBe( + "node_modules/express/lib/router.js", + ); + expect(result.frames![0].in_app).toBe(false); + }); + }); + + describe("special cases and edge cases", () => { + it("should preserve already-relative paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (src/index.ts:10:5)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("src/index.ts"); + expect(result.frames![0].abs_path).toBe("src/index.ts"); + }); + + it("should preserve special paths like native", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at Array.map (native)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("native"); + expect(result.frames![0].abs_path).toBe("native"); + }); + + it("should handle paths without common markers", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at unknownPath (/random/path/without/markers/file.ts:10:5)`; + + const result = captureException(error); + + // Should still strip leading slash, but can't normalize further + expect(result.frames![0].filename).toBe( + "random/path/without/markers/file.ts", + ); + }); + + it("should handle very short paths", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at handler (/index.ts:1:1)`; + + const result = captureException(error); + + expect(result.frames![0].filename).toBe("index.ts"); + }); + }); + + describe("backwards compatibility", () => { + it("should maintain abs_path field with original path", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at func (/Users/john/long/path/to/project/src/index.ts:10:5)`; + + const result = captureException(error); + + // filename should be normalized + expect(result.frames![0].filename).toBe("src/index.ts"); + + // abs_path should be unchanged + expect(result.frames![0].abs_path).toBe( + "/Users/john/long/path/to/project/src/index.ts", + ); + }); + + it("should maintain all other frame properties", () => { + const error = new Error("Test"); + error.stack = `Error: Test + at myFunction (/app/src/test.ts:42:15)`; + + const result = captureException(error); + + const frame = result.frames![0]; + expect(frame.function).toBe("myFunction"); + expect(frame.filename).toBeDefined(); + expect(frame.abs_path).toBeDefined(); + expect(frame.lineno).toBe(42); + expect(frame.colno).toBe(15); + expect(frame.in_app).toBeDefined(); + expect(typeof frame.in_app).toBe("boolean"); + }); + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index 3604a50..ad8d998 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,7 +78,7 @@ export interface Event { // Error tracking isError?: boolean; - error?: object; + error?: ErrorData; // Legacy fields for MCPCat API compatibility actorId?: string; // Maps to identifyActorGivenId in some contexts @@ -170,3 +170,28 @@ export interface MCPCatData { lastMcpSessionId?: string; // Track the last MCP sessionId we saw sessionSource: "mcp" | "mcpcat"; // Track whether session ID came from MCP protocol or MCPCat generation } + +// Error tracking types +export interface StackFrame { + filename: string; + function: string; // Function name or "" + lineno?: number; + colno?: number; + in_app: boolean; + abs_path?: string; +} + +export interface CauseData { + message: string; + type?: string; + stack?: string; + frames?: StackFrame[]; +} + +export interface ErrorData { + message: string; + type?: string; // Error class name (e.g., "TypeError", "Error") + stack?: string; // Full stack trace string + frames?: StackFrame[]; // Parsed stack frames + causes?: CauseData[]; +} From 6bf87ef4aa0cce8ad9abc96d36b04f5b8e5bafae Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 18 Nov 2025 17:58:07 +0100 Subject: [PATCH 2/2] rename types to be less typescript-specific --- src/modules/exceptions.ts | 28 ++++++++++++------------- src/tests/error-capture.test.ts | 14 +++++++------ src/tests/exceptions.test.ts | 36 ++++++++++++++++----------------- src/types.ts | 4 ++-- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/modules/exceptions.ts b/src/modules/exceptions.ts index d22a490..97e639b 100644 --- a/src/modules/exceptions.ts +++ b/src/modules/exceptions.ts @@ -1,4 +1,4 @@ -import { ErrorData, StackFrame, CauseData } from "../types.js"; +import { ErrorData, StackFrame, ChainedErrorData } from "../types.js"; // Maximum number of exceptions to capture in a cause chain const MAX_EXCEPTION_CHAIN_DEPTH = 10; @@ -37,9 +37,9 @@ export function captureException(error: unknown): ErrorData { } // Unwrap Error.cause chain - const causes = unwrapErrorCauses(error); - if (causes.length > 0) { - errorData.causes = causes; + const chainedErrors = unwrapErrorCauses(error); + if (chainedErrors.length > 0) { + errorData.chained_errors = chainedErrors; } return errorData; @@ -617,7 +617,7 @@ function makeRelativePath(filename: string): string { } /** - * Recursively unwraps Error.cause chain and returns array of causes. + * Recursively unwraps Error.cause chain and returns array of chained errors. * * Error.cause is a standard JavaScript feature that allows chaining errors: * const cause = new Error("Root cause"); @@ -626,10 +626,10 @@ function makeRelativePath(filename: string): string { * This function extracts all errors in the cause chain up to MAX_EXCEPTION_CHAIN_DEPTH. * * @param error - Error object to unwrap - * @returns Array of CauseData objects representing the cause chain + * @returns Array of ChainedErrorData objects representing the error chain */ -function unwrapErrorCauses(error: Error): CauseData[] { - const causes: CauseData[] = []; +function unwrapErrorCauses(error: Error): ChainedErrorData[] { + const chainedErrors: ChainedErrorData[] = []; const seenErrors = new Set(); let currentError: unknown = (error as any).cause; let depth = 0; @@ -637,7 +637,7 @@ function unwrapErrorCauses(error: Error): CauseData[] { while (currentError && depth < MAX_EXCEPTION_CHAIN_DEPTH) { // If cause is not an Error, stringify it and stop if (!(currentError instanceof Error)) { - causes.push({ + chainedErrors.push({ message: stringifyNonError(currentError), type: "NonError", }); @@ -650,24 +650,24 @@ function unwrapErrorCauses(error: Error): CauseData[] { } seenErrors.add(currentError); - const causeData: CauseData = { + const chainedErrorData: ChainedErrorData = { message: currentError.message || "", type: currentError.name || currentError.constructor?.name || "Error", }; if (currentError.stack) { - causeData.stack = currentError.stack; - causeData.frames = parseV8StackTrace(currentError.stack); + chainedErrorData.stack = currentError.stack; + chainedErrorData.frames = parseV8StackTrace(currentError.stack); } - causes.push(causeData); + chainedErrors.push(chainedErrorData); // Move to next cause in chain currentError = (currentError as any).cause; depth++; } - return causes; + return chainedErrors; } /** diff --git a/src/tests/error-capture.test.ts b/src/tests/error-capture.test.ts index 2cc3957..c2b14de 100644 --- a/src/tests/error-capture.test.ts +++ b/src/tests/error-capture.test.ts @@ -140,14 +140,16 @@ describe("Error Capture Integration Tests", () => { expect(errorEvent!.error!.type).toBe("Error"); // Verify cause chain is captured - expect(errorEvent!.error!.causes).toBeDefined(); - expect(errorEvent!.error!.causes!.length).toBe(1); - expect(errorEvent!.error!.causes![0].message).toBe("Root cause error"); - expect(errorEvent!.error!.causes![0].type).toBe("Error"); + expect(errorEvent!.error!.chained_errors).toBeDefined(); + expect(errorEvent!.error!.chained_errors!.length).toBe(1); + expect(errorEvent!.error!.chained_errors![0].message).toBe( + "Root cause error", + ); + expect(errorEvent!.error!.chained_errors![0].type).toBe("Error"); // Verify cause has its own stack trace - expect(errorEvent!.error!.causes![0].stack).toBeDefined(); - expect(errorEvent!.error!.causes![0].frames).toBeDefined(); + expect(errorEvent!.error!.chained_errors![0].stack).toBeDefined(); + expect(errorEvent!.error!.chained_errors![0].frames).toBeDefined(); } finally { await cleanup(); } diff --git a/src/tests/exceptions.test.ts b/src/tests/exceptions.test.ts index 7c37a61..b61eea0 100644 --- a/src/tests/exceptions.test.ts +++ b/src/tests/exceptions.test.ts @@ -171,10 +171,10 @@ describe("captureException", () => { const result = captureException(error); expect(result.message).toBe("Wrapper error"); - expect(result.causes).toBeDefined(); - expect(result.causes!.length).toBe(1); - expect(result.causes![0].message).toBe("Root cause"); - expect(result.causes![0].type).toBe("Error"); + expect(result.chained_errors).toBeDefined(); + expect(result.chained_errors!.length).toBe(1); + expect(result.chained_errors![0].message).toBe("Root cause"); + expect(result.chained_errors![0].type).toBe("Error"); }); it("should unwrap multiple causes", () => { @@ -184,10 +184,10 @@ describe("captureException", () => { const result = captureException(error); - expect(result.causes).toBeDefined(); - expect(result.causes!.length).toBe(2); - expect(result.causes![0].message).toBe("Middle cause"); - expect(result.causes![1].message).toBe("Root cause"); + expect(result.chained_errors).toBeDefined(); + expect(result.chained_errors!.length).toBe(2); + expect(result.chained_errors![0].message).toBe("Middle cause"); + expect(result.chained_errors![1].message).toBe("Root cause"); }); it("should limit cause chain to 10", () => { @@ -199,8 +199,8 @@ describe("captureException", () => { const result = captureException(error); - expect(result.causes).toBeDefined(); - expect(result.causes!.length).toBeLessThanOrEqual(10); + expect(result.chained_errors).toBeDefined(); + expect(result.chained_errors!.length).toBeLessThanOrEqual(10); }); it("should handle non-Error causes", () => { @@ -208,10 +208,10 @@ describe("captureException", () => { const result = captureException(error); - expect(result.causes).toBeDefined(); - expect(result.causes!.length).toBe(1); - expect(result.causes![0].message).toBe("string cause"); - expect(result.causes![0].type).toBe("NonError"); + expect(result.chained_errors).toBeDefined(); + expect(result.chained_errors!.length).toBe(1); + expect(result.chained_errors![0].message).toBe("string cause"); + expect(result.chained_errors![0].type).toBe("NonError"); }); it("should detect circular cause references", () => { @@ -223,8 +223,8 @@ describe("captureException", () => { const result = captureException(error2); // Should not crash, should stop at circular reference - expect(result.causes).toBeDefined(); - expect(result.causes!.length).toBeLessThanOrEqual(2); + expect(result.chained_errors).toBeDefined(); + expect(result.chained_errors!.length).toBeLessThanOrEqual(2); }); it("should capture stack traces for each cause", () => { @@ -235,8 +235,8 @@ describe("captureException", () => { expect(result.stack).toBeDefined(); expect(result.frames).toBeDefined(); - expect(result.causes![0].stack).toBeDefined(); - expect(result.causes![0].frames).toBeDefined(); + expect(result.chained_errors![0].stack).toBeDefined(); + expect(result.chained_errors![0].frames).toBeDefined(); }); }); diff --git a/src/types.ts b/src/types.ts index ad8d998..261e2fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -181,7 +181,7 @@ export interface StackFrame { abs_path?: string; } -export interface CauseData { +export interface ChainedErrorData { message: string; type?: string; stack?: string; @@ -193,5 +193,5 @@ export interface ErrorData { type?: string; // Error class name (e.g., "TypeError", "Error") stack?: string; // Full stack trace string frames?: StackFrame[]; // Parsed stack frames - causes?: CauseData[]; + chained_errors?: ChainedErrorData[]; }