# 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) file, line = extract_caller_info file = relative_to_rails_root(file) file_info = format_tag('file', file) line_info = format_tag('line', line) %(time=#{datetime(timestamp)} severity=#{severity.ljust(5)}#{file_info}#{line_info}#{progname(progname)} #{message(msg)}\n) # rubocop:disable Layout/LineLength 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 # Extracts caller information (file and line number) for the log message def extract_caller_info caller_locations.each do |loc| # Skip frames that are part of internal or irrelevant paths (gems, vendor, etc.) next if internal_frame?(loc.path) # Return the first relevant application-level frame return [loc.path, loc.lineno] end # Fallback if no relevant frame is found %w[unknown unknown] end # Determines if a given file path corresponds to an internal or excluded frame def internal_frame?(path) excluded_patterns = %w[ /gems/ /vendor/ loggable ].freeze excluded_patterns.any? { |pattern| path.include?(pattern) } end # Converts an absolute file path to a path relative to Rails.root # If the path is outside of Rails.root, it returns the original path def relative_to_rails_root(path) rails_root = defined?(Rails) ? Rails.root.to_s : '' path.start_with?(rails_root) ? path.sub(%r{^#{rails_root}/}, '') : path end # Formats a key-value tag for log output # If the value is 'unknown', it returns an empty string to omit the tag def format_tag(key, value) return '' if value == 'unknown' " #{key}=#{value}" end end end end end