diff --git a/package.json b/package.json index ebdf1e5..64ac827 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hawk.so/javascript", "type": "commonjs", - "version": "3.2.10", + "version": "3.2.11", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" @@ -47,7 +47,7 @@ "vue": "^2" }, "dependencies": { - "@hawk.so/types": "^0.1.35", + "@hawk.so/types": "^0.1.36", "error-stack-parser": "^2.1.4", "vite-plugin-dts": "^4.2.4" } diff --git a/src/addons/consoleCatcher.ts b/src/addons/consoleCatcher.ts index 8989d60..23a9326 100644 --- a/src/addons/consoleCatcher.ts +++ b/src/addons/consoleCatcher.ts @@ -5,16 +5,51 @@ import type { ConsoleLogEvent } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; /** - * Creates a console interceptor that captures and formats console output + * Maximum number of console logs to store */ -function createConsoleCatcher(): { - initConsoleCatcher: () => void; - addErrorEvent: (event: ErrorEvent | PromiseRejectionEvent) => void; - getConsoleLogStack: () => ConsoleLogEvent[]; - } { - const MAX_LOGS = 20; - const consoleOutput: ConsoleLogEvent[] = []; - let isInitialized = false; +const MAX_LOGS = 20; + +/** + * Console methods to intercept + */ +const CONSOLE_METHODS: string[] = ['log', 'warn', 'error', 'info', 'debug']; + +/** + * Console catcher class for intercepting and capturing console logs. + * + * This singleton class wraps native console methods to capture all console output with accurate + * stack traces. When developers click on console messages in DevTools, they are taken to the + * original call site in their code, not to the interceptor's code. + */ +export class ConsoleCatcher { + /** + * Singleton instance + */ + private static instance: ConsoleCatcher | null = null; + + /** + * Console output buffer + */ + private readonly consoleOutput: ConsoleLogEvent[] = []; + + /** + * Initialization flag + */ + private isInitialized = false; + + /** + * Private constructor to enforce singleton pattern + */ + private constructor() {} + + /** + * Get singleton instance + */ + public static getInstance(): ConsoleCatcher { + ConsoleCatcher.instance ??= new ConsoleCatcher(); + + return ConsoleCatcher.instance; + } /** * Converts any argument to its string representation @@ -23,7 +58,7 @@ function createConsoleCatcher(): { * @throws Error if the argument can not be stringified, for example by such reason: * SecurityError: Failed to read a named property 'toJSON' from 'Window': Blocked a frame with origin "https://codex.so" from accessing a cross-origin frame. */ - function stringifyArg(arg: unknown): string { + private stringifyArg(arg: unknown): string { if (typeof arg === 'string') { return arg; } @@ -45,7 +80,7 @@ function createConsoleCatcher(): { * * @param args - Console arguments that may include style directives */ - function formatConsoleArgs(args: unknown[]): { + private formatConsoleArgs(args: unknown[]): { message: string; styles: string[]; } { @@ -62,7 +97,7 @@ function createConsoleCatcher(): { return { message: args.map(arg => { try { - return stringifyArg(arg); + return this.stringifyArg(arg); } catch (error) { return '[Error stringifying argument: ' + (error instanceof Error ? error.message : String(error)) + ']'; } @@ -92,7 +127,7 @@ function createConsoleCatcher(): { .slice(styles.length + 1) .map(arg => { try { - return stringifyArg(arg); + return this.stringifyArg(arg); } catch (error) { return '[Error stringifying argument: ' + (error instanceof Error ? error.message : String(error)) + ']'; } @@ -105,16 +140,52 @@ function createConsoleCatcher(): { }; } + /** + * Extracts user code stack trace from the full stack trace. + * + * Dynamic stack frame identification: + * - Problem: Fixed slice(2) doesn't work reliably because the number of internal frames + * varies based on code structure (arrow functions, class methods, TS→JS transforms, etc.). + * - Solution: Find the first stack frame that doesn't belong to consoleCatcher module. + * This ensures DevTools will navigate to the user's code, not the interceptor's code. + * + * @param errorStack - Full stack trace string from Error.stack + * @returns Object with userStack (full stack from user code) and fileLine (first frame for DevTools link) + */ + private extractUserStack(errorStack: string | undefined): { + userStack: string; + fileLine: string; + } { + const stackLines = errorStack?.split('\n') || []; + const consoleCatcherPattern = /consoleCatcher/i; + let userFrameIndex = 1; // Skip Error message line + + // Find first frame that doesn't belong to consoleCatcher module + for (let i = 1; i < stackLines.length; i++) { + if (!consoleCatcherPattern.test(stackLines[i])) { + userFrameIndex = i; + break; + } + } + + // Extract user code stack (everything from the first non-consoleCatcher frame) + const userStack = stackLines.slice(userFrameIndex).join('\n'); + // First frame is used as fileLine - this is what DevTools shows as clickable link + const fileLine = stackLines[userFrameIndex]?.trim() || ''; + + return { userStack, fileLine }; + } + /** * Adds a console log event to the output buffer * * @param logEvent - The console log event to be added to the output buffer */ - function addToConsoleOutput(logEvent: ConsoleLogEvent): void { - if (consoleOutput.length >= MAX_LOGS) { - consoleOutput.shift(); + private addToConsoleOutput(logEvent: ConsoleLogEvent): void { + if (this.consoleOutput.length >= MAX_LOGS) { + this.consoleOutput.shift(); } - consoleOutput.push(logEvent); + this.consoleOutput.push(logEvent); } /** @@ -122,9 +193,7 @@ function createConsoleCatcher(): { * * @param event - The error event or promise rejection event to convert */ - function createConsoleEventFromError( - event: ErrorEvent | PromiseRejectionEvent - ): ConsoleLogEvent { + private createConsoleEventFromError(event: ErrorEvent | PromiseRejectionEvent): ConsoleLogEvent { if (event instanceof ErrorEvent) { return { method: 'error', @@ -149,39 +218,55 @@ function createConsoleCatcher(): { } /** - * Initializes the console interceptor by overriding default console methods + * Initializes the console interceptor by overriding default console methods. + * + * Wraps native console methods to intercept all calls, capture their context, and generate + * accurate stack traces that point to the original call site (not the interceptor). */ - function initConsoleCatcher(): void { - if (isInitialized) { + // eslint-disable-next-line @typescript-eslint/member-ordering + public init(): void { + if (this.isInitialized) { return; } - isInitialized = true; - const consoleMethods: string[] = ['log', 'warn', 'error', 'info', 'debug']; + this.isInitialized = true; - consoleMethods.forEach(function overrideConsoleMethod(method) { + CONSOLE_METHODS.forEach((method) => { if (typeof window.console[method] !== 'function') { return; } + // Store original function to forward calls after interception const oldFunction = window.console[method].bind(window.console); - window.console[method] = function (...args: unknown[]): void { - const stack = new Error().stack?.split('\n').slice(2) - .join('\n') || ''; - const { message, styles } = formatConsoleArgs(args); + /** + * Override console method to intercept all calls. + * + * For each intercepted call, we: + * 1. Generate a stack trace to find the original call site + * 2. Format the console arguments into a structured message + * 3. Create a ConsoleLogEvent with metadata + * 4. Store it in the buffer + * 5. Forward the call to the native console (so output still appears in DevTools) + */ + window.console[method] = (...args: unknown[]): void => { + // Capture full stack trace and extract user code stack + const errorStack = new Error('Console log stack trace').stack; + const { userStack, fileLine } = this.extractUserStack(errorStack); + const { message, styles } = this.formatConsoleArgs(args); const logEvent: ConsoleLogEvent = { method, timestamp: new Date(), type: method, message, - stack, - fileLine: stack.split('\n')[0]?.trim(), + stack: userStack, + fileLine, styles, }; - addToConsoleOutput(logEvent); + this.addToConsoleOutput(logEvent); + // Forward to native console so output still appears in DevTools oldFunction(...args); }; }); @@ -192,27 +277,18 @@ function createConsoleCatcher(): { * * @param event - The error or promise rejection event to handle */ - function addErrorEvent(event: ErrorEvent | PromiseRejectionEvent): void { - const logEvent = createConsoleEventFromError(event); + // eslint-disable-next-line @typescript-eslint/member-ordering + public addErrorEvent(event: ErrorEvent | PromiseRejectionEvent): void { + const logEvent = this.createConsoleEventFromError(event); - addToConsoleOutput(logEvent); + this.addToConsoleOutput(logEvent); } /** * Returns the current console output buffer */ - function getConsoleLogStack(): ConsoleLogEvent[] { - return [ ...consoleOutput ]; + // eslint-disable-next-line @typescript-eslint/member-ordering + public getConsoleLogStack(): ConsoleLogEvent[] { + return [ ...this.consoleOutput ]; } - - return { - initConsoleCatcher, - addErrorEvent, - getConsoleLogStack, - }; } - -const consoleCatcher = createConsoleCatcher(); - -export const { initConsoleCatcher, getConsoleLogStack, addErrorEvent } = - consoleCatcher; diff --git a/src/catcher.ts b/src/catcher.ts index 6b8d9b8..2908238 100644 --- a/src/catcher.ts +++ b/src/catcher.ts @@ -16,7 +16,7 @@ import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; import type { HawkJavaScriptEvent } from './types'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; -import { addErrorEvent, getConsoleLogStack, initConsoleCatcher } from './addons/consoleCatcher'; +import { ConsoleCatcher } from './addons/consoleCatcher'; import { validateUser, validateContext } from './utils/validation'; /** @@ -98,6 +98,11 @@ export default class Catcher { */ private readonly consoleTracking: boolean; + /** + * Console catcher instance + */ + private readonly consoleCatcher: ConsoleCatcher | null = null; + /** * Catcher constructor * @@ -116,8 +121,14 @@ export default class Catcher { this.setUser(settings.user || Catcher.getGeneratedUser()); this.setContext(settings.context || undefined); this.beforeSend = settings.beforeSend; - this.disableVueErrorHandler = settings.disableVueErrorHandler !== null && settings.disableVueErrorHandler !== undefined ? settings.disableVueErrorHandler : false; - this.consoleTracking = settings.consoleTracking !== null && settings.consoleTracking !== undefined ? settings.consoleTracking : true; + this.disableVueErrorHandler = + settings.disableVueErrorHandler !== null && settings.disableVueErrorHandler !== undefined + ? settings.disableVueErrorHandler + : false; + this.consoleTracking = + settings.consoleTracking !== null && settings.consoleTracking !== undefined + ? settings.consoleTracking + : true; if (!this.token) { log( @@ -144,7 +155,8 @@ export default class Catcher { }); if (this.consoleTracking) { - initConsoleCatcher(); + this.consoleCatcher = ConsoleCatcher.getInstance(); + this.consoleCatcher.init(); } /** @@ -284,7 +296,7 @@ export default class Catcher { */ if (this.consoleTracking) { - addErrorEvent(event); + this.consoleCatcher!.addErrorEvent(event); } /** @@ -551,7 +563,7 @@ export default class Catcher { const userAgent = window.navigator.userAgent; const location = window.location.href; const getParams = this.getGetParams(); - const consoleLogs = this.consoleTracking && getConsoleLogStack(); + const consoleLogs = this.consoleTracking && this.consoleCatcher?.getConsoleLogStack(); const addons: JavaScriptAddons = { window: { diff --git a/yarn.lock b/yarn.lock index 7f9c52f..3aa494e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -316,10 +316,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@hawk.so/types@^0.1.35": - version "0.1.35" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.35.tgz#6afd416dced1cc3282d721ca5621bf452b27aea1" - integrity sha512-uMTAeu6DlRlk+oputJBjTlrm1GzOkIwlMfGhpdOp3sRWe/YPGD6nMYlb9MZoVN6Yee7RIpYD7It+DPeUPAyIFw== +"@hawk.so/types@^0.1.36": + version "0.1.36" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.36.tgz#234b0e4c81bf5f50b1208910d45fc4ffb62e8ae1" + integrity sha512-AjW4FZPMqlDoXk63ntkTGOC1tdbHuGXIhEbVtBvz8YC9A7qcuxenzfGtjwuW6B9tqyADMGehh+/d+uQbAX7w0Q== dependencies: "@types/mongodb" "^3.5.34"