Conditional Logging to the JavaScript Console
Tags: webdev, javascript
Published: 2022-11-14 14:00:00

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](https://eslint.org/) to warn about `console` method calls and other issues. (On RedBit's web team, we use [husky](https://typicode.github.io/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;
	};

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](https://github.com/motdotla/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!");

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!");

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](https://jestjs.io/) you can [silence `console` methods with the jest CLI.](https://jestjs.io/docs/cli#--silent)

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();
	    }
	  }
	});