DEV Community

Tony Wallace for RedBit Development

Posted on • Originally published at redbitdev.com

Conditional Logging to the JavaScript Console

The JavaScript console is a very useful debugging tool, but it can also introduce a very simple security risk into your application: console method calls that aren't removed before code is deployed can leak sensitive information to the browser. That's why many developers use a linter to warn about console method calls and other issues. (On RedBit's web team, we use husky to run the linter on a pre-commit hook so that console method calls are never committed by accident. We've found that many small bugs can be avoided by making linting mandatory before committing code.)

The problem is that there are times when you want to leave console method calls in place. You can use eslint-disable comments to bypass the linter, but you still run the risk of leaking sensitive information if you forget to clean up before you deploy. You could also wrap your console method calls in a condition that checks a debug or environment flag and only executes the calls in development, but that's a bit awkward. It's also easy to forget so your users' security will depend on your memory. That certainly isn't a good strategy given that we all make mistakes from time to time.

A better solution would be to integrate the debug or environment condition into the console itself. We can do this by replacing the browser's window.console object with a modified version of the same. Example 1 shows the function that does this.

Example 1
// console.js

export const createDebugConsole = (consoleObj, options = {}) => {
  const nextConsoleObj = { ...consoleObj };
  const { enabled = true } = options;

  for (let key in nextConsoleObj) {
    /* eslint-disable no-prototype-builtins */
    if (
      nextConsoleObj.hasOwnProperty(key) &&
      typeof nextConsoleObj[key] === 'function'
    ) {
      const func = nextConsoleObj[key];
      nextConsoleObj[key] = function () {
        if (enabled) {
          func.apply(nextConsoleObj, arguments);
        }
      };
    }
    /* eslint-enable no-prototype-builtins */
  }

  return nextConsoleObj;
};
Enter fullscreen mode Exit fullscreen mode

Notice that the createDebugConsole accepts the console object as an argument, rather than assuming it to be in scope. The function is designed this way for two reasons. First, it eliminates a dependency on the global scope which makes the function easier to unit test. Second, it makes it possible to use the function in non-browser environments that use a browser-like console object that may not be attached to the window global.

Let's examine this function line by line:

  1. Accept the console object and a hash of options as arguments.
  2. Make a shallow copy of the console object as nextConsoleObj, to avoid modifying the original.
  3. Destructure the enabled boolean from the options hash. The default value is true, which means that the new console will allow logging unless you pass { enabled: false } in the options hash.
  4. Enumerate the properties of the copied console object. For each key that is owned by the object (i.e. not inherited) and whose value is a function, take a reference to the value (func) and then replace it with a new function. If the enabled option is true, the new function calls func.apply with the new console object and the new function's arguments. (Note that we're using the traditional function declaration here, rather than an ES6 arrow function, to ensure that we have access to the arguments array.) If the enabled option is false, the new function does nothing. Any additional properties of the console object that are not functions will be left unchanged.
  5. Return the new console object.

(Depending on your linter rules, you may or may not need the /* eslint-disable no-prototype-builtins */ and /* eslint-enable no-prototype-builtins */ comments.)

Integration

Example 2 shows how to use the function to replace the browser's default console with our new debug console. In this case, we're using process.env.NODE_ENV to enable logging in non-production builds. (This assumes that your build process replaces process.env.NODE_ENV with the value of the NODE_ENV environment variable.) You could also set the enabled option based on a value from a dotenv file or any other source of configuration.

Example 2
// index.js

import { createDebugConsole } from './console';

window.console = createDebugConsole(window.console, {
  enabled: process.env.NODE_ENV !== 'production'
});

console.log("This won't log in production!");
Enter fullscreen mode Exit fullscreen mode

Call createDebugConsole once in your application's main index.js file or wherever you do other setup work. You can now use the global console object normally. If the condition that sets the enabled value is true, the console methods will behave normally. It it's false the methods will return without doing anything. You can now be more confident that your production applications won't leak data to the console.

You could also assign the return value of createDebugConsole to a new constant in case you need to leave the window.console global untouched, or if you just don't want to use the global console, as shown in Example 3:

Example 3
import { createDebugConsole } from './console';

const myConsole = createDebugConsole(window.console, {
  enabled: process.env.NODE_ENV !== 'production'
});

myConsole.log("This won't log in production!");
Enter fullscreen mode Exit fullscreen mode

The approach you take should depend on your application and personal preferences. I prefer to use the window.console global for two reasons:

  1. It allows me to use the console without having to import anything
  2. The linter will still complain if I leave console method calls in my code without explicitly allowing them with eslint-disable no-console comments. This helps reduce unnecessary logging.

Testing

The console created by createDebugConsole will not be active in unit tests, so you won't be able to use it to prevent logging in your test environment. If you use jest you can silence console methods with the jest CLI.

Here is a short jest suite that you can use to test createDebugConsole:

import { createDebugConsole } from './console';

const getMockConsole = () => ({
  assert: jest.fn(),
  clear: jest.fn(),
  count: jest.fn(),
  countReset: jest.fn(),
  debug: jest.fn(),
  dir: jest.fn(),
  dirxml: jest.fn(),
  error: jest.fn(),
  group: jest.fn(),
  groupCollapsed: jest.fn(),
  groupEnd: jest.fn(),
  info: jest.fn(),
  log: jest.fn(),
  profile: jest.fn(),
  profileEnd: jest.fn(),
  table: jest.fn(),
  time: jest.fn(),
  timeEnd: jest.fn(),
  timeLog: jest.fn(),
  timeStamp: jest.fn(),
  trace: jest.fn(),
  warn: jest.fn(),
});

test('createDebugConsole returns a copy of the input console', () => {
  const consoleObj = getMockConsole();
  const nextConsoleObj = createDebugConsole(consoleObj);

  expect(consoleObj === nextConsoleObj).toBe(false);

  for (let key in consoleObj) {
    // eslint-disable-next-line no-prototype-builtins
    if (consoleObj.hasOwnProperty(key)) {
      expect(consoleObj[key] === nextConsoleObj[key]).toBe(false);
    }
  }
});

test('When a function is called on the debug console returned by createDebugConsole, the same function is called on the original console with the same arguments if the debug console is enabled', () => {
  const consoleObj = getMockConsole();
  const nextConsoleObj = createDebugConsole(consoleObj, { enabled: true });
  const args = ['test', 'test2', 'test3'];

  for (let key in nextConsoleObj) {
    // eslint-disable-next-line no-prototype-builtins
    if (nextConsoleObj.hasOwnProperty(key)) {
      nextConsoleObj[key](...args);
      expect(consoleObj[key]).toHaveBeenCalledTimes(1);
      expect(consoleObj[key]).toHaveBeenCalledWith(...args);
    }
  }
});

test('When a function is called on the debug console returned by createDebugConsole, the same function is not called on the original console if the debug console is disabled', () => {
  const consoleObj = getMockConsole();
  const nextConsoleObj = createDebugConsole(consoleObj, { enabled: false });
  const args = ['test', 'test2', 'test3'];

  for (let key in nextConsoleObj) {
    // eslint-disable-next-line no-prototype-builtins
    if (nextConsoleObj.hasOwnProperty(key)) {
      nextConsoleObj[key](...args);
      expect(consoleObj[key]).not.toHaveBeenCalled();
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Top comments (0)