7 min read

How to Extend console.log and Keep the Correct Filename and Line Numbers Shown

How to Extend console.log and Keep the Correct Filename and Line Numbers Shown
Spoiler: You can't achieve this in code alone, but you can use a feature of Chrome to make it work.

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.

We see that console.log was called from demo.js on line 3.

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.

  1. 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.
With many console.log calls it now looks like logging.js is doing all the work around here.
  1. We now need to remember to import our own log function and use that everywhere instead of console.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:

console.log = Function.prototype.bind.call(console.log, console, new Date(), 'You logged:');

This will add the current date and the text "You logged:" to the start of each console.log output, while keeping line numbers preserved.

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?

The Sentry part is a red-herring. Here I explain what I tried to find the solution in Sentry's code, but there wasn't one.

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:

Somehow Sentry is doing what we want to achieve.

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.