# frozen_string_literal: true require_relative "logrb/version" require "hexdump" require "json" # Logrb provides a facility for working with logs in text and json formats. # All instances share a single mutex to ensure logging consistency. # The following attributes are available: # # fields - A hash containing metadata to be included in logs emitted by this # instance. # level - The level filter for the instance. Valid values are :error, :fatal, # :info, :warn, and :debug # format - The format to output logs. Supports :text and :json. # # Each instance exposes the following methods, which accepts an arbitrary # number of key-value pairs to be included in the logged message: # # #error(msg, error=nil, **fields): Outputs an error entry. When `error` is # present, attempts to obtain backtrace information and also includes it # to the emitted entry. # # #fatal(msg, **fields): Outputs a fatal entry. Calling fatal causes the # current process to exit with a status 1. # # #warn(msg, **fields): Outputs a warning entry. # #info(msg, **fields): Outputs a informational entry. # #debug(msg, **fields): Outputs a debug entry. # #dump(msg, data=nil): Outputs a given String or Array of bytes using the # same format as `hexdump -C`. class Logrb attr_accessor :fields, :level, :format COLORS = { error: 31, fatal: 31, unknown: 0, info: 36, warn: 33, debug: 30, reset: 0, dump: 37 }.freeze BACKGROUNDS = { debug: 107 }.freeze LEVELS = { error: 4, fatal: 4, unknown: 4, warn: 3, info: 2, debug: 1, reset: 1 }.freeze # Internal: A mutex instance used for synchronizing the usage of the output # IO. def self.mutex @mutex ||= Mutex.new end # Initializes a new Logger instance that outputs logs to a provided output. # # output - an IO-like object that implements a #write method. # format - Optional. Indicates the format used to output log entries. # Supports :text (default) and :json. # level - Level to filter this logger instance # fields - Fields to include in emitted entries def initialize(output, format: :text, level: :debug, **fields) @output = output @format = format @fields = fields @level = level end # Returns a new logger instance using the same output of its parent's, with # an optional set of fields to be merged against the parent's fields. # # fields - A Hash containing metadata to be included in all output entries # emitted from the returned instance. def with_fields(**fields) inst = Logrb.new(@output, format: @format, level: @level) inst.fields = @fields.merge(fields) inst end LEVELS.except(:error).each_key do |name| define_method(name) do |msg, **fields| return if LEVELS[@level] > LEVELS[name] wrap(name, msg, nil, fields) nil end end # Public: Emits an error to the log output. When error is provided, this # method attempts to gather a stacktrace to include in the emitted entry. def error(msg, error = nil, **fields) return if LEVELS[@level] > LEVELS[:error] wrap(:error, msg, error, fields) nil end # Public: Dumps a given String or Array in the same format as `hexdump -C`. def dump(log, data = nil, **fields) return if LEVELS[@level] > LEVELS[:debug] if data.nil? data = log log = nil end data = data.pack("C*") if data.is_a? Array dump = [] padding = @format == :json ? "" : " " Hexdump.dump(data, output: dump) dump.map! { |line| "#{padding}#{line.chomp}" } dump = dump.join("\n") if @format == :json fields[:dump] = dump dump = nil end wrap(:dump, log || "", nil, fields) write_output("#{dump}\n\n") unless dump.nil? end private # Internal: Formats a given text using the ANSI escape sequences. Notice # that this method does not attempt to determine whether the current output # supports escape sequences. def color(color, text) bg = BACKGROUNDS[color] reset_bg = "" if bg bg = "\e[#{bg}m" reset_bg = "\e[49m" end "#{bg}\e[#{COLORS[color]}m#{text}\e[#{COLORS[:reset]}m#{reset_bg}" end # Internal: Removes all backtrace frames pointing to the logging facility # itself. def clean_caller_locations caller_locations.reject { |t| t.absolute_path&.end_with?("logrb.rb") } end # Internal: Returns the caller of a function, returning a pair containing # its path and base method name. def determine_caller c = clean_caller_locations.first [normalize_location(c), c.base_label] end # Internal: Performs a cleanup for a given backtrace frame. # # trace - Trace to be clean. # include_function_name - Optional. When true, includes the function name # on the normalized string. Defaults to false. def normalize_location(trace, include_function_name: false) path = trace.absolute_path return trace.to_s if path.nil? if (root = Gem.path.find { |p| path.start_with?(p) }) path = "$GEM_PATH#{path[root.length..]}" end "#{path}:#{trace.lineno}#{include_function_name ? " in `#{trace.label}'" : ""}" end # Internal: Returns a string containing a stacktrace of the current # invocation. def stack_trace(trace = clean_caller_locations) trace.map { |s| normalize_location(s, include_function_name: true) }.join("\n") end # Internal: Composes a log line with given information. # level - The severity of the log message # caller_meta - An Array containing the caller's location and name # msg - The message to be logged # fields - A Hash of fields to be included in the entry def compose_line(level, caller_meta, msg, fields) ts = Time.now.strftime("%Y-%m-%dT%H:%M:%S.%L%z") msg = " #{msg}" unless msg.empty? fields_str = if fields.empty? "" else " #{fields}" end level_str = color(level, level.to_s.upcase) "#{ts} #{level_str}: #{caller_meta.last}:#{msg}#{fields_str}" end # Internal: Logs a text entry to the current output. # level - The severity of the message to be logged. # msg - The message to be logged # error - Either an Exception object or nil. This parameter is used # to provide extra information on the logged entry. # fields - A Hash containing metadata to be included in the logged # entry. # caller_meta - An Array containing the caller's location and name. def text(level, msg, error, fields, caller_meta) fields ||= {} fields.merge! @fields write_output(compose_line(level, caller_meta, msg, fields)) if (error_message = error&.message) write_output(": #{error_message}") end write_output("\n") return unless level == :error backtrace_str = backtrace(error) .split("\n") .map { |s| " #{s}" }.join("\n") write_output(backtrace_str) write_output("\n") end # Internal: Attempts to obtain a backtrace from a provided object. In case # the object does not include backtrace metadata, uses #stack_trace as a # fallback. def backtrace(from) if from.respond_to?(:backtrace_locations) && !from.backtrace_locations.nil? stack_trace(from.backtrace_locations) else stack_trace end end # Internal: Writes a given value to the current's output IO. Calls to this # method are thread-safe. def write_output(text) Logrb.mutex.synchronize do @output.write(text) end end # Internal: Logs a JSON entry to the current output. # level - The severity of the message to be logged. # msg - The message to be logged # error - Either an Exception object or nil. This parameter is used # to provide extra information on the logged entry. # fields - A Hash containing metadata to be included in the logged # entry. # caller_meta - An Array containing the caller's location and name. def json(level, msg, error, fields, caller_meta) fields ||= {} fields.merge! @fields data = { level: level, caller: caller_meta.first, msg: msg, ts: Time.now.utc.to_i } data[:stacktrace] = backtrace(error) if level == :error data.merge!(fields) write_output("#{data.to_json}\n") end # Internal: Dynamically invokes the current log formatter for the # provided arguments. For further information, see #text and #json def wrap(level, msg, error, fields) msg = msg.to_s send(@format, level, msg, error, fields, determine_caller) exit 1 if level == :fatal end end