# frozen_string_literal: true require 'logger' require 'time' require 'active_support/logger_silence' module Loggable module Logfmt # A structured logger that formats log messages in a key-value style using Logfmt formatting # This has been adapted from https://github.com/cyberdelia/logfmt-ruby/ class Logger < ::Logger def initialize(*args, **kwargs) super @formatter ||= KeyValueFormatter.new end # Include the LoggerSilence module to fix the incompatibility with ActiveSupport::LoggerSilence whe # Rails.application.config.assets.quiet option is set to true, but do not include the module for the # test environment as it causes issues with ActiveSupport::LoggerThreadSafeLevel::IsolatedExecutionState. def self.include_logger_silence include ActiveSupport::LoggerSilence end # Custom formatter class that renders logs as in a key-value style using Logfmt formatting class KeyValueFormatter < ::Logger::Formatter def call(severity, timestamp, progname, msg) %(time=#{datetime(timestamp)} severity=#{severity.ljust(5)}#{progname(progname)} #{message(msg)}\n) end private def datetime(time) time.utc.iso8601(3) end def message(msg) return unless msg if msg.respond_to?(:to_hash) pairs = msg.to_hash.map { |k, v| format_pair(k, v) } pairs.compact.join(' ') else format_pair('message', msg) end end def format_pair(key, value) return nil if value.nil? # Return a bare key when the value is a `TrueClass` return key if value == true "#{key}=#{format_value(value)}" end def progname(progname) return nil unless progname # Format this pair like any other to ensure quoting, escaping, etc…, # But we also need a leading space so we can interpolate the resulting # key/value pair into our log line. " #{format_pair(' progname', progname)}" end def format_value(value) if value.is_a?(Float) format('%.3f', value) elsif value.is_a?(Time) datetime(value) elsif value.respond_to?(:to_ary) format_value( "[#{Array(value).map { |v| format_value(v) }.join(', ')}]" ) else # Interpolating due to a weird/subtle behaviour possible in #to_s. # Namely, it's possible it doesn't actually return a String: # https://github.com/ruby/spec/blob/3affe1e54fcd11918a242ad5d4a7ba895ee30c4c/language/string_spec.rb#L130-L141 value = "#{value}" # rubocop:disable Style/RedundantInterpolation value = value.dump if value.match?(/[[:space:]]|[[:cntrl:]]/) # wrap in quotes and escape control characters value end end end end end end