Building a Logger Mixin in Sass

In this tutorial we’ll build a “logger” mixin, which outputs a flexible, informative log as CSS when Sass is compiled.

Logging is the process of recording application actions and state to a secondary interface. – Code Project

The Idea

The other day, Neat maintainer Reda Lemedan and I were talking all things Sass and all of the sudden I saw an interesting mixin of his:

@include -neat-warn("Something is wrong");

I asked him what this mixin does, and he told me it’s basically a wrapper for the @warn directive from Sass that checks whether or not the user is willing to print warnings from Neat in the console (based on a global variable).

So I thought to myself why stop there? and started playing with the idea that same night. My idea was to build a wrapper for both @warn and @error (from Sass 3.4) to help library and framework developers print different type of messages (info, debug, warn, error…) and keep track of all logs.

My current implementation provides:

  • 5 levels of logging (DEBUG, INFO, WARN, ERROR and FATAL);
  • a minimum level at which the logger starts printing;
  • a history of all logs, that can be printed as CSS;
  • a friendly API with easy-to-use functions;
  • a helper to learn more about different levels of logging.

How Does it Work?

It turned out to be fairly straightforward. We need a global variable holding the whole configuration, and a mixin serving as a wrapper for our console printing directives.

Because we want our global configuration to be customisable (to some extent), we wrap its declaration in a mixin. Not only is this more convenient, but it’s also nicer for the end user.

So we have a mixin, let’s call it logger that is only intented to be called once, creating a global map holding our configuration. Then we have our wrapper, log, that accepts a logging level (for instance WARN or ERROR) and the message to log as arguments. That’s pretty much it.

To make things more convenient to the developers, we will provide some shorthand functions to log different levels. For instance, instead of having to type:

@include log("ERROR", "There is not enough unicorn here.");

… we could have:

@include ERROR("There is not enough unicorn here.");

So you’ll end up with an API looking like this:

// Instantiate a logger
// that starts printing logs at `INFO` level.
// This means that `DEBUG` logs won't be shown.
@include logger("INFO");

// Log things.
@include ERROR("There is not enough unicorn here.");

We’ll also add some mixins to bring in some cool extra features:

  • one printing information about each logging level as a reminder;
  • one printing all logs that have been registered in the current compilation.

Building the API

Logger Constructor

Let’s start with the beginning, shall we? The logger constructor. This should accept a single parameter: the level at which the logger should start printing logs in the console.

This is quite a common pattern for logging systems. For instance:

  • If you only want to print errors (ERROR and FATAL), you’d write @include logger("ERROR").
  • If you want to print everything, you’d go with @include logger("ALL"), which is basically the same as the lowest logging level (DEBUG).
  • If you want to disable the logger altogether, you run @include logger("OFF").

Note: you can find more information about logging levels in this StackOverflow thread or in the Apache logs documentation.

@mixin logger($minimum-level) {
  // List of available levels
  $levels: "DEBUG", "INFO", "WARN", "ERROR", "FATAL";

  // Make sure the given string is uppercase
  $minimum-level: to-upper-case($minimum-level);

  // If level is `ALL`, go with lowest level of all
  @if $minimum-level == "ALL" {
    $minimum-level: nth($levels, 1);
  }

  // If level is invalid, arbitrary go with `INFO`
  @if not index($levels "OFF", $minimum-level) {
    $minimum-level: "INFO";
  }

  // Create global variable
  $logger-configuration: (
    // List of available levels
    "levels"  : $levels,

    // List of levels that are printed with `@error`
    "errors"  : "FATAL" "ERROR",

    // Minimum level (as an index of `$levels`) to print
    "min"     : index($levels, $minimum-level),

    // Whether or not the logger is enabled
    "enabled" : $minimum-level != "OFF",

    // A map to keep track of all logs
    "history" : (
      "DEBUG" : (),
      "INFO"  : (),
      "WARN"  : (),
      "ERROR" : (),
      "FATAL" : ()
    )
  ) !global;
}

The code above should be mostly self-explanatory, but I’ve added some comments to make everything clear. As you can see, this mixin doesn’t do much except create a global variable. Not that bad, is it?

Before going any further, let’s create a little helper function that makes it easy for us to get a value from this global map. Because you know, typing map-get($logger-configuration, ...) isn’t remotely fun. What about logger-conf(...) instead?

@function logger-conf($key) {
  @return map-get($logger-configuration, $key);
}

Log Wrapper

Okay, let’s move on to the actual log function which prints things in the console. Not only should it output the given messages in the user’s console, but it should also update the history in order to keep track of what’s being logged (which might or might not be useful).

That sounds difficult. Well worry not, it’s going to be as smooth as butter. We already know this mixin should accept only two parameters: the logging level, and the message.

@mixin log($level, $message) {
  // Make sure the level is uppercase
  $level: to-upper-case($level);

  // Unless it's disabled, proceed
  @if logger-conf("enabled") {
    // Get current level's index
    // For instance, `DEBUG` would be `1`
    $index-current-level: index(logger-conf("levels"), $level);

    // If `$level` is invalid, 
    // arbitrary falls back on `INFO`
    @if not $index-current-level { 
      $level: "INFO";
    }

    // Update logger history
    @include logger-update-history($level, $message);

    // Finally, print message in console 
    // if current level is greater than or equal to minimum level.
    @if $index-current-level >= logger-conf("min") {
      $print: '[' + $level + '] :: ' + $message;

      // Print it as `@error` if it's an error level
      @if index(logger-conf("errors"), $level) {
        @error $print;
      }

      // Else use `@warn`
      @else {
        @warn $print;
      }
    }
  }
}

Now, we need to deal with updating the history. This is actually a little tougher, but once you get used to map manipulation, it becomes clearer.

Before looking at the code, let me explain how is the history working. Basically, it’s a map where keys are the logging levels, and values are lists of logged messages. For instance, you could have something like:

$_: (
  "DEBUG": (),
  "INFO": (),
  "WARN": (
    "You should pay attention to this.",
    "This could be improved."
  ),
  "ERROR": (
    "Something's broken."
  ),
  "FATAL": ()
)

Okay. Let’s go.

@mixin logger-update-history($level, $message) {
  // Get history map from configuration
  $history: logger-conf("history");

  // Get history list for current level 
  $current-level-history: map-get($history, $level);

  // Append the fresh log to the list
  $current-level-history: append($current-level-history, $message);

  // Create a temporary variable containing the new history map
  $logger-history: map-merge($history, ($level: $current-level-history));

  // Update the history map from the configuration with our temporary variable
  $logger-configuration: map-merge($logger-configuration, ("history": $logger-history)) !global;
}

It involves quite a few unfriendly lines, but when you explain each line individually, it all makes sense eventually.

We’re done here, but we spoke about adding shorthand functions. Let’s do that now before we forget:

@mixin FATAL($message) { @include log("FATAL", $message); }
@mixin ERROR($message) { @include log("ERROR", $message); }
@mixin  WARN($message) { @include log("WARN",  $message); }
@mixin  INFO($message) { @include log("INFO",  $message); }
@mixin DEBUG($message) { @include log("DEBUG", $message); }

That’s it. One last thing we could do, but isn’t really mandatory, is testing whether logger has been included before trying to use the global map. Not only do we prevent stupid mistakes, but we could also make the logger instantiation optional by doing it on the fly.

@mixin log($level, $message) {
  // Test whether `logger-configuration` global variable exists.
  // If it doesn't, it means `logger` has not been included,
  // so we include it, arbitrary setting the min level to `INFO`.
  @if not global-variable-exists("logger-configuration") {
    @include logger("INFO");
  }

  @if logger-conf("enabled") {
    // ...
  }
}

Adding Extras

We’ll start with the first (and least useful) of both extra mixins, the helper. It’s really a gadget at this point since all it does is print a CSS rule with logging levels as selectors, and explanations as values.

This is intented to give some help to developers when they don’t really know which logging level they should use. It could have been written as a comment but I wanted to try this help printer thingie.

@mixin logger-help {
  // Open a new `logger-help` selector
  logger-help {
    OFF: "Disable the logger.";
    FATAL: "Severe errors that cause premature termination.";
    ERROR: "Other runtime errors or unexpected conditions.";
    WARN: "Use of deprecated APIs, poor use of API, 'almost' errors,"
    + "other runtime situations that are undesirable or unexpected, but not necessarily wrong.";
    INFO: "Interesting runtime events (startup/shutdown).";
    DEBUG: "Detailed information on the flow through the system.";
  }
}

You use it like this:

@include logger-help;

…and it compiles as:

logger-help {
  OFF: "Disable the logger.";
  FATAL: "Severe errors that cause premature termination.";
  ERROR: "Other runtime errors or unexpected conditions.";
  WARN: "Use of deprecated APIs, poor use of API, 'almost' errors,other runtime situations that are undesirable or unexpected, but not necessarily wrong.";
  INFO: "Interesting runtime events (startup/shutdown).";
  DEBUG: "Detailed information on the flow through the system.";
}

Nothing special. The other extra mixin is way more interesting. It uses the history to print all logs that have been registered during the compilation.

@mixin logger-print-logs {
  // Open a new `logger-logs` selector
  logger-logs {
    // Loop over the history
    @each $level, $logs in logger-conf("history") {
      // Check whether current logging level from loop
      // should be displayed or not based on the minimum level
      // and the length of its value (no log, no print).
      @if index(logger-conf("levels"), $level) >= logger-conf("min") and length($logs) > 0 {
        // Loop over the registered logs and print them.
        @each $log in $logs {
          #{$level}: $log;
        }
      }
    }
  }
}

Again, simple use:

@include logger-print-logs;

… which would output (based on our earlier example):

logger-logs {
  WARN: "You should pay attention to this.";
  WARN: "This could be improved.";
  ERROR: "Something's broken";
}

Example

// Instantiate a new logger with `INFO` as the minimum level for logging.
// If not included, it will automatically be done on first log.
@include logger("INFO");

// Logger help (optional, obviously)
@include logger-help;

// Log stuff
@include INFO("Hey, look at that.");
@include INFO("Bring in the unicorns!");
@include WARN("Dude, pay attention.");

// This one is not printed but still tracked in logs.
@include DEBUG("Debug and stuff."); 

// Output history (optional) especially useful for debugging
@include logger-print-logs;

Final Thoughts

As you can see, the code is quite light in the end, plus most of its bulk is comments. I think it provides a nice clean API helping keeping track of what’s being logged in any given project.

This is tool aimed at library and framework developers. If you happen to be one, please give this a try if you think it could be useful, and give me your feedback.

Feel free to grab the mixin from GitHub, or play with it directly on SassMeister.