How to Extend console.log and Keep the Correct Filename and Line Numbers Shown
It's common to use console.log
in JavaScript or Typescript applications to output some debugging information to the browser's console. Normally when you do this you simply call console.log('Some text');
and then you see your text in the console. You also get a handy piece of info on the right hand side that shows where in your code this log came from. That's what this is all about.
This can be very useful when debugging to see where logs are coming from. If you have source maps set up correctly you can usually click on that file/line number and see the code in the browser too.
This all works nicely. But when you want to extend your logging to do something else it starts to get complicated. Say you have an API you want to send a message to every time you log something in the console. We could write a simple function to do this:
function sendLogToApi(text) {
// Make some HTTP request to send this log off somewhere.
}
function log(text) {
sendLogToApi(text);
console.log(text);
}
And then we call our own log()
function instead of console.log()
.
// Old:
console.log('Hello world');
// New
log('Hello world');
There are two issues with this.
- We no longer see the real location of the log call. We only see where
console.log()
is called, and that will always be inside our helper function.
- We now need to remember to import our own
log
function and use that everywhere instead ofconsole.log,
which is globally available. That's extra hassle and creates a dependency on this function. It's also not going to work for third party code without modification.
We can solve problem number 2 easily. Instead of creating a new function, we'll replace console.log
with our own function.
function sendLogToApi(text) {
// Make some HTTP request to send this log off somewhere.
}
const originalConsoleLog = console.log.bind(console);
console.log = function (text) {
sendLogToApi(text);
originalConsoleLog(text);
}
Now all calls to console.log will call our custom function instead. We can switch back to console.log
instead of log
.
But we're still seeing functions.js
instead of demo.js
where the console.log
call is.
If you search around you will find some solutions to this, such as this: https://stackoverflow.com/questions/9559725/extending-console-log-without-affecting-log-line
These solutions focus mainly on cases where you want to enhance your console output, such as adding colour or adding a prefix to the text. If this is what you're after, you can use something like this:
Here we can see the original source line, and the additional text added the console output. Looking better!
But what about sending it to that API too?
This is where it seemed to get difficult. I wasn't able to find a solution that allowed you to perform additional actions while also keeping the line numbers. But I had seen this working, with one tool, so I started to dig into how that worked.
How Does Sentry Do It?
In one application I work on we use Sentry for monitoring errors. This is a very useful tool because it captures a lot of data leading up to any errors that are thrown, including all console logs!
We can see in Sentry the console.log calls leading up to an uncaught error:
Yet in the browser this console.log call has the original file shown, and not a file from Sentry's code:
Time to dig in to the source of Sentry to see how they're doing it. I created a quick test app, added Sentry, and threw an Error so that we can see things in Sentry.
import * as Sentry from "@sentry/browser";
Sentry.init({
dsn: "https://[email protected]/4507844115693568",
integrations: [
Sentry.browserTracingIntegration(),
],
});
console.log('Hello world');
// Throw an error so we can see it in Sentry
throw new Error('This is an error');
We see our error in Sentry:
And in the console we see the correct line for our log still, not anything weird like Sentry's JS.
Through some digging and searching in the Sentry code we come to this function:
function instrumentConsole() {
if (!('console' in GLOBAL_OBJ)) {
return;
}
CONSOLE_LEVELS.forEach(function (level) {
if (!(level in GLOBAL_OBJ.console)) {
return;
}
fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod) {
originalConsoleMethods[level] = originalConsoleMethod;
return function (...args) {
const handlerData = { args, level };
triggerHandlers('console', handlerData);
const log = originalConsoleMethods[level];
log && log.apply(GLOBAL_OBJ.console, args);
};
});
});
}
It appears to boil down to log.apply
again. But there is some magic we're not seeing. If we add something in here we can call extra code while not affecting the log line shown in the output:
function instrumentConsole() {
if (!('console' in GLOBAL_OBJ)) {
return;
}
CONSOLE_LEVELS.forEach(function (level) {
if (!(level in GLOBAL_OBJ.console)) {
return;
}
fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod) {
originalConsoleMethods[level] = originalConsoleMethod;
return function (...args) {
const handlerData = { args, level };
triggerHandlers('console', handlerData);
// Added this:
if (level !== 'warn') {
console.warn('Here we are in instrumentConsole and we saw:', level, args);
}
const log = originalConsoleMethods[level];
log && log.apply(GLOBAL_OBJ.console, args);
};
});
});
}
We can see that even the additional output from inside Sentry's handler also has the line of the original call shown.
I tried copying the code from Sentry to my own code to see if that would work, but it didn't. I'll save you the janky implementation of that.
At this point I was getting frustrated wondering what the secret was. And then I noticed something while refreshing the page. There is a split second where the console shows @sentry/browser/....
as the file name and not main.js
. This is something to do with the browser and not code!
How To Manually Exclude Source Files In Chrome
This page in Chrome's developer documentation explains how to do what we are looking for: https://developer.chrome.com/blog/devtools-modern-web-debugging
I believe Sentry was only working because it is in node_modules
and that is ignored by default.
Find the file where all your logs are coming from. In this case it is ConsoleLogBuffer.js
Click on it to open it in the Sources tab. Then right click the tab and select Add script to ignore list
With that ignored we suddenly see all the real sources of the logs again:
Now, that's quite a manual process isn't it. Can we do something programmatically here?
Enter ignoreList
You can also achieve this automatically (in Chrome and Chrome-based browsers) by adding an ignoreList
to your source maps.
If you are using Webpack there is a plugin created by Mengdi Chen to do this devtools-ignore-webpack-plugin
This allows you to specify the files Chrome should ignore without manually having to add them.
plugins: [
new DevToolsIgnorePlugin({
shouldIgnorePath(p) {
// Tell Chrome to ignore ConsoleLogBuffer.js so it doesn't show up as the source of console.log calls.
// See https://github.com/mondaychen/devtools-ignore-webpack-plugin
if (p.includes('ConsoleLogBuffer.js')) {
return true;
}
return p.includes('/node_modules/') || p.includes('/webpack/');
},
}),
],
Adjust the ConsoleLogBuffer.js
path as appropriate, rebuild you app, and now you should see the correct sources in the Chrome developer tools again.
Note: This plugin adds a property under the old name x_google_ignoreList
. There is a new standard property called ignoreList
which is now supported by Chrome https://developer.chrome.com/blog/new-in-devtools-120#ignore-list-spec
At the time of writing only Chrome supports this and this doesn't work in Firefox. Hopefully it will also support the standard ignoreList
soon.