@pabra/logger

A small and simple but extendable logger for typescript/javascript in browser and Node.js.

Usage no npm install needed!

<script type="module">
  import pabraLogger from 'https://cdn.skypack.dev/@pabra/logger';
</script>

README

@pabra/logger

npm version npm bundle size (scoped) Codecov unit-tests npm-publish

What

A JavaScript/TypeScript logger that implements Syslog severitiy levels.

goals are:

  • be lightweight/small
  • can be used in browser and node.js
  • have as few as possible dependencies (currently just 1)
  • (almost) ready to use if you just want to use console.log and do not want to log debug messages in production
  • easily extendable
  • functional code and immutable data

A Logger consists of 3 parts:

  • Filter (optional) - should a message be logged at all
  • Formatter - how to format log entries
  • Transporter - where to trasport log entries to

These are packed together into a Handler.

Install

npm install --save @pabra/logger
# or
yarn add @pabra/logger

Getting Started

Just Log

This works in both, browser and node.js environments.

// import
import getLogger from '@pabra/logger';

// init and use root logger
const rootLogger = getLogger('myProject');

rootLogger.info("I'm using a simple logger now!");

Results in the following console output:

2020-08-13T13:55:32.327Z [myProject] INFORMATIONAL - I'm using a simple logger now!

Logging Data

Pass any additional data after the log message.

rootLogger.warning(
  'something unexpected happened',
  { some: ['data', true] },
  '23',
  42,
);

Results in the following console output:

2020-09-06T07:29:05.356Z [myProject] WARNING - something unexpected happened { some: [ 'data', true ] } 23 42

Child Logger

Call getLogger on your rootLogger to get a child logger.

// import
import getLogger from '@pabra/logger';

// init root logger
const rootLogger = getLogger('myProject');

// init and use child logger in your modules/components/etc.
const moduleLogger = rootLogger.getLogger('myModule');
moduleLogger.info('Logging from within a module!');

Results in the following console output:

2020-09-06T07:39:08.677Z [myProject.myModule] INFORMATIONAL - Logging from within a module!

Selectively Logging for Dev / Prod

Set up a custom Handler to only show log messages starting at 'warning' level in production:

import getLogger, { handlers } from '@pabra/logger';

const logLevel = process.env.NODE_ENV === 'development' ? undefined : 'warning';
const logHandler = handlers.getConsoleRawDataHandler(logLevel);
const rootLogger = getLogger('myProject', logHandler);
// in some module
const moduleLogger = rootLogger.getLogger('myModule');

Then, any log messages that are lower than "warning" will be ignored.

rootLogger.info("I'm using a simple logger now!");
moduleLogger.notice("I'm using a simple module logger now!");
rootLogger.err('No such table in db.');
moduleLogger.warning('User entered invalid user name.');

Will only show messages eqal or higher than 'warning' level:

2020-09-06T07:53:40.896Z [myProject] ERROR - No such table in db.
2020-09-06T07:53:40.896Z [myProject.myModule] WARNING - User entered invalid user name.

You should take care that process.env.NODE_ENV is properly set. This might also differ if you use it in node.js or browser (there is no global process in the browser - webpack EnvironmentPlugin might help with that).

Usage

Logger

What is it

type Logger = {
  emerg: (message: string, ...data: any[]) => void;
  alert: (message: string, ...data: any[]) => void;
  crit: (message: string, ...data: any[]) => void;
  err: (message: string, ...data: any[]) => void;
  warning: (message: string, ...data: any[]) => void;
  notice: (message: string, ...data: any[]) => void;
  info: (message: string, ...data: any[]) => void;
  debug: (message: string, ...data: any[]) => void;
  getLogger: GetLogger;
  getHandlers: () => Handlers;
};

How to get it

import getLogger from '@pabra/logger';

// get a main/root logger
const mainLogger = getLogger(loggerName); // default handler will be used
// or
const mainLogger = getLogger(loggerName, handler);
// or
const mainLogger = getLogger(loggerName, handlers);

// get a child/module logger
const moduleLogger = mainLogger.getLogger(loggerName); // parent's handlers will be used
// or
const moduleLogger = mainLogger.getLogger(loggerName, handler);
// or
const moduleLogger = mainLogger.getLogger(loggerName, handlers);
object type required description
loggerName
string
yes name of your logger
handler
type Handler = {
    readonly filter?: Filter | undefined;
    readonly formatter: Formatter;
    readonly transporter: Transporter;
}
no a single Handler
handlers
Handler[]
no multiple Handlers

How to use it

moduleLogger.info(message, ...data);
object type required description
moduleLogger
type Logger = {
  emerg: (message: string, ...data: any[]) => void;
  alert: (message: string, ...data: any[]) => void;
  crit: (message: string, ...data: any[]) => void;
  err: (message: string, ...data: any[]) => void;
  warning: (message: string, ...data: any[]) => void;
  notice: (message: string, ...data: any[]) => void;
  info: (message: string, ...data: any[]) => void;
  debug: (message: string, ...data: any[]) => void;
  getLogger: GetLogger;
  getHandlers: () => Handlers;
};
the actual Logger Object
message
string
yes a message to log
data
any
no some kind of data to log

For each call of a log function the Logger will pass the message and data to each of it's Handlers.

Handler

What is it

type Handler = {
  readonly filter?: Filter;
  readonly formatter: Formatter;
  readonly transporter: Transporter;
};

A Handler keeps all 3 parts together that are needed to handle a log entry - hence the name. Whereas the filter is optional.

How to get it

import { handlers, Handler } from '@pabra/logger';

const myHandler: Handler = handlers.getConsoleTextHandler(logLevelName);
const myHandler: Handler = handlers.getConsoleRawDataHandler(logLevelName);
const myHandler: Handler = handlers.getConsoleJsonHandler(logLevelName);
object type required description
handlers
{
  getConsoleTextHandler,
  getConsoleRawDataHandler,
  getConsoleJsonHandler,
} as const;
an object of common handlers
logLevelName
type LogLevelName =
  | 'emerg'
  | 'alert'
  | 'crit'
  | 'err'
  | 'warning'
  | 'notice'
  | 'info'
  | 'debug';
no The name of the maximal log level to handle (low log levels are more urgent than higher ones). If none is passed (or undefined) that Hanlder won't filter - means everything get's logged.
getConsoleRawDataHandler
(
  level?: LogLevelName | undefined,
) => Handler
This is the default Handler if you don't pass one to getLogger. It mostly works like console.log. It doesn't has a Formatter and just passes the raw data to console.
getConsoleTextHandler
(
  level?: LogLevelName | undefined,
) => Handler
This Handler will be best for human readability.
getConsoleJsonHandler
(
  level?: LogLevelName | undefined,
) => Handler
This Handler will be best for machine readability as it will be one big strigified JSON line.

How to make it

import { Handler } from '@pabra/logger';

const myHandler: Handler = {
  filter: myFilter,
  formatter: myFormatter,
  transporter: myTransporter,
};

Filter

What is it

type Filter = (logger: InternalLogger, message: Message) => boolean;
type InternalLogger = {
  readonly name: string;
  readonly nameChain: string[];
  readonly handlers: Handler[];
};
interface Message {
  readonly raw: string;
  readonly data: any[];
  readonly level:
    | 'emerg'
    | 'alert'
    | 'crit'
    | 'err'
    | 'warning'
    | 'notice'
    | 'info'
    | 'debug';
}

The Filter function decides if a log entry should be handled at all. If it returns false the log entry handling immediately ends for this handler.

If there is no Filter provided in a Handler, every log entry gets handled. So no Filter behaves the same as a Filter that's always returning true.

How to get it

import { filters, Filter } from '@pabra/logger';

const myFilter: Filter = filters.getMaxLevelFilter(logLevelName);
object type required description
filters
{ getMaxLevelFilter } as const;
an object of common filters
logLevelName
type LogLevelName =
  | 'emerg'
  | 'alert'
  | 'crit'
  | 'err'
  | 'warning'
  | 'notice'
  | 'info'
  | 'debug';
yes The name of the maximal log level to handle (low log levels are more urgent than higher ones).
getMaxLevelFilter
(
  level: LogLevelName,
) => Filter
This Filter decides based on the severity of the log entry weather it should be logged/handled or not (low levels are more urgent - see Syslog severitiy levels).

How to make it

A Filter is a function that gets the InternalLogger object and the Message object passed as arguments and needs to return a boolean.

If you want to have a Handler that should only handle error log entries, your Filter could look like this:

import { Filter } from '@pabra/logger';

const myFilter: Filter = (_logger, message) => message.level === 'err';

// or if you only want to handle log entries from your "auth" module
const myFilter: Filter = (logger, _message) => logger.name === 'auth';

Formatter

What is it

type Formatter = (logger: InternalLogger, message: Message) => string;
type InternalLogger = {
  readonly name: string;
  readonly nameChain: string[];
  readonly handlers: Handler[];
};
interface Message {
  readonly raw: string;
  readonly data: any[];
  readonly level:
    | 'emerg'
    | 'alert'
    | 'crit'
    | 'err'
    | 'warning'
    | 'notice'
    | 'info'
    | 'debug';
}

The Formatter function produces the formatted message (string) that finally appears in your log file/console/etc. It might add a time stamp and than somehow join the severity level/name, logger name, raw log message and log data into one string.

How to get it

import { formatters, Formatter } from '@pabra/logger';

const myFormatter: Formatter = formatters.jsonFormatter;
const myFormatter: Formatter = formatters.textFormatter;
const myFormatter: Formatter = formatters.textWithoutDataFormatter;
const myFormatter: Formatter = formatters.getJsonLengthFormatter(maxLength);
const myFormatter: Formatter = formatters.getTextLengthFormatter(maxLength);
object type required description
formatters
{
  textWithoutDataFormatter,
  textFormatter,
  jsonFormatter,
  getTextLengthFormatter,
  getJsonLengthFormatter,
} as const;
an object of common formatters
maxLength
undefined | number
no The maximum length of your formatted log message. If undefined or omitted the default is 1024^2 (1 MiB). It is there to prevent you from potentially sending huge data objects over the wire.
Notice: if used with jsonFormatter the stringified data will end up truncated and not parseable anymore.
jsonFormatter
Formatter
Will return untruncated, stringified JSON like this:
{
  "name": "auth",
  "nameChain": ["main", "auth"],
  "time": "2020-08-16T08:23:43.395Z",
  "level": "debug",
  "levelValue": 7,
  "levelServerity": "Debug",
  "message": "failed to login",
  "data": [{ "user": "bob" }]
}

Can handle instances of Error as data.
textFormatter
Formatter
Will return untruncated, text like this: 2020-08-16T08:45:08.297Z [main.auth] DEBUG - failed to login {"user":"bob"}
Can handle instances of Error as data.
textWithoutDataFormatter
Formatter
This Formatter will just return the raw message without trying to serialize data. It's used for getConsoleRawDataHandler to be able to pass arbitrary objects like DOM Nodes or Events to the console which could not be serialized by JSON.stringify otherwise.
getJsonLengthFormatter
(
  maxLength?: number | undefined,
) => Formatter
Will return length limited jsonFormatter.
getTextLengthFormatter
(
  maxLength?: number | undefined,
) => Formatter
Will return length limited textFormatter.

How to make it

A Formatter is a function that gets the InternalLogger object and the Message object passed as arguments and needs to return a string.

A very simple Formatter (for the sake of simplicity ignores data) could look like this:

import { Formatter } from '@pabra/logger';

const myFormatter: Formatter = (logger, message) =>
  `${new Date().toISOString()} [${logger.name}] ${message.level}: ${
    message.raw
  }`;

Transporter

What is it

type Transporter = (logger: InternalLogger, message: MessageFormatted) => void;
type InternalLogger = {
  readonly name: string;
  readonly nameChain: string[];
  readonly handlers: Handler[];
};
interface MessageFormatted {
  readonly raw: string;
  readonly data: DataArgs;
  readonly level: LogLevelName;
  readonly formatted: string;
}

The Transporter "transports" the formatted message to its destination. That might be the console, a file, some http endpoint, etc.

How to get it

import { transporters, Transporter } from '@pabra/logger';

const myTransporter: Transporter = transporters.consoleTransporter;
const myTransporter: Transporter = transporters.consoleWithoutDataTransporter;
object type required description
transporters
{
  consoleTransporter,
  consoleWithoutDataTransporter,
} as const
an object of common transporters
consoleTransporter
Transporter
It passes the formated message and data to the console. It's used by the default Handler (getConsoleRawDataHandler - used if no Handler is passed to getLogger). It can be used if you want to pass arbitrary objects (like DOM Nodes, Events, etc.) to the console without having formatter dealt with them.
consoleWithoutDataTransporter
Transporter
It passes only the formatted message to the console. A Formatter should have taken care, that data became part of formatted message. It's used by getConsoleTextHandler and getConsoleJsonHandler.

How to make it

A Transporter is a function that gets the InternalLogger object and the MessageFormatted object passed as arguments and needs to return nothing (viod).

A very simple Tranporter to POST to your logging server might look like:

import { Transporter } from '@pabra/logger';

const myTransporter: Transporter = (_logger, message) =>
  void fetch('https://example.com', {
    method: 'POST',
    body: message.formatted,
  });