#logging #log #structured #log-level #error-logging

astrolog

A logging system for Rust that aims to be easy and simple to use and flexible

1 unstable release

0.1.0 Mar 1, 2019

#752 in Debugging

LGPL-2.1-only

84KB
2K SLoC

Astrolog

A logging system for Rust that aims to be easy and simple to use and flexible.

The main purpose of Astrolog is to be used in applications, not in crates or libraries. It focuses on simplicity rather than maximum efficiency. Performance is not ignored, but ease of use has higher priority during development.

How to use

Astrolog can be used in two ways: the global logger can be called statically from anywhere in your application, while if you want to have one or more instance(s) with different configurations, you can instantiate them separately and pass the relevant one to your application's components.

The global logger

This method is particularly useful if you are building a small app and/or don't want to inject your logger to functions or objects that could use it. If you are used to the default rust's log functionality or to crates like slog that provide logging macros, this will look familiar, but with static calls instead of macros.

fn main() {
    astrolog::config(|logger| {
        logger
            .set_global("OS", env::consts::OS)
            .push_handler(ConsoleHandler::new().with_levels_range(Level::Info, Level::Emergency));
    });
  
    normal_function();
    function_with_error(42);
}

fn normal_function() {
    astrolog::debug("A debug message");
}

fn function_with_error(i: i32) {
    astrolog::with("i", i)
        .with("line", line!())
        .with("file", file!())
        .error("An error with some debug info");
}

The normal logger

This method is useful if you want different loggers for different parts of your application, or if you want to pass around the logger via dependency injection, a service locator or a DIC (or using singletons).

fn main() {
    let logger1 = Logger::new()
        .with_global("OS", env::consts::OS)
        .with_handler(ConsoleHandler::new().with_levels_range(Level::Trace, Level::Info));

    let logger2 = Logger::new()
        .with_global("OS", env::consts::OS)
        .with_handler(ConsoleHandler::new().with_levels_range(Level::Info, Level::Emergency));

    normal_function(&logger1);
    function_with_parameter(42, &logger2);

}

fn normal_function(logger: &Logger) {
    logger.debug("A debug message");
}

fn function_with_parameter(i: i32, logger: &Logger) {
    logger
        .with("i", i)
        .with("line", line!())
        .with("file", file!())
        .error("An error with some debug info");
}

Syntax and configuration

Astrolog works by letting the user build log entries, and passing them to handlers. Each logger can have one or multiple handlers, each individually configurable to only accept a certain set of log levels.

For example, you might want a ConsoleHandler (or TermHandler for colored output) to handle trace, debug and info levels, but at the same time you want to save messages of all levels to a file, and maybe send all messages of level warning and higher to an external logging aggregator.

Entries are built via an implicit builder pattern and are sent to the handlers when the appropriate level function is called.

Let's make it simpler with an example:

fn main() {
    logger.info("Some informative message")
}

This will build the entry and immediately send it to the handlers.

Using with will instead start building the entry:

fn main() {
    logger.with("some key", 42).info("Some informative message")
}

This will create an entry, store a key-value pair ("some key" and 42) in it and finally send it to the handlers.

Multiple calls to with (or with_multi or with_error) can be chained before calling the level method.

Available levels

Astrolog uses more levels than normal loggers. This is the complete list in order of severity:

  • Trace
  • Profile
  • Debug
  • Info
  • Notice
  • Warning
  • Error
  • Critical
  • Alert
  • Emergency

The functions on the Logger are named accordingly (.trace(), .profile(), ...). There is also a .log() function accepting the Level as first parameter, so that it can be decided programmatically at runtime.

Levels from debug to emergency mimic Unix/Linux's syslog levels.

Other logging systems tend to conflate into debug all the debugging, profiling and tracing informations, while Astrolog suggests using debug only for spurious "placeholder" messages.

profile is meant to log profiling information (execution times, number of calls to a function or loop iterations, etc.), while trace is meant for tracing the program execution (for example when debugging)

This allows to better filter debug info and send them to specific handlers. For example you mey want to send profile info to a Prometheus handler, debug, info and notice to STDOUT, trace to a file for easier analysis and everything warning and above to STDERR.

Examples

To run the examples, use:

cargo run --example simple
cargo run --example global
cargo run --example passing
cargo run --example errors
cargo run --example multithread

Each example shows different ways to use Astrolog.

simple shows how to create a dedicated logger and use it via method calls, with or without extra parameters.

global shows how to configure and use the global logger via static calls.

passing shows how you can create a dedicated logger and pass it around by reference or in a Rc.

errors show how to pass Rust errors to the logging functions and print a trace of the errors.

multithread shows how you can use Astrolog in a multithreaded application easily.

Handlers

The core Astrolog crate provides a few basic handlers, while others can be implemented in separate crates.

Console handler

The console handler simply prints all messages to the console, on a single line each, with a configurable format (see [Formatters] later on).

This handler can be configured to use either stdout or stderr to print the messages, but given the modularity of Astrolog, two ConsoleHandler can be registered for different log levels to go to different outputs.

This handler does not provide output coloring (see the astrolog-term crate for this).

Example:

fn main() {
    let logger = Logger::new()
        .with_handler(ConsoleHandler::new()
              .with_stdout()
              .with_levels_range(Level::Debug, Level::Notice)
        )
        .with_handler(ConsoleHandler::new()
              .with_stderr()
              .with_levels_range(Level::Warning, Level::Emergency)
        );
}

Vec handler

This handler simply collects all the messages in a Vec to allow to process them later. It is useful, for example, to debug a new formatter, or to batch messages.

Using it requires a bit more work during setup due to Rust's ownership rules:

fn main() {
    let handler = VecHandler::new();
    let logs_store = handler.get_store();
    
    let logger = Logger::new()
        .with_handler(handler);
        
    logger.info("A new message");
    
    let logs = logs_store.take();
    
    // logs is now a Vec<Record> over which you can iterate
}

Null handler

This handler is the /dev/null of Astrolog. It simply discards any message it receives.

It can be used to decide at runtime to not log anything, with a minimal processing cost, keeping the logger instance in place.

fn main() {
    let logger = Logger::new()
        .with_handler(
            NullHandler::new()
        );
}

Formatters

Formatters allow to format the log record in different ways, depending on the user's preferences or the handler's required format

For example, logging to a file could be best done with a LineFormatter, while printing an error on a web page would benefit from an HtmlFormatter, and sending it via a REST API could require a JsonFormatter.

Each record formatter can then use different "sub-formatters" for the level indicator, the date and the context (the values associated to a record).

LineFormatter

This is the simplest and probably most useful formatter provided in the base Astrolog crate. It simply returns the log record info in a single line, formatted by a template. The template supports placeholders to insert parts of the record or of the context in the output.

fn main() {
    let logger = Logger::new()
        .with_handler(ConsoleHandler::new()
              .with_formatter(LineFormatter::new()
                  .with_template("{{ [datetime]+ }}{{ level }}: {{ message }}{{ +context? }}")
              )
        );
}

This example shows the default configuration for the ConsoleHandler, so it's not needed, but it gives an idea of how to configure the formatter and its template.

The supported placeholders are:

  • datetime: inserts the record's date and time, accordingly to the configured datetime formatter (see below)
  • level: inserts the record's level, accordingly to the configured level formatter (see below)
  • message: inserts the logged message
  • context: inserts the context, accordingly to the configured context formatter (see below)

Any other placeholder is considered as the name of a variable in the context, and JSON pointers are supported to access embedded info.

All the placeholders, except for message (to avoid loops) can be used in the message itself to add context variables to the message. For example:

fn main() {
    logger
        .with("user", json!({"name": "Alex", "login": { "username": "dummy123" } }))
        .debug("Logged in as {{ /user/login/username }}");
}

HtmlFormatter

This formatter is very similar to the LineFormatter, with the added feature of escaping automatically the strings in the message and in the values so that &, < and > are correctly encoded to entities and show on the page.

JsonFormatter

This formatter transforms the record into a JSON object and returns it as a string.

By default the top-level field names are time, level, message and context but they can be customised via .set_field_names() or .with_field_names()

Log parts formatters

The parts of a log messages can be formatted in different ways. Sometimes the handler requires a specific format, for example for dates, while sometimes it's just a matter of preference.

Date formatting

formatter::date::Format is an enum supporting a number of predefined formats and the ability to use a custom format, based on [chrono]'s format.

Level formatting

formatter::level::Format is an enum supporting a combination of long and short (4-chars) representation of log levels, in lowercase, uppercase or titlecase.

Context formatting

This module provides, in the core crate, two ways to format the context associated with a log record: JSON and JSON5.

While JSON is useful (or even mandatory) when transmitting logs to other services or to applications, JSON5 has a better readability for humans, so it's especially useful for console, file and syslog logs (and it's the default).

Fields naming

A special case is formatter::fields::Names as it is used to define the names of the main fields in some formatters, like the JSON one, allowing to customise how the final representation appears.

Example

fn main() {
    let logger = Logger::new()
        .with_handler(ConsoleHandler::new()
            .with_formatter(LineFormatter::new()
                .with_date_format(DateFormat::Email)
                .with_level_format(LevelFormat::LowerShort)
            )
        );
}

License

As a clarification, when using Astrolog as a Rust crate, the crate is considered as a dynamic library and therefore the LGPL allows you to use it in closed source projects or projects with different open source licenses, while any modification to Astrolog itself must be released under the LGPL 2.1 license.

Dependencies

~6MB
~121K SLoC