-
Notifications
You must be signed in to change notification settings - Fork 3
refactor(console-catcher): convert to class with singleton pattern #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Dobrunia
wants to merge
14
commits into
master
Choose a base branch
from
refactor/js-console-catcher
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
bbe13cc
chore: update @hawk.so/types to version 0.1.36 and use constant for c…
Dobrunia 643ff11
refactor: implement ConsoleCatcher class for improved console log han…
Dobrunia c64f091
chore: remove packageManager field from package.json for cleaner conf…
Dobrunia 5e1eef0
fix: update console method reference from globalThis to window in Con…
Dobrunia 20272ce
chore: add TODOs for future improvements in ConsoleCatcher
Dobrunia e8479b5
chore: add eslint-disable comments for member ordering in ConsoleCatc…
Dobrunia 370b173
lint fix
Dobrunia 8c663cf
lint fix
Dobrunia 52da0a9
bump version
Dobrunia 38a0d73
remove todos
Dobrunia 8a977c4
chore: initialize consoleCatcher conditionally and handle potential n…
Dobrunia cba2218
fix: Console log stack trace
Dobrunia c43204c
chore: add docks
Dobrunia 5a5b924
chore: implement user stack extraction in ConsoleCatcher metod
Dobrunia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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[]; | ||
| } { | ||
|
|
@@ -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)) + ']'; | ||
| } | ||
|
|
@@ -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)) + ']'; | ||
| } | ||
|
|
@@ -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
|
||
| */ | ||
| 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); | ||
| } | ||
|
|
||
| /** | ||
| * 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', | ||
|
|
@@ -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
|
||
| * 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 @@ | |
| * | ||
| * @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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.