DEV Community

Safia Abdalla
Safia Abdalla

Posted on

Node module deep-dive: console

Howdy there! Yep, it’s another Monday. That means it’s time for me to head over to the good ol’ GitHub dot com for another Node module deep-dive. If you’re new around here, I’ve been going through and reading the Node code base over the past few weeks. My goal is to learn more about what goes on under the hood in the Node standard library and to learn some new code patterns. This week, I’m diving into the console module. At this point, we need to take a break and give some well-deserved thanks to console, the most reliable debugging tool that ever lived! For the unfamiliar, console exposes a set of methods for printing to standard output and standard error. It’s commonly used like this.

> console.log("Just a standard message going to standard out.");
Just a standard message going to standard out.
undefined
> console.error("Error: Something terrible has happened and this is going to stderr.");
Error: Something terrible has happened and this is going to stderr.
undefined
> console.info("This is also informative and is going to standard out.");
This is also informative and is going to standard out.
undefined

Enter fullscreen mode Exit fullscreen mode

Pretty nifty, huh? Alright! Let’s dive into the code. As per usual, here is a permalink to the version of the console module that I’ll be reading through in this post. I’ve done a few of these code reads at this point and I’ve learned a bit of something: it helps to read these Node modules starting from the bottom. Essentially, this gives me a chance to figure out what the API exposed by the module is and read through the relevant modules much more effectively. In the case of the console module, the exports look like this.

module.exports = new Console(process.stdout, process.stderr);
module.exports.Console = Console;

Enter fullscreen mode Exit fullscreen mode

The default export of the console module is an instance of the Console object, but the module also exports the definition for the object itself so that users can instantiate their own instances in their code. The Console object defines several functions on its prototype. I was already aware of some functions, like log and debug, but there were a few that were new to me, like time and count. The key supporting function behind the functions exposed by the API is the write function, which has the following function definition.

function write(ignoreErrors, stream, string, errorhandler, groupIndent) {

Enter fullscreen mode Exit fullscreen mode

I read through the body of the function and figured out what each of the parameters was responsible for.

  • ignoreErrors: This parameter is a Boolean that defines whether to ignore errors that occur when writing to an output stream. Note that by default, this is set to true.
  • stream: This parameter defines the stream object that the function should write information to.
  • string: This parameter defines the string that should be hidden.
  • errorhandler: A callback that is executed whenever an error is encountered when writing to the stream.
  • groupIndent: This parameter defines how many indents to put in each new line. For example, this is particularly useful when printing a stack trace since the trace is usually indented.

I found it particularly useful to establish what each of the parameters did because it made reading through the body of the function much easier. The first couple of lines check to see if the string needs to be indented after every newline and checks to see if there are any newlines in the string. If there is a groupIndent defined and newlines in the string, the newline is replaced with the appropriate indent.

if (groupIndent.length !== 0) {
  if (string.indexOf('\n') !== -1) {
    string = string.replace(/\n/g, `\n${groupIndent}`);
  }
  string = groupIndent + string;
}
string += '\n';

Enter fullscreen mode Exit fullscreen mode

The next portion of the code base handles the ignoreErrors parameter described above. If ignoreErrors is true, the string is directly sent to the stream without any error handling.

if (!ignoreErrors) return stream.write(string);

Enter fullscreen mode Exit fullscreen mode

On the other hand, if we do want to handle errors the function executes a try-catch clause.

try {
  // Add and later remove a noop error handler to catch synchronous errors.
  stream.once('error', noop);

  stream.write(string, errorhandler);
} catch (e) {
  // console is a debugging utility, so it swallowing errors is not desirable
  // even in edge cases such as low stack space.
  if (e.message === 'Maximum call stack size exceeded')
    throw e;
  // Sorry, there’s no proper way to pass along the error here.
} finally {
  stream.removeListener('error', noop);
}

Enter fullscreen mode Exit fullscreen mode

I found the stream.once('error', noop); statement rather interesting and decided to do some digging to figure out what it was all about. I eventually found this pull request and the corresponding issue. It appears that this statement is added to handle cases where standard out and standard error are unavailable. Instead of throwing an error, the function should fail silently. However, if an error occurs once the stream has been initiated for writing, the function should handle the errors using the errorhandler.

Most of the functions exposed by the console API utilize the write function. For example, the oft-used log function looks like this.

Console.prototype.log = function log(...args) {
  write(this._ignoreErrors,
        this._stdout,
        util.format.apply(null, args),
        this._stdoutErrorHandler,
        this[kGroupIndent]);
};

Enter fullscreen mode Exit fullscreen mode

And the warn function looks a little like this.

Console.prototype.warn = function warn(...args) {
  write(this._ignoreErrors,
        this._stderr,
        util.format.apply(null, args),
        this._stderrErrorHandler,
        this[kGroupIndent]);
};

Enter fullscreen mode Exit fullscreen mode

There were a few functions that were new to me in the console API. For example, the time and timeEnd functions are used to measure the elapsed time between two points in the code base. For example, we can test how much time elapses between the execution of two statements as follows.

> console.time("testing-time");
undefined
> for (var i = 0; i console.timeEnd("testing-time");
testing-time: 42570.609ms

Enter fullscreen mode Exit fullscreen mode

The time function adds a key-value pair to a _times property on the Console object which defines a relationship between the label and the current timestamp as retrieved by the process.hrtime function.

Console.prototype.time = function time(label = 'default') {
  // Coerces everything other than Symbol to a string
  label = `${label}`;
  this._times.set(label, process.hrtime());
};

Enter fullscreen mode Exit fullscreen mode

The timeEnd retrieves the start timestamp stored by the time function and calculates the amount of time that has elapsed since that time.

Console.prototype.timeEnd = function timeEnd(label = 'default') {
  // Coerces everything other than Symbol to a string
  label = `${label}`;
  const time = this._times.get(label);
  if (!time) {
    process.emitWarning(`No such label '${label}' for console.timeEnd()`);
    return;
  }
  const duration = process.hrtime(time);
  const ms = duration[0] * 1000 + duration[1] / 1e6;
  this.log('%s: %sms', label, ms.toFixed(3));
  this._times.delete(label);
};

Enter fullscreen mode Exit fullscreen mode

In combination, the time and timeEnd function serve as nice functions to benchmarking portions of a code base.

Another set of functions that caught my eye while reading through the code base were the count and countReset functions. These functions are used to maintain a count given a particular label.

> console.count("red-fish");
red-fish: 1
undefined
> console.count("blue-fish");
blue-fish: 1
undefined
> console.count("red-fish");
red-fish: 2
undefined
> console.count("blue-fish");
blue-fish: 2
undefined
> console.count("red-fish");
red-fish: 3
undefined

Enter fullscreen mode Exit fullscreen mode

The count function increments or resets a counter defined for a specific label that is stored in the kCounts property on the Console object.

Console.prototype.count = function count(label = 'default') {
  // Ensures that label is a string, and only things that can be
  // coerced to strings. e.g. Symbol is not allowed
  label = `${label}`;
  const counts = this[kCounts];
  let count = counts.get(label);
  if (count === undefined)
    count = 1;
  else
    count++;
  counts.set(label, count);
  this.log(`${label}: ${count}`);
};

Enter fullscreen mode Exit fullscreen mode

And the resetCount function resets the count for a particular label.

Console.prototype.countReset = function countReset(label = 'default') {
  const counts = this[kCounts];
  counts.delete(`${label}`);
};

Enter fullscreen mode Exit fullscreen mode

There is an interesting note written above the countReset function which states.

// Not yet defined by the https://console.spec.whatwg.org, but
// proposed to be added and currently implemented by Edge. Having
// the ability to reset counters is important to help prevent
// the counter from being a memory leak.

Enter fullscreen mode Exit fullscreen mode

As mentioned above, the specification for the console API doesn’t explicitly define a function to reset the counts for a label. I thought this was pretty interesting considering that the standard defines a specification for the timeEnd function associated with the time function. In any case, this standard is a living standard so there’s plenty of time for it to be added.

And that’s that! The Console object is less complicated than some other functions to analyze but I did discover some new uses while reading through the code.

If you have any questions or comments about the above, feel free to ask me a question or reach out to me on Twitter.

Top comments (0)