Class: Aspera::Log

Inherits:
Object
  • Object
show all
Includes:
Singleton
Defined in:
lib/aspera/log.rb

Overview

Singleton object for logging

Constant Summary collapse

LOG_TYPES =

Where logs are sent to

%i[stderr stdout syslog].freeze
LEVELS =

Levels are :trace2,:trace1,:debug,:info,:warn,:error,fatal,:unknown

Logger::Severity.constants.sort { |a, b| Logger::Severity.const_get(a) <=> Logger::Severity.const_get(b) }.map { |c| c.downcase.to_sym }.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#dump_format ⇒ Object

Returns the value of attribute dump_format.



148
149
150
# File 'lib/aspera/log.rb', line 148

def dump_format
  @dump_format
end

#logger ⇒ Object (readonly)

Returns the value of attribute logger.



147
148
149
# File 'lib/aspera/log.rb', line 147

def logger
  @logger
end

#logger_type ⇒ Object

Returns the value of attribute logger_type.



147
148
149
# File 'lib/aspera/log.rb', line 147

def logger_type
  @logger_type
end

Class Method Details

.caller_method ⇒ Object

Returns the last 2 containers (module/class) and method caller



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/aspera/log.rb', line 121

def caller_method
  locations = caller_locations
  # Outermost frame of logging code: Ruby logger, or this file (level methods, dump)
  i = locations.rindex { |loc| loc.path.end_with?('/logger.rb') || loc.path.eql?(__FILE__) }
  frame = locations[i + 1] if i
  return '???' unless frame
  # "Class::Module#method" (Ruby >= 3.4) or "method" (Ruby < 3.4)
  full = frame.label
  # Split into class/module and method parts
  parts = full.split(/(::|#)/)
  # Reconstruct keeping only last two class/module names + separator + method
  if parts.include?('#')
    sep_index = parts.index('#')
    classes = parts[0...sep_index].join
    method = parts[sep_index + 1]
  else
    classes = parts[0..-2].join
    method = parts.last
  end
  class_parts = classes.split('::')
  selected_classes = class_parts.last(2).join('::')
  return method if selected_classes.empty?
  "#{selected_classes}.#{method}"
end

.capture_stderr ⇒ Object

Capture the output of $stderr and log it at debug level

Yield Returns:

  • (void) —

    Code block whose stderr output will be captured



111
112
113
114
115
116
117
118
# File 'lib/aspera/log.rb', line 111

def capture_stderr
  real_stderr = $stderr
  $stderr = StringIO.new
  yield if block_given?
  log.debug($stderr.string)
ensure
  $stderr = real_stderr
end

.dump(name, object = nil, level: :debug, &block) ⇒ Object

Dump object (Hash) to log using specified level

Parameters:

  • name (String, Symbol) —

    Name of object dumped

  • object (Hash, nil) (defaults to: nil) —

    Data to dump

  • level (Symbol) (defaults to: :debug) —

    Debug level

Yield Returns:

  • (Object) —

    Computed object to dump (alternative to object parameter)



89
90
91
92
93
94
# File 'lib/aspera/log.rb', line 89

def dump(name, object = nil, level: :debug, &block)
  return unless instance.logger.send(:"#{level}?")
  Aspera.assert(object.nil? || block.nil?, 'Use either object, or block, not both')
  object = yield if block_given?
  instance.logger.send(level, obj_dump(name, object))
end

.instance ⇒ Log

Returns the singleton instance of Log

Returns:

  • (Log) —

    the singleton instance



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/aspera/log.rb', line 65

class Log
  include Singleton

  # Where logs are sent to
  LOG_TYPES = %i[stderr stdout syslog].freeze

  # Levels are :trace2,:trace1,:debug,:info,:warn,:error,fatal,:unknown
  LEVELS = Logger::Severity.constants.sort { |a, b| Logger::Severity.const_get(a) <=> Logger::Severity.const_get(b) }.map { |c| c.downcase.to_sym }.freeze

  # Class methods
  class << self
    def short_levl(level)
      "#{level[0, 3]}#{level[-1]}"
    end

    # Get the logger object of singleton
    def log; instance.logger; end

    # Dump object (`Hash`) to log using specified level
    #
    # @param name   [String, Symbol]  Name of object dumped
    # @param object [Hash, nil]       Data to dump
    # @param level  [Symbol]          Debug level
    # @yieldreturn [Object] Computed object to dump (alternative to object parameter)
    def dump(name, object = nil, level: :debug, &block)
      return unless instance.logger.send(:"#{level}?")
      Aspera.assert(object.nil? || block.nil?, 'Use either object, or block, not both')
      object = yield if block_given?
      instance.logger.send(level, obj_dump(name, object))
    end

    # @return [String] Dump of object
    def obj_dump(name, object)
      dump_text =
        case instance.dump_format
        when :json
          JSON.pretty_generate(object) rescue PP.pp(object, +'')
        when :ruby
          PP.pp(object, +'')
        else Aspera.error_unexpected_value(instance.dump_format) { 'dump format' }
        end
      "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}"
    end

    # Capture the output of $stderr and log it at debug level
    # @yieldreturn [void] Code block whose stderr output will be captured
    def capture_stderr
      real_stderr = $stderr
      $stderr = StringIO.new
      yield if block_given?
      log.debug($stderr.string)
    ensure
      $stderr = real_stderr
    end

    # Returns the last 2 containers (module/class) and method caller
    def caller_method
      locations = caller_locations
      # Outermost frame of logging code: Ruby logger, or this file (level methods, dump)
      i = locations.rindex { |loc| loc.path.end_with?('/logger.rb') || loc.path.eql?(__FILE__) }
      frame = locations[i + 1] if i
      return '???' unless frame
      # "Class::Module#method" (Ruby >= 3.4) or "method" (Ruby < 3.4)
      full = frame.label
      # Split into class/module and method parts
      parts = full.split(/(::|#)/)
      # Reconstruct keeping only last two class/module names + separator + method
      if parts.include?('#')
        sep_index = parts.index('#')
        classes = parts[0...sep_index].join
        method = parts[sep_index + 1]
      else
        classes = parts[0..-2].join
        method = parts.last
      end
      class_parts = classes.split('::')
      selected_classes = class_parts.last(2).join('::')
      return method if selected_classes.empty?
      "#{selected_classes}.#{method}"
    end
  end

  attr_reader :logger_type, :logger
  attr_accessor :dump_format

  # Set the program name used in log output
  # @param value [String] Program name
  # @return [nil]
  def program_name=(value)
    @program_name = value
    self.logger_type = @logger_type
    nil
  end

  # Set log level of underlying logger given symbol level
  # @param new_level [Symbol] One of LEVELS
  def level=(new_level)
    Aspera.assert_values(new_level, LEVELS)
    @logger.level = Logger::Severity.const_get(new_level.to_sym.upcase)
  end

  # Set log formatter; accepts a symbol name, a Proc, or a Logger::Formatter instance
  # @param formatter [String, Proc, Logger::Formatter] Formatter to use; one of: standard, default, caller
  # @return [nil]
  def formatter=(formatter)
    if formatter.is_a?(String)
      Aspera.assert(FORMATTER_LAMBDAS.key?(formatter.to_sym), type: Error) { "Unknown formatter #{formatter}, use one of: #{FORMATTERS.join(', ')}" }
      formatter = FORMATTER_LAMBDAS[formatter.to_sym]
    elsif !formatter.respond_to?(:call) && !formatter.is_a?(Logger::Formatter)
      raise Error, 'Formatter must be a String, a Logger::Formatter or a Proc'
    end
    # Update formatter with password hiding
    @logger.formatter = SecretHider.instance.log_formatter(formatter)
    nil
  end

  def formatter
    @logger.formatter
  end

  # Get symbol of debug level of underlying logger
  # @return [Symbol] One of LEVELS
  def level
    Aspera.assert(Logger::SEVERITY_LABEL.key?(@logger.level), 'unexpected logger level value')
    Logger::SEVERITY_LABEL[@logger.level].downcase
  end

  # Change underlying logger output destination, keeping current log level
  # @param new_log_type [Symbol] Log destination; one of LOG_TYPES (:stderr, :stdout, :syslog)
  # @return [nil]
  def logger_type=(new_log_type)
    # [Integer]
    current_severity_integer = @logger&.level || ENV['AS_LOG_LEVEL']&.to_i || Logger::Severity::INFO
    case new_log_type
    when :stderr
      @logger = Logger.new($stderr, progname: @program_name, formatter: DEFAULT_FORMATTER)
    when :stdout
      @logger = Logger.new($stdout, progname: @program_name, formatter: DEFAULT_FORMATTER)
    when :syslog
      require 'syslog/logger'
      # The syslog class automatically creates methods from the severity names.
      # We just need to add the mapping (but syslog lowest is DEBUG)
      1.upto(Logger::TRACE_MAX).each do |level|
        Syslog::Logger.const_get(:LEVEL_MAP)[Logger.const_get("TRACE#{level}")] = Syslog::LOG_DEBUG
      end
      Logger::Severity.constants.each do |severity|
        Syslog::Logger.make_methods(severity.downcase)
      end
      # Use `local2` facility, like other Aspera components
      @logger = Syslog::Logger.new(@program_name, Syslog::LOG_LOCAL2)
    else Aspera.error_unexpected_value(new_log_type) { "log type (#{LOG_TYPES.join(', ')})" }
    end
    @logger.level = current_severity_integer
    @logger_type = new_log_type
    # add secret hider to default logger
    self.formatter = @logger.formatter
    nil
  end

  private

  def initialize
    @logger = nil
    @program_name = 'aspera'
    @dump_format = :json
    @logger_type = :stderr
    # This sets @logger and @logger_type (self needed to call method instead of local var)
    self.logger_type = @logger_type
  end

  # Short (4-letters) levels with color (evaluated on each call, as colors can be enabled or disabled by option)
  LVL_COLOR = {
    TRACE2:  -> { short_levl(:TRACE2).faint },
    TRACE1:  -> { short_levl(:TRACE1).blue },
    DEBUG:   -> { short_levl(:DEBUG).cyan },
    INFO:    -> { short_levl(:INFO).green },
    WARN:    -> { short_levl(:WARN).bg(:yellow).black },
    ERROR:   -> { short_levl(:ERROR).bg(:red).blink },
    FATAL:   -> { short_levl(:FATAL).magenta },
    UNKNOWN: -> { short_levl(:UNKNOWN).blink }
  }.freeze

  DEFAULT_FORMATTER = ->(s, _d, _p, m) { "#{LVL_COLOR[s]&.call} #{m}\n" }

  # pre-defined formatters
  FORMATTER_LAMBDAS = {
    standard: Logger::Formatter.new,
    default:  DEFAULT_FORMATTER,
    caller:   ->(s, _d, _p, m) { "#{LVL_COLOR[s]&.call} #{Log.caller_method}\n#{m}\n" }
  }.freeze

  FORMATTERS = FORMATTER_LAMBDAS.keys

  private_constant :DEFAULT_FORMATTER, :FORMATTER_LAMBDAS
end

.log ⇒ Object

Get the logger object of singleton



81
# File 'lib/aspera/log.rb', line 81

def log; instance.logger; end

.obj_dump(name, object) ⇒ String

Returns Dump of object.

Returns:



97
98
99
100
101
102
103
104
105
106
107
# File 'lib/aspera/log.rb', line 97

def obj_dump(name, object)
  dump_text =
    case instance.dump_format
    when :json
      JSON.pretty_generate(object) rescue PP.pp(object, +'')
    when :ruby
      PP.pp(object, +'')
    else Aspera.error_unexpected_value(instance.dump_format) { 'dump format' }
    end
  "#{name.to_s.green}(#{instance.dump_format})#{object.class}=\n#{dump_text}"
end

.short_levl(level) ⇒ Object



76
77
78
# File 'lib/aspera/log.rb', line 76

def short_levl(level)
  "#{level[0, 3]}#{level[-1]}"
end

Instance Method Details

#formatter ⇒ Object



181
182
183
# File 'lib/aspera/log.rb', line 181

def formatter
  @logger.formatter
end

#formatter=(formatter) ⇒ nil

Set log formatter; accepts a symbol name, a Proc, or a Logger::Formatter instance

Parameters:

  • formatter (String, Proc, Logger::Formatter) —

    Formatter to use; one of: standard, default, caller

Returns:

  • (nil)


169
170
171
172
173
174
175
176
177
178
179
# File 'lib/aspera/log.rb', line 169

def formatter=(formatter)
  if formatter.is_a?(String)
    Aspera.assert(FORMATTER_LAMBDAS.key?(formatter.to_sym), type: Error) { "Unknown formatter #{formatter}, use one of: #{FORMATTERS.join(', ')}" }
    formatter = FORMATTER_LAMBDAS[formatter.to_sym]
  elsif !formatter.respond_to?(:call) && !formatter.is_a?(Logger::Formatter)
    raise Error, 'Formatter must be a String, a Logger::Formatter or a Proc'
  end
  # Update formatter with password hiding
  @logger.formatter = SecretHider.instance.log_formatter(formatter)
  nil
end

#level ⇒ Symbol

Get symbol of debug level of underlying logger

Returns:

  • (Symbol) —

    One of LEVELS



187
188
189
190
# File 'lib/aspera/log.rb', line 187

def level
  Aspera.assert(Logger::SEVERITY_LABEL.key?(@logger.level), 'unexpected logger level value')
  Logger::SEVERITY_LABEL[@logger.level].downcase
end

#level=(new_level) ⇒ Object

Set log level of underlying logger given symbol level

Parameters:

  • new_level (Symbol) —

    One of LEVELS



161
162
163
164
# File 'lib/aspera/log.rb', line 161

def level=(new_level)
  Aspera.assert_values(new_level, LEVELS)
  @logger.level = Logger::Severity.const_get(new_level.to_sym.upcase)
end

#program_name=(value) ⇒ nil

Set the program name used in log output

Parameters:

  • value (String) —

    Program name

Returns:

  • (nil)


153
154
155
156
157
# File 'lib/aspera/log.rb', line 153

def program_name=(value)
  @program_name = value
  self.logger_type = @logger_type
  nil
end