@@ -5,16 +5,51 @@ import type { ConsoleLogEvent } from '@hawk.so/types';
55import 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 = / c o n s o l e C a t c h e r / 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 ;
0 commit comments