loading...

Productive logging with Athena & Crylog

blacksmoke16 profile image Blacksmoke16 Updated on ・8 min read

This article was written for an older version of Athena and is no longer valid. Its content has been integrated into Creating a JSON API with Athena & Granite and Dependency Injection in Crystal.

Also, Crylog has been deprecated in favor of the standard libraries' Log module.

Logging

Logging is an important tool when creating any application. Logging adds information that could be useful for debugging issues, doing analytics, or just gathering user behavior data; all of which can be used to inform future decisions.

However not all logging is equal. Logging should be setup in such a way so that only useful information is logged, depending on the environment.
For example, a developer working on an application would love to see debug level information so that they have all of the possible information available in order to track down issues and find bottlenecks. The application running in the production environment should NOT see debug level logs so that the log files are not cluttered, which causes actual important issues to be lost in the shuffle.

Crylog

Crylog is a new flexible logging framework for Crystal based on Monolog. It allows applications to have multiple named loggers, each with various handlers, processors, formatters.

A handler would be something that does something with a logged message. For example logging to a file or STDOUT. But it could also be sending the message to Sentry, or a database etc.

A processor adds extra data to each message. A good use case of this would be adding user/customer ids to each logged message that would represent the user which caused that message to be logged.

Lastly, a formatter of course determines how a message gets serialized. Formatters allow different styles to be used depending on the handler, as HTML would be great for email handlers but not so much for log files.

When used together, Crylog allows for various loggers to be customized to handle the various aspects of an application. Such as only logging messages that are warnings or higher in the production environment, but all messages during development.

Athena

Athena is an annotation based web framework which uses Crylog as its logging solution. For this article I'm going to be demonstrating some of the benefits that Crylog enables when combined with some neat Athena features, such as Dependency Injection. This will be a continuation of the Blog Article I wrote a few months ago.

Dependency Injection (DI)

One of the newer features of Athena is a service container layer. This allows a project to share useful objects, aka services, throughout the project. These objects live in a special class called the Service Container (SC). Object instances can be retrieved from the container, or even injected directly into classes as a form of constructor DI. While this article is not focused on DI specifically, it will be using it. For more information related to it, see the Athena Docs.

Agenda

First, lets define some goals we wish to achieve via our logging.

  • Log some informational messages when articles are created/deleted, or a user logins, or a new user registers, along with some contextual information.
  • Both scenarios should be logged to a file, but the development environment should also log to STDOUT.

By default Athena will log all messages to STDOUT and a development.log file when in the development environment, while the production environment logs to a production.log file, but only for warnings and higher. Also since we want to add some custom logic into our loggers, we'll need to define our own configuration.

Security HTTP Handler

In the last tutorial, we created a HTTP::Handler class to handle authorization around the token used in each request. I do have plans for some security related features that I use in this article to be built into Athena itself, however for now it is up to the user to define.

However there are some problems with this approach. As I left off in the previous tutorial, how would we know what user is logged in within our controller actions? To solve this we can create a UserStorage class that will store the current user, so that other classes would have access to it via DI. Lets create a new directory under src called services that I'll put the UserStorage service. Next create a new file called user_storage.cr with the following code. Be sure to require it in your blog.cr file as well.

module Blog
  @[Athena::DI::Register]
  class UserStorage < Athena::DI::ClassService
    # Use a ! property since they'll always be a user defined in our use case.
    # It also defines a nilable getter method to make sure there is a user
    property! user : Blog::Models::User
  end
end

Notice how we are inheriting from Athena::DI::ClassService, this allows Athena to get a list of all services to register, while the @[Athena::DI::Register] allows some configuration on how the service is registered.

Now we can go back to our security handler, get the user storage service, and set the user.

module Blog
  class SecurityHandler
    include HTTP::Handler

    def call(ctx : HTTP::Server::Context) : Nil
      ...
      # Get the user storage service from the container
      user_storage = Athena::DI.get_container.get("user_storage").as(UserStorage)

      # Set the user in user storage
      user_storage.user = Blog::Models::User.find! body[0]["user_id"]

      # Call the next handler
      call_next ctx
    end
  end
end

Then we can go do a similar thing and update the references in the ArticleController.

module Blog::Controllers
  @[Athena::Routing::ControllerOptions(prefix: "article")]
  class ArticleController < Athena::Routing::Controller
    include Athena::DI::Injectable

    def initialize(@request_stack : Athena::Routing::RequestStack, @user_storage : UserStorage); end

    @[Athena::Routing::Post(path: "")]
    @[Athena::Routing::ParamConverter(param: "body", type: Blog::Models::Article, converter: Athena::Routing::Converters::RequestBody)]
    def new_article(body : Blog::Models::Article) : Blog::Models::Article
      # Sets the owner of the blog post as the current authed user
      body.user = @user_storage.user
      body.save
      body
    end

    @[Athena::Routing::Get(path: "")]
    def get_articles : Array(Blog::Models::Article)
      # We are also using the user in UserStorage as an additional conditional in our query when fetching articles
      # this allows us to only returns articles that belong to the current user.
      Blog::Models::Article.where(:deleted_at, :neq, nil).where(:user_id, :eq, @user_storage.user.id).select
    end

    @[Athena::Routing::Put(path: "")]
    @[Athena::Routing::ParamConverter(param: "body", type: Blog::Models::Article, converter: Athena::Routing::Converters::RequestBody)]
    def update_article(body : Blog::Models::Article) : Blog::Models::Article
      body.user = @user_storage.user
      body.save
      body
    end

    @[Athena::Routing::Get(path: "/:article_id")]
    @[Athena::Routing::ParamConverter(param: "article", pk_type: Int64, type: Blog::Models::Article, converter: Athena::Routing::Converters::Exists)]
    def get_article(article : Blog::Models::Article) : Blog::Models::Article
      article
    end

    @[Athena::Routing::Delete(path: "/:article_id")]
    @[Athena::Routing::ParamConverter(param: "article", pk_type: Int64, type: Blog::Models::Article, converter: Athena::Routing::Converters::Exists)]
    def delete_article(article : Blog::Models::Article) : Nil
      article.deleted_at = Time.utc
      article.save
      @request_stack.response.status = HTTP::Status::ACCEPTED
    end
  end
end

We are also able to inject the RequestStack in order to have access to the request/response.

NOTE: Services can only be auto injected, like the ArticleController if the class gets instantiated within the request/response cycle. Classes instantiated outside of it, like the SecurityHandler, do not have access to the same container and services must be fetched manually.

Now that we have our authentication problem resolved we can move onto adding some logging.

Adding Logs

We'll do the two public routes first. Simply go to your AuthController and add a Athena.logger.info "User logged in", Crylog::LogContext{"user_id" => user.id} before the {token: user.generate_jwt}. This would log

[2019-11-20T01:40:40Z] main.INFO: User logged in {"user_id":1}

our message with the id of the user that logged in. We can do a similar thing in our UserController. Add a Athena.logger.info "New user registered", Crylog::LogContext{"user_id" => body.id, "email" => body.email, "first_name" => body.first_name, "last_name" => body.last_name} after saving the user model. This would log that someone registered with some information about that user.

We could also do the same thing in our ArticleController, adding like Athena.logger.info "Article ##{body.id} was created", Crylog::LogContext{"user_id" => @user_storage.user.id}. However we could keep things more DRY by using a Crylog Processor since the ArticleController are authenticated endpoints, which would have a user in the UserStorage.

Let's create a new directory under src called logger that I'll put the processor. Next create a new file called user_processor.cr with the following code. Be sure to require it in your blog.cr file as well.

module Blog
  struct UserProcessor < Crylog::Processors::LogProcessor
    def call(message : Crylog::Message) : Nil
      user_storage = Athena::DI.get_container.get("user_storage").as(UserStorage)

      # Return early if a message was logged in a public endpoint there won't be a user in storage
      return unless user = user_storage.user?

      # Add the current user's id to all log messages
      message.extra["user_id"] = user.id
    end
  end
end

This would add the current user's id to every logged message as part of the message's extra property. It also handles the case where we were logging users logging in and registering, which would not have a user in storage. Another option would be to define a logger specifically for public stuff, but that is a bit overkill for now.

Next we need to tell Crylog to use our processor. We will also handle the 2nd bullet point in our agenda. Within blog.cr module add the following code:

  def Athena.configure_logger
    # Create the logs dir if it doesn't exist already.
    Dir.mkdir Athena.logs_dir unless Dir.exists? Athena.logs_dir
    Crylog.configure do |registry|
      registry.register "main" do |logger|
        handlers = [] of Crylog::Handlers::LogHandler

        if Athena.environment == "development"
          # Log to STDOUT and development log file if in develop env.
          handlers << Crylog::Handlers::IOHandler.new(STDOUT)
          handlers << Crylog::Handlers::IOHandler.new(File.open("#{Athena.logs_dir}/development.log", "a"))
        elsif Athena.environment == "production"
          # Log only to a file if in production env.
          handlers << Crylog::Handlers::IOHandler.new(File.open("#{Athena.logs_dir}/production.log", "a"))
        end

        # Tell crylog to use our processor on the main logger.
        logger.processors = [Blog::UserProcessor.new] of Crylog::Processors::LogProcessors

        logger.handlers = handlers
      end
    end
  end

This method tells Crylog how to configure the loggers to use. As you can see, we are logging to STDOUT and a development.log file when in the dev environment, but only a production file when in the prod environment. Notice we are opening the log file with the a option; this makes makes it so messages are appended to the log instead of completely overwriting it. We are also telling it to use our UserProcessor. For more information regarding the possible logging configurations, checkout the Crylog documentation.

After that, start the server and start doing things to see messages logged. Messages logged in the ArticleController will have the current user id included in the message; just like when a user logs in, but without having to explicitly specify it.

From here additional handlers/processors etc could be defined to also log these messages to Greylog, Sentry, or even send emails if they are of a high enough severity. Crylog allows for all sorts of flexibility when deciding on how best implement logging for your project.

Update November 19, 2019 for some minor polish & changes based on previous tutorial/new Athena version

Posted on by:

Discussion

pic
Editor guide