Warning This project is still in the development stage, under 0.x.x version breaking changes can be introduced in any release, but I'll try to make them loud.
- Tiny and simple.
< 1kb!
without extra configuration - Fast enough. No extra overhead, no hidden magic
- Customizable. You can replace most of the parts with your own
- Isomorphic. Automatically works in Node.js and browsers
- Typed. Written in TypeScript, with full type support
- Well featured. Semantic levels, JSON logs, pretty output, error handling, and more
- 🆕 Built-in HTTP frameworks ⛓️
express
,koa
, Node corehttp
loggers are supported out of the box
const log = createLogger();
log.info('Hello, world!'); // [my-app] Hello, world!
log.info({ object: 'property' }, 'Template %s', 'string'); // Template string { object: 'property' }
log.debug('Some additional information...'); // nothing, because debug level is disabled
// Child logger will extend all unspecified settings from the parent
const childLog = log.child('example');
const needToGoDeeper = childLog.child('next one', {
level: 'debug' // override level
});
childLog.warn('Hello, world!'); // [example] Hello, world!
needToGoDeeper.debug('debug is enabled here'); // [example › next one] debug is enabled here
# yarn
yarn add @neodx/log
# npm
npm install @neodx/log
# pnpm
pnpm install @neodx/log
For basic usage, you can just create a logger and start logging:
import { createLogger } from '@neodx/log';
const log = createLogger();
log.info('Hello, world!'); // Hello, world!
By default, logger doesn't have a name, so you can't distinguish between different loggers. Let's add a name:
const log = createLogger({
name: 'my-app'
});
log.info('Hello, world!'); // [my-app] Hello, world!
We're supporting multiple log levels exposed as methods for semantic and output control.
You can use one of the built-in log levels: error
, warn
, info
, done
, success
, verbose
, debug
:
named.error('Something went wrong!'); // errors most important level
named.warn('Deprecated function used!'); // warnings
named.info('User logged in'); // information, most used level, neutral messages
named.done('Task completed'); // any success messages, by default less important than "info"
named.success('Session has been closed'); // alias to "done"
named.debug({ login: 'gigachad', password: '123' }, 'User logged in, session id: %s', 'xx-dj2jd'); // debug messages, the least important level, can contain sensitive information for debugging purposes
named.verbose('User opened the page %s', '/home'); // verbose messages, extended information, alias to "debug"
Sometimes you want to specify additional semantic levels, for example, trace
or fatal
, but you don't want to add a new real level to the logger, because it requires additional configuration and output control.
To solve this problem, you can define level aliases:
const log = createLogger({
level: 'info',
levels: {
fatal: 'error',
trace: 'debug'
}
});
log.fatal('Something went wrong!'); // [my-app] Something went wrong!
log.trace('User opened the page %s', '/home'); // [my-app] User opened the page /home
In this example, we defined two aliases: fatal
and trace
that will be mapped to error
and debug
levels respectively without any additional behavior.
Some targets are supporting additional configuration for aliases, for example, pretty
target tries to work with aliases as with real levels, so you can specify different settings for them.
const aliases = createLogger({
name: 'aliases',
level: 'trace',
levels: {
...DEFAULT_LOGGER_LEVELS,
fatal: 'error',
trace: 'debug'
},
target: pretty({
levelColors: {
...pretty.defaultColors,
fatal: 'red',
trace: 'magentaBright'
},
levelBadges: {
...pretty.defaultBadges,
fatal: '💀',
trace: '❯'
}
})
});
aliases.error('Message from error level');
aliases.fatal('fatal is alias for error');
aliases.warn('Attention!');
aliases.info('Some common information');
aliases.done('Success message');
aliases.debug('Additional details');
aliases.verbose('is alias for debug');
aliases.trace('is alias for debug, too!');
Every log method supports format template and optional error/metadata as the first argument. "Metadata" is an object with your data, which will be serialized to JSON and added to the log message.
You can use any combination of arguments:
- Template/raw -
log.info('Hello, %s!', 'world')
,log.info('Hello, world!')
- Metadata -
log.info({ my: 'field' })
- Error -
log.error(new Error('Something went wrong!'))
- Error with metadata
log.error({ err: new Error('Something went wrong!'), foo: 'bar' })
In other words:
- If the first argument is an object, it will be treated as metadata; other arguments are a format template and arguments for it
- If the first argument is an error, it will be treated as an error; other arguments are a format template and arguments for it
- Otherwise, the first argument is a format template and the other arguments are arguments for it
Let's see how it works:
const log = createLogger();
log.info('Hello, world!'); // "Hello, world!"
log.info({ name: 'world' }); // [ Metadata: { "name": "world" } ]
log.info('Hello, %s!', 'world'); // "Hello, world!"
log.info({ name: 'world' }, 'Hello, %s!', 'world'); // [ Metadata: { "name": "world" } ] Hello, world!
log.error(new Error('Something went wrong!')); // Error: Something went wrong!
log.error({ err: new Error('Something went wrong!'), foo: 'bar' }); // Error: Something went wrong! [ Metadata: { "foo": "bar" } ]
@neodx/log
provides a set of integrations for popular frameworks built on top of the core logger and Node.JS http
module.
Note: We have a plan to add more integrations in the future, but you can use the core logger with any framework.
Note: Under explanation, we will use
@neodx/log/express
, but the same approach can be used for any supported framework.
Note: Currently, we are not able to catch errors from single middleware, so you need to use
preserveErrorMiddleware
to catch errors from all middlewares.
import { createExpressLogger } from '@neodx/log/express';
import { createLogger } from '@neodx/log/node';
import express from 'express';
import createError from 'http-errors';
const app = express();
const expressLogger = createExpressLogger();
app.use(expressLogger);
app.get('/', (req, res) => {
res.send('respond with a resource');
});
app.get('/:id', (req, res) => {
req.log.info('Requested user ID %s', req.params.id);
res.status(200).json({ id: req.params.id });
});
// ... other routes
app.use((req, res, next) => {
next(createError(404));
});
app.use(expressLogger.preserveErrorMiddleware);
By default, createExpressLogger
and other framework integrations will create a new empty logger instance,
but you can pass your own instance:
import { createExpressLogger } from '@neodx/log/express';
import { createLogger } from '@neodx/log';
const logger = createLogger({
name: 'my-app',
level: 'debug'
});
app.use(createExpressLogger({ logger }));
For detailed information, check HttpLoggerParams API reference
// Other frameworks adapters will have same options
createExpressLogger({
// You can pass your own request ID extractor instead of our built-in generator
getRequestId: req => req.headers['x-request-id'],
// Control logging behavior
shouldLog: ({ req }) => !isHealthCheck(req), // enable/disable logging. By default, it will log every request
shouldLogError: true, // will log any tracked error
shouldLogRequest: true, // log information about the request
shouldLogResponse: true, // log successful response
// Extract information from request/response
getMeta: ({ req }) => ({ ip: req.ip }), // function to get metadata for every request
// function to get metadata for error
getErrorMeta: ({ req, res, error }) => ({
/* ... */
}),
// function to get metadata for request
getRequestMeta: ({ req, res }) => ({
/* ... */
}),
// function to get metadata for response
getResponseMeta: ({ req, res }) => ({
/* ... */
}),
// Customize log messages (we already format them for you in a well-readable way)
getErrorMessage: ({ req }) => `Failed ${req.method} ${req.url}`, // function to get log message for error
getRequestMessage: ({ req }) => `Request ${req.method} ${req.url}`, // function to get log message for request
getResponseMessage: ({ req }) => `Success ${req.method} ${req.url}` // function to get log message for response
});
In Koa, you don't need to pass anything except createKoaLogger
middleware:
app.use(createKoaLogger());
Detailed example
import { createKoaLogger } from '@neodx/log/koa';
import { createLogger } from '@neodx/log/node';
import createError from 'http-errors';
import Koa from 'koa';
const dev = process.env.NODE_ENV !== 'production';
const port = process.env.PORT || 3000;
const app = new Koa();
const logger = createLogger({
name: 'koa-app'
});
const koaLogger = createKoaLogger({
logger,
simple: dev,
shouldLogRequest: true
});
app.use(koaLogger);
app.use(async (ctx, next) => {
await next();
const status = ctx.status || 404;
if (status === 404) {
ctx.throw(createError(404));
}
});
app.get('/users', ctx => {
ctx.body = 'respond with a resource';
});
app.get('/users/:id', ctx => {
ctx.req.log.info('Requested user ID %s', ctx.params.id);
ctx.status = 200;
ctx.body = { id: ctx.params.id };
});
app.listen(port, () => {
logger.success(`Example app listening on port ${port}!`);
});
Node.JS http
module is the most basic way to create a server in Node.JS,
so we don't have any abstractions as we have for Express or Koa.
We need to create http logger handler and pass it to http.createServer
by ourselves:
const httpLogger = createHttpLogger();
const server = createServer((req, res) => {
httpLogger(req, res);
// ... your code
});
Detailed example
import { createHttpLogger } from '@neodx/log/http';
import { createLogger } from '@neodx/log/node';
import createError from 'http-errors';
import { createServer } from 'node:http';
const dev = process.env.NODE_ENV !== 'production';
const port = process.env.PORT || 3000;
const logger = createLogger({
name: 'node-http-app'
});
const httpLogger = createHttpLogger({
logger,
simple: dev,
shouldLogRequest: true
});
const server = createServer((req, res) => {
httpLogger(req, res);
if (req.url === '/users') {
res.setHeader('Content-Type', 'application/json');
res.writeHead(200);
res.end(
JSON.stringify([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
])
);
} else {
res.err = createError(404);
res.writeHead(404);
res.end('Unknown route');
}
});
server.listen(port, () => {
logger.success(`Example app listening on port ${port}!`);
});
Levels are a semantic method and mechanism for controlling output in different environments.
const log = createLogger({
/**
* @default "info"
*/
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug'
});
log.error('Always visible'); // "error" satisfies both "info" and "debug" restrictions
log.info('It is visible too'); // "info" satisfies both restrictions too
log.debug('But this is not'); // will not be visible in production, because "debug" doesn't satisfy "info" restriction
If you want to introduce your levels, you can do it with levels
option:
import { createLogger, LOGGER_SILENT_LEVEL } from '@neodx/log';
const log = createLogger({
level: 'success',
/**
* @default { error: 10, warn: 20, info: 30, verbose: 40, debug: 50 }
*/
levels: {
fatal: 10,
error: 20,
warn: 30,
info: 40,
success: 50,
debug: 60,
[LOGGER_SILENT_LEVEL]: Infinity // special level, which will disable all output
}
});
log.success('Done successfully!');
log.warn('Let me check it...');
log.fail('Oops, or not done');
For some additional information about levels, see createLogger API options.
Imagine that you have a server application, and you want to get the following behavior:
- In development, you want to see all logs in the console
- In production, you want to see only errors in the console and all non-debug logs in the file
- In tests, you want to see only errors in the console
To achieve this, you can use target
option and specify different targets based on the environment:
import { createLogger, pretty, json, file } from '@neodx/log/node';
const log = createLogger({
target: [
// Enabling pretty output for errors in test mode
{
level: 'error',
target: process.env.NODE_ENV === 'test' ? pretty() : []
},
// Enabling JSON stdout streaming for errors in production mode
{
level: 'error',
target: process.env.NODE_ENV === 'production' ? json() : []
},
// Enabling file streaming for "info", "warn" and "error" in production mode
{
level: 'info',
target: process.env.NODE_ENV === 'production' ? file('/dev/null') : []
},
// Enabling pretty output for "debug" in development mode
{
level: 'debug',
target: process.env.NODE_ENV === 'development' ? pretty() : []
}
]
});
It's an example for multiple targets configuration only, but you also can use a single target and multiple targets on same level, see createLogger API options for more information.
We're providing support for limited (see further) printf format for log messages. You can annotate string with special placeholders, which will be replaced with values from the argument list:
%s
- string%d
- number%i
- integer%f
- float%j
- JSON, under the hood we're resolving circular references (they will be replaced with"[Circular]"
)%o
,%O
- object, in our implementation, it's the same as%j
%%
- percent sign
To start using it, just add placeholders to your message:
log.info('Raw string'); // "Raw string", no placeholders, no arguments
log.info('Hello, %s!', 'world'); // "Hello, world!"
log.info('Hello, %s, you are %d years old!', 'John', 30); // "Hello, John, you are 30 years old!"
log.info('Hello, %s, you are %i years old! Your salary is %f', 'John', 30, '1000.5'); // "Hello, John, you are 30 years old! Your salary is 1000.5"
log.info('Object: %j', { foo: 'bar' }); // "Object: { "foo": "bar" }"
const object = { foo: 'bar' };
object.bar = object; // Circular reference
log.info('Circular object: %j', object); // "Circular object: { "foo": "bar", "bar": "[Circular]" }"
In specific cases, you can replace our printf
implementation with your own:
import { createLoggerFactory, DEFAULT_LOGGER_PARAMS } from '@neodx/log';
import { readArguments } from '@neodx/log/utils';
import { format } from 'node:util';
export const createLogger = createLoggerFactory({
formatMessage: (message: string, args: unknown[]) => format(message, ...args),
readArguments,
defaultParams: {
...DEFAULT_LOGGER_PARAMS
// Your default params
}
});
If you don't feel good with our built-in core parts, and you want to build your own logger, you can use createLoggerFactory
function:
import { createLoggerFactory } from '@neodx/log';
import { readArguments } from '@neodx/log/utils';
export const createLogger = createLoggerFactory({
defaultParams: {
...DEFAULT_LOGGER_PARAMS,
target: createConsoleTarget()
},
formatMessage: (message: string, args: unknown[]) => {
// Your implementation
},
readArguments // Not too much sense to override it, but you can do it
});
Create a new logger instance.
import { createLogger, LOGGER_SILENT_LEVEL } from '@neodx/log';
const log = createLogger({
/**
* Logger name
* @default No name
*/
name: 'my-logger',
/**
* Logger level. All messages with lower level will be ignored.
* @default info
*/
level: 'debug',
/**
* Object with all available levels and their values.
* @default { error: 10, warn: 20, info: 30, done: 40, debug: 50, success: 'done', verbose: 'debug', [LOGGER_SILENT_LEVEL]: Infinity }
*/
levels: {
error: 10, // Lowest value - highest priority, can be disabled only with { level: "silent" }
warn: 20,
info: 30,
debug: 40, // Highest value - lowest priority, logs will be emitted only with { level: "debug" },
verbose: 'debug', // You can use level aliases
[LOGGER_SILENT_LEVEL]: Infinity // Special level, which can be used to disable all logs. Ignore it if you don't need it.
},
/**
* Logger target(s). See details further.
* @type {LoggerHandler<Level> | Array<LoggerHandler<Level> | LoggerHandleConfig<Level> | Falsy>}
*/
target: createConsoleTarget(),
/**
* Base metadata, which will be added to every log message
* @default No metadata
*/
meta: {
foo: 'bar'
},
/**
* Logger chunks transformer(s). See details further.
* @type {LoggerTransformer<Level> | Array<LoggerTransformer<Level>>}
*/
transform: params => ({
...params,
meta: {
...params.meta,
bar: 'baz'
}
})
});
Alternatively, can be created with createLoggerFactory
.
Single target for all logs.
import { createLogger, pretty, json } from '@neodx/log/node';
const log = createLogger({
target: process.env.NODE_ENV === 'production' ? json() : pretty()
});
Log target can be described as a config object:
const log = createLogger({
target: {
/**
* The minimum level priority that this stream will receive.
* @example 'info' - will receive 'info', 'warn' and 'error' chunks
* @example 'warn' - will receive 'warn' and 'error' chunks
* @example 'error' - will receive only 'error' chunks
* @default no minimum level, will receive all chunks
*/
level: 'info', // Will receive only 'info', 'warn' and 'error' chunks
/**
* Your handler function(s) that will receive log chunks.
* @param chunk Log chunk
*/
target: chunk => console.log(chunk)
}
});
If you need to send different log chunks to different targets, you can specify an array of targets:
const log = createLogger({
target: [
{
level: 'info', // Will receive only 'info', 'warn' and 'error' chunks
target: [json(), chunk => writeLogToFile(chunk)]
},
{
level: 'error', // Will receive only 'error' chunks
target: chunk => sendLogToSentry(chunk)
},
// You can use simple conditional expressions to specify targets
process.env.NODE_ENV === 'production' && {
level: 'debug', // Will receive only 'debug' chunks
target: chunk => sendLogToSentry(chunk)
}
]
});
Single transformer for all logs.
const log = createLogger({
/**
* Transform log chunks before sending them to the target(s)
*/
transform: params => ({
...params,
meta: redactSensitiveData(params.meta)
})
});
Warning: This feature probably will be removed.
If you need to transform different log chunks in different ways, you can specify an array of transformers:
const log = createLogger({
/**
* Transform log chunks before sending them to the target(s)
*/
transform: [
params => ({
...params,
meta: redactSensitiveData(params.meta)
}),
params => ({
...params,
msg: replaceUnsecureLinks(params.msg)
})
]
});
Create new HTTP logger middleware for handling node:http
-based requests.
req
and res
are node:http
request and response objects or their extensions (e.g. express
request and response).
Extended signature:
declare function createHttpLogger<
// By default, `req` and `res` are `node:http` request and response objects
Req extends IncomingMessage = IncomingMessage,
Res extends OutgoingMessage = OutgoingMessage
>(params: HttpLoggerParams<Req, Res>): RequestHandler<Req, Res>;
type RequestHandler<Req extends IncomingMessage, Res extends OutgoingMessage> = (
req: Req,
res: Res,
next: (err?: any) => void
) => void;
interface HttpLoggerParams<
Req extends IncomingMessage = IncomingMessage,
Res extends OutgoingMessage = OutgoingMessage
> {
/**
* Custom logger instance.
* @default createLogger()
*/
logger?: Logger<HttpLogLevels>;
/**
* Custom colors instance
* @see `@neodx/colors`
*/
colors?: Colors;
/**
* If `true`, the logger will only log the pre-formatted message without any additional metadata.
* @default process.env.NODE_ENV === 'development'
*/
simple?: boolean;
/**
* Optional function to extract/create request ID.
* @default built-in simple safe number counter
*/
getRequestId?: (req: Req, res: Res) => string | number;
// ===
// Metadata and formatting
// ===
/**
* Extract shared metadata for every produced log
*/
getMeta?: (req: Req, res: Res) => Record<string, unknown>;
/**
* Extract metadata for request logs
*/
getRequestMeta?: (ctx: HttpResponseContext<Req, Res>) => Record<string, unknown>;
/**
* Custom incoming request message formatter
*/
getRequestMessage?: (ctx: HttpResponseContext<Req, Res>) => string;
/**
* Extract metadata for success response logs
*/
getResponseMeta?: (ctx: HttpResponseContext<Req, Res>) => Record<string, unknown>;
/**
* Custom success response message formatter
*/
getResponseMessage?: (ctx: HttpResponseContext<Req, Res>) => string;
/**
* Extract metadata for error response logs
*/
getErrorMeta?: (ctx: HttpResponseContext<Req, Res>) => Record<string, unknown>;
/**
* Custom error response message formatter
*/
getErrorMessage?: (ctx: HttpResponseContext<Req, Res>) => string;
// ===
// Control logging behavior
// ===
/**
* Whether to log anything at all.
* @default true
*/
shouldLog?: boolean | ((req: Req, res: Res) => boolean);
/**
* Prevents logging of errors.
* @default true
*/
shouldLogError?: boolean | ((ctx: HttpResponseContext<Req, Res>) => boolean);
/**
* Prevents built-in logging of requests.
* DISABLED BY DEFAULT, because it can be very verbose.
* @default false
*/
shouldLogRequest?: boolean | ((ctx: HttpResponseContext<Req, Res>) => boolean);
/**
* Prevents built-in logging of responses.
* @default true
*/
shouldLogResponse?: boolean | ((ctx: HttpResponseContext<Req, Res>) => boolean);
}
interface HttpResponseContext<
Req extends IncomingMessage = IncomingMessage,
Res extends OutgoingMessage = OutgoingMessage
> {
// request
req: Req;
// response
res: Res;
// error will be present if is an error response
error?: Error;
// reference to logger instance
logger: Logger<HttpLogLevels>; // 'debug' | 'error' | 'info' | 'done'
// reference to @neodx/colors instance
colors: Colors;
// measured response time, filled on response finish/error
responseTime: number;
}
Same signature as createHttpLogger
, but with express
-specific types and additional preserveErrorMiddleware
.
import type { Request, Response, ErrorRequestHandler } from 'express';
declare function createExpressLogger(
params: HttpLoggerParams<Request, Response>
): ExpressLoggerMiddleware;
interface ExpressLoggerMiddleware extends RequestHandler {
/**
* Middleware to preserve error status code and message
*/
preserveErrorMiddleware: ErrorRequestHandler;
}
Same signature as createHttpLogger
.
import type { Middleware } from 'koa';
declare function createKoaLogger(params: HttpLoggerParams): Middleware;
Customize logger behavior and create a new logger factory.
import { createLoggerFactory, DEFAULT_LOGGER_PARAMS } from '@neodx/log';
import { readArguments, printf, type LogArguments } from '@neodx/log/utils';
const createLogger = createLoggerFactory({
/**
* Default logger parameters
*/
defaultParams: {
...DEFAULT_LOGGER_PARAMS,
target: createConsoleTarget()
},
/**
* Message formatter
* @default Our own limited implementation of printf
*/
formatMessage: (message: string, args: unknown[]) => printf(message, args),
/**
* Arguments reader, used to extract metadata and error from the arguments list
* @param args - Logger method's arguments
* @returns A tuple with message fragments, metadata and optional error
*/
readArguments: (args: unknown[]): LogArguments => readArguments(args) // I don't know why you want to replace it, but you can
});
Logging is one of the key aspects of software development, and you've probably heard advice like "Just log everything." It's a solid recommendation, and chances are, you agree with it too. However, in web development, logging can sometimes become challenging to manage and maintain, leading to a frustrating development experience (DX).
Often, we find ourselves avoiding logs until it becomes inevitable, removing them, or wrapping them in numerous conditions. In today's development landscape, logs can be perceived as a hindrance to DX. Nevertheless, embracing comprehensive logging is essential for effective software development and is required for building stable products.
So, what's the problem? Why do we avoid logging?
During software development, developers frequently face the same issue: "How can I turn off, replace, or modify my logs?" The inability to easily control logging behavior often leads us to one of two choices—either drop the logs (we even have ESLint rules for this purpose) altogether or introduce an abstraction layer.
Dropping logs means avoiding any use of console.log
and similar APIs, simply because we cannot control them.
On the other hand, abstractions come with their own set of trade-offs and there is no widely-accepted, easy-to-use solution available.
In my opinion, - we just don't have a good enough abstraction layer for logging:
- Small size
- Isomorphic
- Configurable
- Multiple transports support
- Multiple log levels support
- Built-in pretty-printing, JSON logging
- etc.
Okay, maybe we have some good enough solutions, but they are not perfect:
- pino - really fast, but 3kb (browser) size, huge API, no built-in pretty-printing
- signale - Not maintained, only Node.JS, only pretty-printing, no JSON logging
- loglevel - Not maintained, just a console wrapper
- Other solutions are not worth mentioning, they are too far from the "just take it and use it" state
And after all, I decided to create my own solution.
This is a simple comparison of JSON logging output from different libraries.
This project got inspiration about API design and some features from the following projects: