Livy is a flexible JavaScript logger heavily inspired by Monolog.
Livy aims to be the one logger used throughout your Node.js application. Therefore, it does not assume anything about your project and how it wants to do logging, but provides all the buildings blocks to quickly assemble a logger suited to your needs. It basically is a logging construction kit.
Want to see an example?
Impatient? Try Livy out on repl.it.
const { createLogger } = require('@livy/logger')
const { SlackWebhookHandler } = require('@livy/slack-webhook-handler')
const { RotatingFileHandler } = require('@livy/rotating-file-handler')
const logger = createLogger('my-app-logger', {
handlers: [
// Write daily rotating, automatically deleted log files
new RotatingFileHandler('/var/log/livy-%date%.log', {
level: 'info',
maxFiles: 30
}),
// Get instant notifications under critical conditions
new SlackWebhookHandler('https://some-slack-webhook.url', {
level: 'critical'
})
]
})
logger.debug("I'm going nowhere. :(")
logger.info("I'm going to the log file.")
logger.emergency("I'm going to the log file and to the slack channel!")
- πΎ Flexible: The basic logging infrastructure and the log-writing mechanisms are completely decoupled and extensible.
- π Universal: Livy was created for Node.js, but most components work in the browser just as well.
- β¨οΈ Great IDE support: Livy is written in TypeScript for great auto completion and easier bug spotting.
- βοΈ Stable: Livy has a comprehensive test suite.
Table of Contents
Install the logger factory from npm:
npm install @livy/logger
You start by installing the @livy/logger
package. This package only contains the overall structure of the logger but no concrete logging functionality β those are installed separately as components.
So now think about how your logging should go. Want to write errors to a log file? Install the @livy/file-handler
and set up your logger:
const { createLogger } = require('@livy/logger')
const { FileHandler } = require('@livy/file-handler')
const logger = createLogger('my-app-logger', {
handlers: [new FileHandler('error.log', { level: 'error' })]
})
That's it, you can start using the logger
instance:
logger.error('Something went wrong!', { location: 'main.js:50' })
And there you go!
Learn more:
- Logger: Learn more about how to configure the logger factory.
- Handlers: See what handlers are available besides writing to a file.
- Concepts: Learn about the core concepts of Livy.
Most of Livy's concepts (and, in fact, a lot of its source code as well) are borrowed from the Monolog library. There are some differences to make it fit nicely into the JavaScript world, but if you're coming from the PHP Monolog world, you'll probably be pretty familiar with how things work in Livy.
Most importantly, Livy adheres to the log levels defined in RFC 5424 and offers a logger interface compatible with PSR-3. This also means that you may use Livy to visualize PSR-3 compatible PHP logs in the browser. For this use case, take a look at the BrowserConsoleHandler and the DomHandler.
The log levels Livy uses are those defined in the syslog protocol, which are:
Level | Severity | Description | Examples |
---|---|---|---|
debug |
7 | Detailed debug information | |
info |
6 | Interesting events | User logs in, SQL logs |
notice |
5 | Normal but significant events | |
warning |
4 | Exceptional occurrences that are not errors | Use of deprecated APIs, poor use of an API, undesirable things that are not necessarily wrong |
error |
3 | Runtime errors that do not require immediate action but should typically be logged and monitored | |
critical |
2 | Critical conditions | Application component unavailable, unexpected exception |
alert |
1 | Action must be taken immediately | Entire website down, database unavailable, etc. This should trigger the SMS alerts and wake you up. |
emergency |
0 | System is unusable |
Handlers are the core unit of Livy. A handler is an entity which receives log records and, if it is responsible, dispenses them towards some target (for example a file). Without a handler, a logger just does nothing.
Each logger can have an arbitrary amount of handlers. They can be added through the handlers
set:
logger.handlers
.add(new FileHandler('livy.log'))
.add(new ConsoleHandler())
Note that handlers attached to a logger are used as a stack. That means that, when logging something, handlers are executed in reversed insertion order. This allows to add temporary handlers to a logger which can prevent further handlers from being executed.
It's the job of formatters to turn log records into strings of all sorts β plain text, HTML, CSV, JSON, you name it.
Formatters are used by most handlers and can be injected through their options:
const { JsonFormatter } = require('@livy/json-formatter')
// Make the file handler write newline-delimited JSON files
const fileHandler = new FileHandler('/var/log/livy.log', {
formatter: new JsonFormatter()
})
// You may also set the formatter afterwards:
fileHandler.formatter = new JsonFormatter()
All handlers that accept an optional formatter also have a default formatter which is available at handler.defaultFormatter
.
Processors take a log record and modify it. This usually means that they add some metadata to the record's extra
property.
A processor can be added to a logger directly (and is subsequently applied to log records before they reach any handler), but many handlers support handler-specific processors as well. In both cases, processors are accessed via the processors
set:
logger.processors.add(record => {
record.extra.easter_egg = 'π£'
return record
})
The log record is an internal concept that you'll mostly only need to understand if you intend to write your own handlers/formatters/loggers.
Explained by example: When having this logger...
const logger = require('@livy/logger').createLogger('my-app-logger')
...then calling a log method like this...
logger.debug('Hello World!', { from: 'Germany' })
...would internally create this object:
{
level: 'debug',
severity: 7,
message: 'Hello World!',
context: { from: 'Germany' },
extra: {}, // Any extra data, usually attached by processors
channel: 'my-app-logger'
datetime: {...} // A Luxon DateTime object of the current date and time
}
That object above is an example of a log record. Log records are passed around many places and if you're not writing your own components, you can just ignore their existence most of the time.
However, for some components it's useful knowing the concept of log records to understand how the component can be configured.
Take the very basic LineFormatter
: It allows you to specify an include
option which is nothing but an object telling the formatter which properties of the log record to include in the output:
const { createLogger } = require('@livy/logger')
const { ConsoleHandler } = require('@livy/console-handler')
const { LineFormatter } = require('@livy/line-formatter')
const logger = createLogger('my-app-logger', {
handlers: [
new ConsoleHandler({
formatter: new LineFormatter({
include: { datetime: false, context: false }
})
})
]
})
logger.debug('Hello World!')
// Prints: DEBUG Hello World!
Handlers may indicate that they completely handled the current log record with no need for further handlers in the logger to take action. They do so by returning Promise<true>
from the handle
method (or true
from handleSync
, respectively).
Because handlers usually don't interact with each other, bubbling prevention is rather exceptional. Most handlers will only do it if you explicitly instruct them to do so (by setting the bubble
option to true
) with some notable exceptions (e.g. the NullHandler
which always prevents bubbling after handling a log record).
Since handlers operate as a stack (as explained in the handlers section), the concept of bubbling allows for easy temporary overriding of a logger's behavior. You may, for example, make a logger
ignore all its configured handlers and only log to the terminal by doing this:
const { ConsoleHandler } = require('@livy/console-handler')
logger.handlers.add(new ConsoleHandler({ bubble: false }))
A logger is often run in many different (synchronous and asynchronous) contexts. Therefore, Livy allows handlers to implement both a synchronous and an asynchronous way to do their jobs and tries to invoke them appropriately. However, since Node.js is an inherently concurrent environment, there are cases where a synchronous way is simply not available (especially for anything network-related).
That's why by default, Livy runs in so-called "mixed mode". That is: handlers are instructed to do their work synchronously wherever possible, but asynchronous actions are allowed as well. However, this behavior comes with a grave tradeoff: Since the logger cannot wait for asynchronous handlers to finish their work, it has no insight into the bubbling behavior intended by asynchronous handlers or whether or not they even completed successfully.
Mixed mode therefore certainly is a suboptimal way to run Livy and you may want to use one of the more clear-cut alternatives: sync mode or async mode. Both modes do have the advantage that they properly invoke all their handlers in-order and handle their errors, but of course these modes have tradeoffs as well:
You can create a sync mode logger by setting the factory's mode
option to "sync"
:
const { createLogger } = require('@livy/logger')
const { SlackWebhookHandler } = require('@livy/slack-webhook-handler')
const logger = createLogger('my-app-logger', {
mode: 'sync'
})
// This will throw an error:
logger.handlers.add(new SlackWebhookHandler('https://some-slack-webhook.url'))
SlackWebhookHandler
is an exclusively asynchronous handler and can not be used in a synchronous environment. This is the tradeoff of a synchronous handler.
Therefore, sync mode is recommended if you have no need for exclusively asynchronous handlers.
You can create a fully async mode logger by setting the factory's mode
option to "async"
:
const { createLogger } = require('@livy/logger')
const logger = createLogger('my-app-logger', {
mode: 'async'
})
This allows any handler to be used with the logger. However, you now have to await
every logging action you perform:
// This is correct:
await logger.debug('foo')
await logger.info('bar')
// This is correct as well:
logger.debug('foo').then(() => logger.info('bar'))
// This is incorrect:
logger.debug('foo')
logger.info('bar')
// Oops! Now we have no guarantee that handlers of the "foo" log *actually* ran before the "bar" log.
This is the tradeoff of asynchronous handlers: You'll have to use highly contagious async
/await
constructs or any other way to handle the Promises returned by an asynchronous logger, which might be undesirable in your codebase.
To sum up: Use async mode if all places where the logger is used can afford to be asynchronous.
Many Livy components work in the browser. Some are even explicitely created for a browser environment.
However, please take notice that these components still use a Node.js-style module format which is not natively supported by browsers. You'll need a bundler like Parcel, Webpack, Rollup or Browserify to make them browser-ready.
Alternatively, you can use a CDN like Pika to try out Livy without using npm.
These are the components (handlers, formatters, processors) officially maintained by the Livy team. They are not "included" in Livy because each component we provide each resides in a separate package. This makes them a little more cumbersome to install, but it helps us properly decoupling our code and keeps your node_modules
folder a whole lot cleaner.
Our components library is by far not as comprehensive as that of Monolog. If you're missing any component, feel free to open an issue and explain your use case!
- ConsoleHandler: Writes log records to a terminal console.
- FileHandler: Writes log records to a file.
- RotatingFileHandler: Stores log records to files which are rotated by date/time or file size. Discards oldest files when a certain configured number of maximum files is exceeded.
- HttpHandler: Send log records to an HTTP(S) endpoint.
- SendmailHandler: Dispenses log records via email.
- SlackWebhookHandler: Sends log records to Slack through notifications.
- SocketIOHandler: Sends log records to a Socket.IO server.
- WebSocketHandler: Sends log records to a WebSocket. This allows for easy passing of log records from a browser to a backend.
- BrowserConsoleHandler: Writes log records to a browser console.
- DomHandler: Writes log records to the DOM.
- ArrayHandler: Pushes log records to an array.
- NoopHandler: Handles anything by doing nothing and does not prevent other handlers from being applied. This can be used for testing.
- NullHandler: Does nothing and prevents other handlers in the stack to be applied. This can be used to put on top of an existing handler stack to disable it temporarily.
Wrappers are a special kind of handler. They don't dispense log records themselves but they modify the behavior of the handler(s) they contain.
- FilterHandler: Restrict which log records are passed to the wrapped handler based on a test callback.
- GroupHandler: Distribute log records to multiple handlers.
- LevelFilterHandler: Restrict which log records are passed to the wrapped handler based on a minimum and maximum level.
- RestrainHandler: Buffers all records until an activation condition is met, in which case it flushes the buffer to its wrapped handler.
- AnsiLineFormatter: Formats a log record into a terminal-highlighted, one-line string.
- ConsoleFormatter: Formats a log record into a terminal-highlighted, human-readable multiline string.
- CsvFormatter: Formats a log record into a CSV line.
- HtmlOnelineFormatter: Formats a log record into a one-line HTML line.
- HtmlPrettyFormatter: Formats a log record into a comprehensive, human-readable HTML string.
- JsonFormatter: Encodes a log record as JSON.
- LineFormatter: Formats a log record into a one-line string.
- HostnameProcessor: Adds the running machine's hostname to a log record.
- MemoryUsageProcessor: Adds the current memory usage to a log record.
- ProcessIdProcessor: Adds the process ID to a log record.
- TagsProcessor: Adds a set of predefined tags to a log record.
- UidProcessor: Adds a unique-per-instance identifier to a log record.
When contributing code, please consider the following things:
- Make sure
yarn test
passes. - Add tests for new features or for bug fixes that have not previously been caught by tests.
- Use imperative mood for commit messages and function doc blocks.
- Add doc blocks to interfaces, classes, methods and public properties.
- You may consider omitting them for constructors or for interfaces whose purpose is very obvious (e.g. a
MyThingOptions
interface next to aMyThing
class). - Parameter descriptions of function doc blocks should be aligned:
/** * @param bar This description is indented very far * @param longParameter just to be aligned with this description. */ function foo(bar: number, longParameter: string) {}
- Return annotations are only needed where the function's description does not make it obvious what's returned.
- You may consider omitting them for constructors or for interfaces whose purpose is very obvious (e.g. a
- Find a good alternative for Luxon to support timezone-related record timestamps. Luxon is great, but it's pretty heavyweight for a core dependency just doing date handling in a library that is potentially run in the browser.
- Native Node.js ES module support is not ready yet. While there are compiled
.mjs
files, this will only work once all dependencies (and first and foremost Luxon as the only core dependency) support them. There's also some minor tweaking to do to get OS-specific EOL characters to function correctly which might require support for top-level await in Node.js.
- Monolog: They did all the hard conceptual work and battle testing, also a lot of source code and documentation is directly taken from there.
- Logo: The scroll icon is based on an illustration by Smashicons from www.flaticon.com (licensed under Flaticon Basic License).