Skip to content

Commit 001c8ab

Browse files
authored
Merge pull request #132 from codex-team/refactor/js-console-catcher
refactor(console-catcher): convert to class with singleton pattern
2 parents 6567338 + 5a5b924 commit 001c8ab

File tree

4 files changed

+148
-60
lines changed

4 files changed

+148
-60
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"vue": "^2"
4848
},
4949
"dependencies": {
50-
"@hawk.so/types": "^0.1.35",
50+
"@hawk.so/types": "^0.1.36",
5151
"error-stack-parser": "^2.1.4",
5252
"vite-plugin-dts": "^4.2.4"
5353
}

src/addons/consoleCatcher.ts

Lines changed: 125 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,51 @@ import type { ConsoleLogEvent } from '@hawk.so/types';
55
import Sanitizer from '../modules/sanitizer';
66

77
/**
8-
* Creates a console interceptor that captures and formats console output
8+
* Maximum number of console logs to store
99
*/
10-
function createConsoleCatcher(): {
11-
initConsoleCatcher: () => void;
12-
addErrorEvent: (event: ErrorEvent | PromiseRejectionEvent) => void;
13-
getConsoleLogStack: () => ConsoleLogEvent[];
14-
} {
15-
const MAX_LOGS = 20;
16-
const consoleOutput: ConsoleLogEvent[] = [];
17-
let isInitialized = false;
10+
const MAX_LOGS = 20;
11+
12+
/**
13+
* Console methods to intercept
14+
*/
15+
const CONSOLE_METHODS: string[] = ['log', 'warn', 'error', 'info', 'debug'];
16+
17+
/**
18+
* Console catcher class for intercepting and capturing console logs.
19+
*
20+
* This singleton class wraps native console methods to capture all console output with accurate
21+
* stack traces. When developers click on console messages in DevTools, they are taken to the
22+
* original call site in their code, not to the interceptor's code.
23+
*/
24+
export class ConsoleCatcher {
25+
/**
26+
* Singleton instance
27+
*/
28+
private static instance: ConsoleCatcher | null = null;
29+
30+
/**
31+
* Console output buffer
32+
*/
33+
private readonly consoleOutput: ConsoleLogEvent[] = [];
34+
35+
/**
36+
* Initialization flag
37+
*/
38+
private isInitialized = false;
39+
40+
/**
41+
* Private constructor to enforce singleton pattern
42+
*/
43+
private constructor() {}
44+
45+
/**
46+
* Get singleton instance
47+
*/
48+
public static getInstance(): ConsoleCatcher {
49+
ConsoleCatcher.instance ??= new ConsoleCatcher();
50+
51+
return ConsoleCatcher.instance;
52+
}
1853

1954
/**
2055
* Converts any argument to its string representation
@@ -23,7 +58,7 @@ function createConsoleCatcher(): {
2358
* @throws Error if the argument can not be stringified, for example by such reason:
2459
* SecurityError: Failed to read a named property 'toJSON' from 'Window': Blocked a frame with origin "https://codex.so" from accessing a cross-origin frame.
2560
*/
26-
function stringifyArg(arg: unknown): string {
61+
private stringifyArg(arg: unknown): string {
2762
if (typeof arg === 'string') {
2863
return arg;
2964
}
@@ -45,7 +80,7 @@ function createConsoleCatcher(): {
4580
*
4681
* @param args - Console arguments that may include style directives
4782
*/
48-
function formatConsoleArgs(args: unknown[]): {
83+
private formatConsoleArgs(args: unknown[]): {
4984
message: string;
5085
styles: string[];
5186
} {
@@ -62,7 +97,7 @@ function createConsoleCatcher(): {
6297
return {
6398
message: args.map(arg => {
6499
try {
65-
return stringifyArg(arg);
100+
return this.stringifyArg(arg);
66101
} catch (error) {
67102
return '[Error stringifying argument: ' + (error instanceof Error ? error.message : String(error)) + ']';
68103
}
@@ -92,7 +127,7 @@ function createConsoleCatcher(): {
92127
.slice(styles.length + 1)
93128
.map(arg => {
94129
try {
95-
return stringifyArg(arg);
130+
return this.stringifyArg(arg);
96131
} catch (error) {
97132
return '[Error stringifying argument: ' + (error instanceof Error ? error.message : String(error)) + ']';
98133
}
@@ -105,26 +140,60 @@ function createConsoleCatcher(): {
105140
};
106141
}
107142

143+
/**
144+
* Extracts user code stack trace from the full stack trace.
145+
*
146+
* Dynamic stack frame identification:
147+
* - Problem: Fixed slice(2) doesn't work reliably because the number of internal frames
148+
* varies based on code structure (arrow functions, class methods, TS→JS transforms, etc.).
149+
* - Solution: Find the first stack frame that doesn't belong to consoleCatcher module.
150+
* This ensures DevTools will navigate to the user's code, not the interceptor's code.
151+
*
152+
* @param errorStack - Full stack trace string from Error.stack
153+
* @returns Object with userStack (full stack from user code) and fileLine (first frame for DevTools link)
154+
*/
155+
private extractUserStack(errorStack: string | undefined): {
156+
userStack: string;
157+
fileLine: string;
158+
} {
159+
const stackLines = errorStack?.split('\n') || [];
160+
const consoleCatcherPattern = /consoleCatcher/i;
161+
let userFrameIndex = 1; // Skip Error message line
162+
163+
// Find first frame that doesn't belong to consoleCatcher module
164+
for (let i = 1; i < stackLines.length; i++) {
165+
if (!consoleCatcherPattern.test(stackLines[i])) {
166+
userFrameIndex = i;
167+
break;
168+
}
169+
}
170+
171+
// Extract user code stack (everything from the first non-consoleCatcher frame)
172+
const userStack = stackLines.slice(userFrameIndex).join('\n');
173+
// First frame is used as fileLine - this is what DevTools shows as clickable link
174+
const fileLine = stackLines[userFrameIndex]?.trim() || '';
175+
176+
return { userStack, fileLine };
177+
}
178+
108179
/**
109180
* Adds a console log event to the output buffer
110181
*
111182
* @param logEvent - The console log event to be added to the output buffer
112183
*/
113-
function addToConsoleOutput(logEvent: ConsoleLogEvent): void {
114-
if (consoleOutput.length >= MAX_LOGS) {
115-
consoleOutput.shift();
184+
private addToConsoleOutput(logEvent: ConsoleLogEvent): void {
185+
if (this.consoleOutput.length >= MAX_LOGS) {
186+
this.consoleOutput.shift();
116187
}
117-
consoleOutput.push(logEvent);
188+
this.consoleOutput.push(logEvent);
118189
}
119190

120191
/**
121192
* Creates a console log event from an error or promise rejection
122193
*
123194
* @param event - The error event or promise rejection event to convert
124195
*/
125-
function createConsoleEventFromError(
126-
event: ErrorEvent | PromiseRejectionEvent
127-
): ConsoleLogEvent {
196+
private createConsoleEventFromError(event: ErrorEvent | PromiseRejectionEvent): ConsoleLogEvent {
128197
if (event instanceof ErrorEvent) {
129198
return {
130199
method: 'error',
@@ -149,39 +218,55 @@ function createConsoleCatcher(): {
149218
}
150219

151220
/**
152-
* Initializes the console interceptor by overriding default console methods
221+
* Initializes the console interceptor by overriding default console methods.
222+
*
223+
* Wraps native console methods to intercept all calls, capture their context, and generate
224+
* accurate stack traces that point to the original call site (not the interceptor).
153225
*/
154-
function initConsoleCatcher(): void {
155-
if (isInitialized) {
226+
// eslint-disable-next-line @typescript-eslint/member-ordering
227+
public init(): void {
228+
if (this.isInitialized) {
156229
return;
157230
}
158231

159-
isInitialized = true;
160-
const consoleMethods: string[] = ['log', 'warn', 'error', 'info', 'debug'];
232+
this.isInitialized = true;
161233

162-
consoleMethods.forEach(function overrideConsoleMethod(method) {
234+
CONSOLE_METHODS.forEach((method) => {
163235
if (typeof window.console[method] !== 'function') {
164236
return;
165237
}
166238

239+
// Store original function to forward calls after interception
167240
const oldFunction = window.console[method].bind(window.console);
168241

169-
window.console[method] = function (...args: unknown[]): void {
170-
const stack = new Error().stack?.split('\n').slice(2)
171-
.join('\n') || '';
172-
const { message, styles } = formatConsoleArgs(args);
242+
/**
243+
* Override console method to intercept all calls.
244+
*
245+
* For each intercepted call, we:
246+
* 1. Generate a stack trace to find the original call site
247+
* 2. Format the console arguments into a structured message
248+
* 3. Create a ConsoleLogEvent with metadata
249+
* 4. Store it in the buffer
250+
* 5. Forward the call to the native console (so output still appears in DevTools)
251+
*/
252+
window.console[method] = (...args: unknown[]): void => {
253+
// Capture full stack trace and extract user code stack
254+
const errorStack = new Error('Console log stack trace').stack;
255+
const { userStack, fileLine } = this.extractUserStack(errorStack);
256+
const { message, styles } = this.formatConsoleArgs(args);
173257

174258
const logEvent: ConsoleLogEvent = {
175259
method,
176260
timestamp: new Date(),
177261
type: method,
178262
message,
179-
stack,
180-
fileLine: stack.split('\n')[0]?.trim(),
263+
stack: userStack,
264+
fileLine,
181265
styles,
182266
};
183267

184-
addToConsoleOutput(logEvent);
268+
this.addToConsoleOutput(logEvent);
269+
// Forward to native console so output still appears in DevTools
185270
oldFunction(...args);
186271
};
187272
});
@@ -192,27 +277,18 @@ function createConsoleCatcher(): {
192277
*
193278
* @param event - The error or promise rejection event to handle
194279
*/
195-
function addErrorEvent(event: ErrorEvent | PromiseRejectionEvent): void {
196-
const logEvent = createConsoleEventFromError(event);
280+
// eslint-disable-next-line @typescript-eslint/member-ordering
281+
public addErrorEvent(event: ErrorEvent | PromiseRejectionEvent): void {
282+
const logEvent = this.createConsoleEventFromError(event);
197283

198-
addToConsoleOutput(logEvent);
284+
this.addToConsoleOutput(logEvent);
199285
}
200286

201287
/**
202288
* Returns the current console output buffer
203289
*/
204-
function getConsoleLogStack(): ConsoleLogEvent[] {
205-
return [ ...consoleOutput ];
290+
// eslint-disable-next-line @typescript-eslint/member-ordering
291+
public getConsoleLogStack(): ConsoleLogEvent[] {
292+
return [ ...this.consoleOutput ];
206293
}
207-
208-
return {
209-
initConsoleCatcher,
210-
addErrorEvent,
211-
getConsoleLogStack,
212-
};
213294
}
214-
215-
const consoleCatcher = createConsoleCatcher();
216-
217-
export const { initConsoleCatcher, getConsoleLogStack, addErrorEvent } =
218-
consoleCatcher;

src/catcher.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { JavaScriptCatcherIntegrations } from './types/integrations';
1616
import { EventRejectedError } from './errors';
1717
import type { HawkJavaScriptEvent } from './types';
1818
import { isErrorProcessed, markErrorAsProcessed } from './utils/event';
19-
import { addErrorEvent, getConsoleLogStack, initConsoleCatcher } from './addons/consoleCatcher';
19+
import { ConsoleCatcher } from './addons/consoleCatcher';
2020
import { validateUser, validateContext } from './utils/validation';
2121

2222
/**
@@ -98,6 +98,11 @@ export default class Catcher {
9898
*/
9999
private readonly consoleTracking: boolean;
100100

101+
/**
102+
* Console catcher instance
103+
*/
104+
private readonly consoleCatcher: ConsoleCatcher | null = null;
105+
101106
/**
102107
* Catcher constructor
103108
*
@@ -116,8 +121,14 @@ export default class Catcher {
116121
this.setUser(settings.user || Catcher.getGeneratedUser());
117122
this.setContext(settings.context || undefined);
118123
this.beforeSend = settings.beforeSend;
119-
this.disableVueErrorHandler = settings.disableVueErrorHandler !== null && settings.disableVueErrorHandler !== undefined ? settings.disableVueErrorHandler : false;
120-
this.consoleTracking = settings.consoleTracking !== null && settings.consoleTracking !== undefined ? settings.consoleTracking : true;
124+
this.disableVueErrorHandler =
125+
settings.disableVueErrorHandler !== null && settings.disableVueErrorHandler !== undefined
126+
? settings.disableVueErrorHandler
127+
: false;
128+
this.consoleTracking =
129+
settings.consoleTracking !== null && settings.consoleTracking !== undefined
130+
? settings.consoleTracking
131+
: true;
121132

122133
if (!this.token) {
123134
log(
@@ -144,7 +155,8 @@ export default class Catcher {
144155
});
145156

146157
if (this.consoleTracking) {
147-
initConsoleCatcher();
158+
this.consoleCatcher = ConsoleCatcher.getInstance();
159+
this.consoleCatcher.init();
148160
}
149161

150162
/**
@@ -284,7 +296,7 @@ export default class Catcher {
284296
*/
285297

286298
if (this.consoleTracking) {
287-
addErrorEvent(event);
299+
this.consoleCatcher!.addErrorEvent(event);
288300
}
289301

290302
/**
@@ -551,7 +563,7 @@ export default class Catcher {
551563
const userAgent = window.navigator.userAgent;
552564
const location = window.location.href;
553565
const getParams = this.getGetParams();
554-
const consoleLogs = this.consoleTracking && getConsoleLogStack();
566+
const consoleLogs = this.consoleTracking && this.consoleCatcher?.getConsoleLogStack();
555567

556568
const addons: JavaScriptAddons = {
557569
window: {

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,10 @@
316316
minimatch "^3.0.4"
317317
strip-json-comments "^3.1.1"
318318

319-
"@hawk.so/types@^0.1.35":
320-
version "0.1.35"
321-
resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.35.tgz#6afd416dced1cc3282d721ca5621bf452b27aea1"
322-
integrity sha512-uMTAeu6DlRlk+oputJBjTlrm1GzOkIwlMfGhpdOp3sRWe/YPGD6nMYlb9MZoVN6Yee7RIpYD7It+DPeUPAyIFw==
319+
"@hawk.so/types@^0.1.36":
320+
version "0.1.36"
321+
resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.1.36.tgz#234b0e4c81bf5f50b1208910d45fc4ffb62e8ae1"
322+
integrity sha512-AjW4FZPMqlDoXk63ntkTGOC1tdbHuGXIhEbVtBvz8YC9A7qcuxenzfGtjwuW6B9tqyADMGehh+/d+uQbAX7w0Q==
323323
dependencies:
324324
"@types/mongodb" "^3.5.34"
325325

0 commit comments

Comments
 (0)