diff --git a/src/modules/exceptions.ts b/src/modules/exceptions.ts index 97e639b..a8146be 100644 --- a/src/modules/exceptions.ts +++ b/src/modules/exceptions.ts @@ -1,4 +1,5 @@ import { ErrorData, StackFrame, ChainedErrorData } from "../types.js"; +import { readFileSync } from "fs"; // Maximum number of exceptions to capture in a cause chain const MAX_EXCEPTION_CHAIN_DEPTH = 10; @@ -22,12 +23,14 @@ export function captureException(error: unknown): ErrorData { return { message: stringifyNonError(error), type: "NonError", + platform: "javascript", }; } const errorData: ErrorData = { message: error.message || "", type: error.name || error.constructor?.name || "Error", + platform: "javascript", }; // Capture stack trace if available @@ -76,6 +79,7 @@ function parseV8StackTrace(stackTrace: string): StackFrame[] { const frame = parseV8StackFrame(line.trim()); if (frame) { + addContextToFrame(frame); frames.push(frame); } @@ -88,6 +92,40 @@ function parseV8StackTrace(stackTrace: string): StackFrame[] { return frames; } +/** + * Adds context_line to a stack frame by reading the source file. + * + * This function extracts the line of code where the error occurred by: + * 1. Reading the source file using abs_path + * 2. Extracting the line at the specified line number + * 3. Setting the context_line field on the frame + * + * Only extracts context for user code (in_app: true) + * If the file cannot be read or the line number is invalid, context_line remains undefined. + * + * @param frame - The StackFrame to add context to (modified in place) + * @returns The modified StackFrame + */ +function addContextToFrame(frame: StackFrame): StackFrame { + if (!frame.in_app || !frame.abs_path || !frame.lineno) { + return frame; + } + + try { + const source = readFileSync(frame.abs_path, "utf8"); + const lines = source.split("\n"); + const lineIndex = frame.lineno - 1; // Convert to 0-based index + + if (lineIndex >= 0 && lineIndex < lines.length) { + frame.context_line = lines[lineIndex]; + } + } catch { + // File not found or not readable - silently skip + } + + return frame; +} + /** * Parses a location string from a V8 stack frame. * diff --git a/src/tests/exceptions.test.ts b/src/tests/exceptions.test.ts index b61eea0..a47a82c 100644 --- a/src/tests/exceptions.test.ts +++ b/src/tests/exceptions.test.ts @@ -44,6 +44,19 @@ describe("captureException", () => { expect(result.message).toBe("Custom error message"); expect(result.type).toBe("CustomError"); }); + + it("should always set platform to 'javascript'", () => { + const error = new Error("Test error"); + const result = captureException(error); + + expect(result.platform).toBe("javascript"); + }); + + it("should set platform to 'javascript' for non-Error objects", () => { + const result = captureException("string error"); + + expect(result.platform).toBe("javascript"); + }); }); describe("stack trace parsing", () => { @@ -161,6 +174,54 @@ describe("captureException", () => { expect(result.frames).toBeDefined(); expect(result.frames!.length).toBeLessThanOrEqual(50); }); + + it("should capture context_line for in_app frames", () => { + // This test throws a real error, so context_line should be captured + const error = new Error("Test error"); + const result = captureException(error); + + expect(result.frames).toBeDefined(); + const inAppFrames = result.frames!.filter((frame) => frame.in_app); + expect(inAppFrames.length).toBeGreaterThan(0); + + // At least one in_app frame should have context_line + const hasContextLine = inAppFrames.some( + (frame) => frame.context_line !== undefined, + ); + expect(hasContextLine).toBe(true); + }); + + it("should NOT capture context_line for library code (in_app: false)", () => { + // Create a mock stack trace with node_modules + const error = new Error("Test"); + error.stack = `Error: Test + 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(); + // All frames should be library code and should NOT have context_line + result.frames!.forEach((frame) => { + expect(frame.in_app).toBe(false); + expect(frame.context_line).toBeUndefined(); + }); + }); + + it("should handle missing files gracefully when extracting context_line", () => { + // Create a mock stack trace with a non-existent file + const error = new Error("Test"); + error.stack = `Error: Test + at testFunction (/nonexistent/file/path.ts:10:5)`; + + const result = captureException(error); + + expect(result.frames).toBeDefined(); + expect(result.frames!.length).toBe(1); + // Frame should be in_app but context_line should be undefined (file not found) + expect(result.frames![0].in_app).toBe(true); + expect(result.frames![0].context_line).toBeUndefined(); + }); }); describe("Error.cause chain", () => { diff --git a/src/types.ts b/src/types.ts index 261e2fe..fa384e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -179,6 +179,7 @@ export interface StackFrame { colno?: number; in_app: boolean; abs_path?: string; + context_line?: string; // The line of code where the error occurred } export interface ChainedErrorData { @@ -194,4 +195,5 @@ export interface ErrorData { stack?: string; // Full stack trace string frames?: StackFrame[]; // Parsed stack frames chained_errors?: ChainedErrorData[]; + platform?: string; // Platform identifier (e.g., "javascript", "node") }