Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
}
Expand Down
174 changes: 125 additions & 49 deletions src/addons/consoleCatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,51 @@
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
Expand All @@ -23,7 +58,7 @@
* @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;
}
Expand All @@ -45,7 +80,7 @@
*
* @param args - Console arguments that may include style directives
*/
function formatConsoleArgs(args: unknown[]): {
private formatConsoleArgs(args: unknown[]): {
message: string;
styles: string[];
} {
Expand All @@ -62,7 +97,7 @@
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)) + ']';
}
Expand Down Expand Up @@ -92,7 +127,7 @@
.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)) + ']';
}
Expand All @@ -105,26 +140,60 @@
};
}

/**
* 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)

Check warning on line 153 in src/addons/consoleCatcher.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @returns type
*/
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 };

Check warning on line 176 in src/addons/consoleCatcher.ts

View workflow job for this annotation

GitHub Actions / lint

Object properties must go on a new line
}

/**
* 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);
}

/**
* Creates a console log event from an error or promise rejection
*
* @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',
Expand All @@ -149,39 +218,55 @@
}

/**
* 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);
/**

Check warning on line 242 in src/addons/consoleCatcher.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "args" declaration
* 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);
};
});
Expand All @@ -192,27 +277,18 @@
*
* @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;
24 changes: 18 additions & 6 deletions src/catcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -98,6 +98,11 @@ export default class Catcher {
*/
private readonly consoleTracking: boolean;

/**
* Console catcher instance
*/
private readonly consoleCatcher: ConsoleCatcher | null = null;

/**
* Catcher constructor
*
Expand All @@ -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(
Expand All @@ -144,7 +155,8 @@ export default class Catcher {
});

if (this.consoleTracking) {
initConsoleCatcher();
this.consoleCatcher = ConsoleCatcher.getInstance();
this.consoleCatcher.init();
}

/**
Expand Down Expand Up @@ -284,7 +296,7 @@ export default class Catcher {
*/

if (this.consoleTracking) {
addErrorEvent(event);
this.consoleCatcher!.addErrorEvent(event);
}

/**
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading