@simplyhexagonal/logger
v2.1.1
Published
Extensible debug logger with singleton capabilities, made to easily broadcast to multiple communication channels/transports
Downloads
1,969
Readme
Simply Hexagonal Logger
Extensible asynchronous debug logger with singleton capabilities, developed to easily broadcast to multiple communication channels/transports.
import Logger from '@simplyhexagonal/logger';
const logger = new Logger({});
logger.debug('Trying to teach', 2, 'tooters', {to: 'toot'});
Open source notice
This project is open to updates by its users, I ensure that PRs are relevant to the community. In other words, if you find a bug or want a new feature, please help us by becoming one of the contributors ✌️ ! See the contributing section
Like this module? ❤
Please consider:
- Buying me a coffee ☕
- Supporting Simply Hexagonal on Open Collective 🏆
- Starring this repo on Github 🌟
Features
- Define different communication channels per log level (i.e. send
debug
messages to console anderror
messages to Slack) - Define multiple communication channels per log level
- Specify channel names and then simply use the
.channel()
function to send messages to any specific channel - Extend your logging capabilities with officially supported transports for: Slack, Discord, Email, SMS, Socket
- Easily make your own transports by implementing and extending the base LoggerTransport class type (and submit them via GitHub issue for adoption as an officially supported transport!)
- Use the same logger instance throughout your app (singleton logger)
- Use multiple logger instances throughout your app with de-duplicated communication channels/transports (singleton transports)
- ANSI colors and easy to read formatting for CLI terminals
- CSS colors and easy to read formatting for browser dev consoles
Usage
Install:
npm install @simplyhexagonal/logger
yarn add @simplyhexagonal/logger
pnpm i @simplyhexagonal/logger
There are three basic configuration elements you should established based on your app's needs and the environment you will deploy to:
- log level
- communication channels
- error management strategy
Log level
There are 6 log levels:
- debug
- info
- warn
- error
- fatal
- all
And a bypass level that outputs the message with no date, level, nor colors:
- raw
In your code you log messages to specific log levels:
logger.debug('hello');
try {
throw new Error('you shall not pass');
} catch {
logger.error('time to turn around');
}
// 2021-10-02T23:47:27.187Z DEBUG 🐞️:
//
// hello
//
// 2021-10-02T23:47:27.191Z ERROR 🚨️:
//
// time to turn around
//
Each log level is given a number value:
{
debug: 0,
info: 10,
warn: 20,
error: 30,
fatal: 40,
all: 100,
raw: 110,
}
When Logger
is instantiated, it will only setup communication channels for the configured log
level and above (i.e. if you selected warn
then the only logLevel >= 20
would be initialized)
You can set the log level when instancing the logger:
import Logger, { LogLevels } from '@simplyhexagonal/logger';
new Logger({
logLevel: LogLevels.DEBUG,
//...
});
It is highly recommended to set the log level based on a condition that determines the environment your app is running on:
const logLevel = (process.env.NODE_ENV === 'production') ? LogLevels.ERROR : LogLevels.DEBUG;
new Logger({
logLevel,
//...
});
Setting the log level using environment variables is only recommended as a way to override the log level configured during instantiation:
# .env
LOG_LEVEL=debug
(i.e. this is useful if you deem it necessary to turn on debug logging in production environments)
Communication channels
Let's say that you have a Discord server with a channel you want to receive only debug messages from your app, and another channel dedicated to receiving only errors.
The debug channel has the webhook path: /D3BU9/W3BH00K
The error channel has the webhook path: /3RR0R/W3BH00K
Using @simplyhexagonal/logger
, you can add the official Discord transport as a dependency
and import it:
import DiscordTransport from '@simplyhexagonal/logger-transport-discord';
Then, you can configure the transports for each of Logger's log levels:
const optionsByLevel = {
debug: [
{
transport: LoggerTransportName.DISCORD,
options: {
// debug channel webhook url
destination: 'https://discord.com/api/webhooks/D3BU9/W3BH00K',
},
},
],
info: [],
warn: [],
error: [
{
transport: LoggerTransportName.DISCORD,
options: {
// error channel webhook url
destination: 'https://discord.com/api/webhooks/3RR0R/W3BH00K',
},
},
],
fatal: [],
all: [],
};
Then you would let Logger know which transport to use for LoggerTransportName.DISCORD
:
const transports = {
[LoggerTransportName.DISCORD]: DiscordTransport,
};
The final result would look something like this:
import {
LogLevels,
LoggerTransportName,
} from '@simplyhexagonal/logger';
import DiscordTransport from '@simplyhexagonal/logger-transport-discord';
const optionsByLevel = {
debug: [
{
transport: LoggerTransportName.DISCORD,
options: {
// debug channel webhook url
destination: 'https://discord.com/api/webhooks/D3BU9/W3BH00K',
},
},
],
info: [],
warn: [],
error: [
{
transport: LoggerTransportName.DISCORD,
options: {
// error channel webhook url
destination: 'https://discord.com/api/webhooks/3RR0R/W3BH00K',
},
},
],
fatal: [],
all: [],
raw: [],
};
const transports = {
[LoggerTransportName.DISCORD]: DiscordTransport,
};
const options = {
logLevel: LogLevels.DEBUG,
optionsByLevel,
transports,
};
const logger = new Logger(options);
Error management strategy
In the previous example there's always a possibility for the Discord webhook to return an error.
When this happens Logger will default to throwing an error which can be handled using .catch()
:
logger.debug('hello discord').catch((e) => {
logger.channel(LoggerTransportName.CONSOLE).error(e);
});
We understand that this adds unnecessary complexity, as such, you are encouraged to turn on error
catching when instantiating Logger
. When you do this, Logger will automagically catch transport
errors and log them to console (with error
log level):
const options = {
optionsByLevel: optionsWithBadTransport,
catchTransportErrors: true,
};
const logger = new Logger(options);
logger.debug('this will fail due to a bad transport');
// 2021-10-03T04:31:02.191Z ERROR 🚨️:
//
// {
// "transportResult": {
// "destination": "...",
// "channelName": "...",
// "error": {
// "name": "Error",
// "message": "LOGGER ERROR: ...",
// "stack": "Error: LOGGER ERROR: ...",
// // ...
// },
// },
// //...
// }
//
Furthermore, you could implement your own fallback transport:
import { LoggerTransport } from '@simplyhexagonal/logger/transports/base';
class MyTransport extends LoggerTransport {
constructor(options: LoggerTransportOptions['options']) {
const r = Math.random().toString(36).substring(7);
super({...options, r});
}
async error([timestamp, ...message]: unknown[]) {
console.log(timestamp, 'MY LOG:', ...message);
return {
destination: this.destination,
channelName: this.channelName,
};
}
}
const options = {
optionsByLevel: optionsWithBadTransport,
catchTransportErrors: true,
fallbackTransport: MyTransport,
};
const logger = new Logger(options);
logger.debug('this will fail due to a bad transport');
// 2021-10-03T04:31:02.201Z MY LOG: UndefinedTransportError: ...
And just as with LOG_LEVEL
, we have implemented an environment variable for overriding purposes:
# .env
LOGGER_CATCH_TRANSPORT_ERRORS=true
IMPORTANT NOTE: we recommend always setting catchTransportErrors
to true
in production!
More options
import {
LogLevels,
LoggerTransportName,
} from '@simplyhexagonal/logger';
import DiscordTransport from '@simplyhexagonal/logger-transport-discord';
const options = {
logLevel: LogLevels.DEBUG, // default
optionsByLevel: {
debug: [
// ***
// This console config is the default if a log level options array is left empty
// (like `info` in this example)
{
transport: LoggerTransportName.CONSOLE,
options: {
destination: LoggerTransportName.CONSOLE,
channelName: LoggerTransportName.CONSOLE,
},
},
// if you do this you would have only one instance of this transport since all
// transports are singleton (as in pre-filtered and de-duplicated)
// ***
{
transport: LoggerTransportName.DISCORD,
options: {
destination: 'https://discord.com/api/webhooks/D3BU9/W3BH00K',
channelName: 'discord-debug',
},
},
],
info: [], // in this case `loggger.info()` will default to logging to the console
warn: [],
error: [
{
transport: LoggerTransportName.DISCORD,
options: {
destination: 'https://discord.com/api/webhooks/3RR0R/W3BH00K',
},
},
],
fatal: [],
all: [],
raw: [],
},
transports: {
[`${LoggerTransportName.DISCORD}`]: DiscordTransport,
},
singleton: true, // default
catchTransportErrors: false, // default
fallbackTransport: MyTransport,
};
const logger = new Logger(options);
The all
and raw
log levels
An important thing to note is that transports defined for the all
and raw
log levels will
always be instantiated.
In the same way, calls to logger.all()
or logger.raw()
will always log.
For this reason we suggest only ever using logger.all()
when an app starts and when an app is
manually stopped, and only use logger.raw()
when outputting messages you absolutely need to
be unformatted.
Channels
It can be extremely useful to setup multiple channels for specific purposes on a log level:
const logger = new Logger({
logLevel: LogLevels.DEBUG,
optionsByLevel: {
warn: [],
info: [],
debug: [],
error: [
{
transport: LoggerTransportName.SLACK,
options: {
destination: 'https://hooks.slack.com/services/T123/B456/M0N90',
channelName: 'mongo',
},
},
{
transport: LoggerTransportName.SLACK,
options: {
destination: 'https://hooks.slack.com/services/T123/B456/M55QL',
channelName: 'mssql',
},
},
],
fatal: [],
all: [],
},
});
and then send messages to a specific channel depending on the event that's triggering the log:
const server = async () => {
await mongoose.connect('mongodb://mymongo.cluster:27017/myapp').catch((e) => {
logger.channel('mongo').error(e);
});
await sql.connect('Server=mymssql.cluster,1433;Database=myapp;').catch((e) => {
logger.channel('mssql').error(e);
});
}
server();
Transports
We have the following officially supported transports:
Time functions to measure performance
We have implemented functions similar to console.time()
and console.timeEnd()
to measure performance:
const logger = new Logger({});
logger.time('my-timed-operation');
// do things
const result: number = await logger.timeEnd('my-timed-operation');
// transport output:
//
// my-timed-operation: 123.456 ms
//
console.log(result);
// 123.456
Three important differences from the console functions to note:
logger.timeEnd
is async as it logs to the transports through the.raw()
methodlogger.timeEnd
will return a number representing the elapsed time in milliseconds, which is useful for being able to store the value for usage in measuring performance through code- we used
performance.timeOrigin + performance.now()
instead ofDate.now()
to get the current time in high resolution milliseconds, due to this fact the time measurements are more accurate than some internal time-based functions (e.g. from experience we've seen code likesetTimeout(() => {}, 100)
might be measured to take99.83 ms
which can break a test if you expect something likeawait logger.timeEnd('...') > 100
)
Contributing
Yes, thank you! This plugin is community-driven, most of its features are from different authors.
Please update the docs and tests and add your name to the package.json
file.
Contributors ✨
Thanks goes to these wonderful people (emoji key):
License
Copyright (c) 2021-Present Logger Contributors. Licensed under the Apache License 2.0.