piotrmurach/tty-logger

View on GitHub
lib/tty/logger.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# frozen_string_literal: true

require_relative "logger/config"
require_relative "logger/data_filter"
require_relative "logger/event"
require_relative "logger/formatters/json"
require_relative "logger/formatters/text"
require_relative "logger/handlers/console"
require_relative "logger/handlers/null"
require_relative "logger/handlers/stream"
require_relative "logger/levels"
require_relative "logger/version"

module TTY
  class Logger
    include Levels

    # Error raised by this logger
    class Error < StandardError; end

    LOG_TYPES = {
      debug: { level: :debug },
      info: { level: :info },
      warn: { level: :warn },
      error: { level: :error },
      fatal: { level: :fatal },
      success: { level: :info },
      wait: { level: :info }
    }.freeze

    # Macro to dynamically define log types
    #
    # @api private
    def self.define_level(name, log_level = nil)
      const_level = (LOG_TYPES[name.to_sym] || log_level)[:level]

      loc = caller_locations(0, 1)[0]
      if loc
        file, line = loc.path, loc.lineno + 7
      else
        file, line = __FILE__, __LINE__ + 3
      end
      class_eval(<<-EOL, file, line)
        def #{name}(*msg, &block)
          log(:#{const_level}, *msg, &block)
        end
      EOL
    end

    define_level :debug
    define_level :info
    define_level :warn
    define_level :error
    define_level :fatal
    define_level :success
    define_level :wait

    # Logger configuration instance
    #
    # @api public
    def self.config
      @config ||= Config.new
    end

    # Global logger configuration
    #
    # @api public
    def self.configure
      yield config
    end

    # Instance logger configuration
    #
    # @api public
    def configure
      yield @config
    end

    # Create a logger instance
    #
    # @example
    #   logger = TTY::Logger.new(output: $stdout)
    #
    # @param [IO] output
    #   the output object, can be stream
    #
    # @param [Hash] fields
    #   the data fields for each log message
    #
    # @api public
    def initialize(output: nil, fields: {})
      @fields = fields
      @config = if block_given?
                  conf = Config.new
                  yield(conf)
                  conf
                else
                  self.class.config
                end
      @level = @config.level
      @handlers = @config.handlers
      @output = output || @config.output
      @ready_handlers = []
      @data_filter = DataFilter.new(@config.filters.data,
                                    mask: @config.filters.mask)

      @types = LOG_TYPES.dup
      @config.types.each do |name, log_level|
        add_type(name, log_level)
      end

      @handlers.each do |handler|
        add_handler(handler)
      end
    end

    # Add new log type
    #
    # @example
    #   add_type(:thanks, {level: :info})
    #
    # @api private
    def add_type(name, log_level)
      if @types.include?(name)
        raise Error, "Already defined log type #{name.inspect}"
      end

      @types[name.to_sym] = log_level
      self.class.define_level(name, log_level)
    end

    # Add handler for logging messages
    #
    # @example
    #   add_handler(:console)
    #
    # @example
    #   add_handler(:console, styles: { info: { color: :yellow } })
    #
    # @example
    #   add_handler([:console, message_format: "%-10s"])
    #
    # @param [Symbol, Handlers] handler
    #   the handler name or class
    #
    # @api public
    def add_handler(handler, **options)
      h, h_opts = *(handler.is_a?(Array) ? handler : [handler, options])
      handler_type = coerce_handler(h)
      global_opts = { output: @output, config: @config }
      opts = global_opts.merge(h_opts)
      ready_handler = handler_type.new(**opts)
      @ready_handlers << ready_handler
    end

    # Remove log events handler
    #
    # @example
    #   remove_handler(:console)
    #
    # @api public
    def remove_handler(handler)
      handler_type = coerce_handler(handler)
      @ready_handlers.delete_if { |_handler| _handler.is_a?(handler_type) }
    end

    # Coerce handler name into object
    #
    # @example
    #   coerce_handler(:console)
    #   # => TTY::Logger::Handlers::Console
    #
    # @raise [Error] when class cannot be coerced
    #
    # @return [Class]
    #
    # @api private
    def coerce_handler(name)
      case name
      when String, Symbol
        Handlers.const_get(name.capitalize)
      when Class
        name
      else
        raise_handler_error
      end
    rescue NameError
      raise_handler_error
    end

    # Raise error when unknown handler name
    #
    # @api private
    def raise_handler_error
      raise Error, "Handler needs to be a class name or a symbol name"
    end

    # Copy this logger
    #
    # @example
    #   logger = TTY::Logger.new
    #   child_logger = logger.copy(app: "myenv", env: "prod")
    #   child_logger.info("Deploying")
    #
    # @return [TTY::Logger]
    #   a new copy of this logger
    #
    # @api public
    def copy(new_fields)
      new_config = @config.to_proc.call(Config.new)
      if block_given?
        yield(new_config)
      end
      self.class.new(fields: @fields.merge(new_fields),
                     output: @output, &new_config)
    end

    # Check current level against another
    #
    # @return [Symbol]
    #
    # @api public
    def log?(level, other_level)
      compare_levels(level, other_level) != :gt
    end

    # Logs streaming output.
    #
    # @example
    #   logger << "Example output"
    #
    # @api public
    def write(*msg)
      event = Event.new(filter(*msg))

      @ready_handlers.each do |handler|
        handler.(event)
      end

      self
    end
    alias << write

    # Log a message given the severtiy level
    #
    # @example
    #   logger.log(:info, "Deployed successfully")
    #
    # @example
    #   logger.log(:info) { "Deployed successfully" }
    #
    # @api public
    def log(current_level, *msg)
      scoped_fields = msg.last.is_a?(::Hash) ? msg.pop : {}
      fields_copy = scoped_fields.dup
      if msg.empty? && block_given?
        msg = []
        Array[yield].flatten(1).each do |el|
          el.is_a?(::Hash) ? fields_copy.merge!(el) : msg << el
        end
      end
      top_caller = caller_locations(1, 1)[0]
      loc = caller_locations(2, 1)[0] || top_caller
      label = top_caller.label
      metadata = {
        level: current_level,
        time: Time.now,
        pid: Process.pid,
        name: @types.include?(label.to_sym) ? label : current_level,
        path: loc.path,
        lineno: loc.lineno,
        method: loc.base_label
      }
      event = Event.new(filter(*msg),
                        @data_filter.filter(@fields.merge(fields_copy)),
                        metadata)

      @ready_handlers.each do |handler|
        level = handler.respond_to?(:level) ? handler.level : @config.level
        handler.(event) if log?(level, current_level)
      end
      self
    end

    # Change current log level for the duration of the block
    #
    # @example
    #   logger.log_at :debug do
    #     logger.debug("logged")
    #   end
    #
    # @param [String] tmp_level
    #   the temporary log level
    #
    # @api public
    def log_at(tmp_level, &block)
      @ready_handlers.each do |handler|
        handler.log_at(tmp_level, &block)
      end
    end

    # Filter message parts for any sensitive information and
    # replace with placeholder.
    #
    # @param [Array[Object]] objects
    #   the messages to filter
    #
    # @return [Array[String]]
    #   the filtered message
    #
    # @api private
    def filter(*objects)
      objects.map do |obj|
        case obj
        when Exception
          backtrace = Array(obj.backtrace).map { |line| swap_filtered(line) }
          copy_error(obj, swap_filtered(obj.message), backtrace)
        else
          swap_filtered(obj.to_s)
        end
      end
    end

    # Create a new error instance copy
    #
    # @param [Exception] error
    # @param [String] message
    # @param [Array,nil] backtrace
    #
    # @return [Exception]
    #
    # @api private
    def copy_error(error, message, backtrace = nil)
      new_error = error.exception(message)
      new_error.set_backtrace(backtrace)
      new_error
    end

    # Swap string content for filtered content
    #
    # @param [String] obj
    #
    # @api private
    def swap_filtered(obj)
      obj.dup.tap do |obj_copy|
        @config.filters.message.each do |text|
          obj_copy.gsub!(text, @config.filters.mask)
        end
      end
    end
  end # Logger
end # TTY