Loggable: A TypeScript Mixin for Generic Class Logging
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.
This article turned out to be the first of a three-part series! Here they are in sequence:
- Loggable: A TypeScript Mixin for Generic Class Logging (you are here)
- Mixin It Up: Picking the Right Problem to Solve
- Composition in Action: Finishing the Swing
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 likeconsole
orwinston
. -
An
options
object that let the developer disable specificlogger
endpoints (or enable all of them). TheLoggable
mixin injects alogger
property into the target class, and provides alog
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