Logger for Sinatra/Rack apps

2022-07-13

While developing an application on Sinatra, I ran into a problem: how to combine

to the single output (file) controlled by a built-in ruby class Logger.

There are no actual information on this topic on the net, so I had to collect it bit by bit. The completed solution is to write custom middleware:

class LoggerMiddleware
  FORMAT = %{"%s %s%s%s %s" %d}

  def initialize(app, app_logger, error_logger)
    @app, @app_logger, @error_logger = app, app_logger, error_logger
  end

  def call(env)
    env['rack.logger'] = @app_logger
    env['rack.errors'] = @error_logger

    status, headers, body = @app.call(env)
    body = Rack::BodyProxy.new(body) { log(env, status) }

    [status, headers, body]
  end

  private

  def log(env, status, _headers = nil)
    data = [
      env[Rack::REQUEST_METHOD],
      env[Rack::SCRIPT_NAME],
      env[Rack::PATH_INFO],
      env[Rack::QUERY_STRING].empty? ? '' : "?#{env[Rack::QUERY_STRING]}",
      env[Rack::SERVER_PROTOCOL],
      status.to_s[0..3]
    ]
    @app_logger.add(Logger::Severity::INFO, FORMAT % data)
  end
end

The sinatra application configuration block will look like:

configure do
  ::Logger.class_eval { alias :puts :error }
  $app_logger = ::Logger.new("log/production.log")
  $error_logger = ::Logger.new('log/error.log')

  set :logging, false

  use LoggerMiddleware, $app_logger, $error_logger
end

Alias puts-error added for compatibility with Rack errors processing.

If you need to manually create log events, use the previously declared variables:

get '/after_login' do
  $app_logger.info("Login from ip #{request.ip}")
end

And one last note: to rotate old log files I was going to use the parameter shift_age in Logger class builder. However, it is much easier to leave this task to the Linux logrotate system [1][2].

That's all, I hope you found this article useful.