5 minute read

Logging is an implementation decision that should be deferred as late as possible.

When you are writing a Typescript or Javascript class, the Loggable mixin permits the consumer of your class to:

  • Inject a preferred logging system into your class, so the logs generated by your class will be consistent with those generated by the rest of the application.

  • Decide at runtime what level of logging your class should produce.

Did the code at the links above look completely different from what you expected? Here’s why.

Why?

If you’ve been following along, you will recall that I’m engaged in a Typescript refactor and major upgrade of my entity-manager library.

This library performs a lot of console logging, most of which is intended to support the development process. If you’re an entity-manager user, you don’t want to see that stuff, as it injects a great deal of unnecessary noise into the logging signal of your own application…

…until you do!

On the one hand, when things in the application go wrong, that internal logging can provide really helpful clues about what’s going on. On the other hand, you really want to be able to turn it off again when you’re done!

Also: what logger are you using? Internally, entity-manager logs to console. But what if the external application logs to an S3 bucket via a Winston transport? If entity-manager’s internal logs don’t land alongside the rest of the application’s logs, they’re probably not worth the hassle.

As it stands, entity-manager solves the signal-to-noise problem in a spectacularly ugly fashion: during the build process, a Rollup plugin just plain strips out all the internal logging.

This entirely eliminates the option of turning the logging back on when needed, which isn’t good. Worse, it means that the production build is just that little bit more different from the source code I actually tested!

Either way, it’s a terrible solution.

A Loggable Base Class?

entity-manager is not the only library of mine with this logging problem; not even the only one within the entity-manager ecosystem. So as a first cut at the solution, I thought I’d create a Loggable base class that could be configured with an injected external logger like winston, and then inherit from it any class requiring logging services.

But as soon as I had that thought, I realized that my code often uses other somewhat generic features that could also benefit from inclusion in a base class! For example: entity-manager’s custom DynamoDB client has a number of methods that perform batched, throttled operations against the database, and the batching-and-throttling logic is easily encapsulated into a class method.

The problem is that some classes need logging, some need batching, and some need both. If I were writing C++, the answer would be to use multiple inheritance, but Typescript doesn’t support that!

A Loggable Mixin!

A mixin is a function that takes a class as an argument and returns another class. These are a very popular hack in the Typescript/Javascript world.

For example, the Loggable mixin takes the following arguments:

  • Some other class, or an empty class if you want to keep things simple.

  • A logger object like console or winston.

  • An options object that let the developer disable specific logger endpoints (or enable all of them). The Loggable mixin injects a logger property into the target class, and provides a log method that can be used to log messages at different levels.

The return value is the original class… only it’s tarted up with an internal logger and some controls. So if I derive my class from Loggable, then inside my class I can write this.logger.debug('debug message') and the message will be logged to console, winston, or to whatever logger object I injected when I created my class… or not at all if I’ve disabled debug messages!

And here’s the best part…

Say I my class also needs batching functionality and I’ve created a Batchable mixin. Then I can do this:

class MyClass extends Loggable(Batchable()) {
  // class definition
}

…and, like magic, MyClass will have both logging and batching functionality, even though Typescript only allows single inheritance!

Installation

npm i @karmaniverous/loggable

Default Use Case

By default, Loggable uses the console logger and disables debug logs.

import { Loggable } from '@karmaniverous/loggable';

// Defaults to console logger & disables debug logs.
class MyClass extends Loggable() {
  myMethod() {
    this.logger.debug('debug log');
    this.logger.info('info log');
  }
}

const myInstance = new MyClass();

// By default, disables debug logs.
myInstance.myMethod();
// info log

// Change disabled logger endpoints on the fly.
myInstance.loggableOptions.disabled = ['info'];
myInstance.myMethod();
// debug log

// Use the instance logger directly.
myInstance.logger.debug('debug log');
myInstance.logger.info('info log');
// debug log

// Set `enableAll` to `true` to ignore disabled endpoints.
myInstance.loggableOptions.enableAll = true;
myInstance.myMethod();
// debug log
// info log

Custom Base Class, Logger & Options

You can inject a custom logger into a custom base class with custom options.

import { Loggable } from '@karmaniverous/loggable';
import winston from 'winston';

class MyBaseClass {
  protected repeat(message: string, times: number) {
    return Array.from({ length: times })
      .map(() => message)
      .join(' ');
  }
}

// Custom base class, logger & options.
class MyClass extends Loggable(MyBaseClass, winston, {
  disabled: ['debug', 'info'],
}) {
  myMethod() {
    this.logger.debug(this.repeat('debug log', 2));
    this.logger.info(this.repeat('info log', 2));
    this.logger.error(this.repeat('error log', 2));
  }
}

const myInstance = new MyClass();

myInstance.myMethod();
// error log error log <-- winston logger!

Generic Logger & Options

You can create a generic class that also defers the choice of logger and options!

import { Loggable } from '@karmaniverous/loggable';
import winston from 'winston';

// This function returns an anonymous class that extends Loggable.
function MyGenericClass<Logger = Console>(
  logger: Logger = console as Logger,
  options?: LoggableOptions // Use Loggable's default options if none provided.
) {
  // The anonymous class extends Loggable with the provided logger & options.
  return class extends Loggable(undefined, logger, options) {
    myMethod() {
      this.logger.debug('debug log');
      this.logger.info('info log');
      this.logger.error('error log');
    }
  };
}

// Generate a winston-logged version of my class...
const MyWinstonLoggedClass = MyGenericClass(winston, {
  disabled: ['debug', 'info'],
});

// ...and instantiate it.
const myInstance = new MyWinstonLoggedClass();

myInstance.myMethod();
// error log <-- winston logger!

One potential gotcha here: this.logger will support whatever methods the provided logger has.

If you provide a logger that doesn’t have a debug method, for example, you will get a runtime error when you try to call this.logger.debug. So be sure to stick to commonly supported logger method names like debug, info, and error!

Leave a comment