File: formatter.rb

package info (click to toggle)
ruby-lumberjack 2.0.4-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 956 kB
  • sloc: ruby: 7,957; makefile: 2
file content (350 lines) | stat: -rw-r--r-- 14,466 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# frozen_string_literal: true

require_relative "formatter_registry"

module Lumberjack
  # Formatter controls the conversion of log entry messages into a loggable format, allowing you
  # to log any object type and have the logging system handle the string conversion automatically.
  #
  # The formatter system works by associating formatting rules with specific classes using the {#add} method.
  # When an object is logged, the formatter finds the most specific formatter for that object's class
  # hierarchy and applies it to convert the object into a string representation.
  #
  # Formatters can be:
  #
  # - Predefined formatters: Accessed by symbol (e.g., +:pretty_print+, +:truncate+)
  # - Custom objects: Any object responding to +#call(object)+
  # - Blocks: Inline formatting logic
  # - Classes: Instantiated automatically with optional arguments
  #
  # The formatter includes optimizations for common primitive types (String, Integer, Float, Boolean)
  # to avoid unnecessary formatting overhead when custom formatters aren't defined for these types.
  #
  # @example Basic formatter usage
  #   formatter = Lumberjack::Formatter.new
  #   formatter.add(MyClass, :pretty_print)
  #   formatter.add(Array) { |vals| vals.join(", ") }
  #   result = formatter.format(my_object)
  #
  # @example Building a custom formatter
  #   formatter = Lumberjack::Formatter.build do |config|
  #     config.add(User, :id)  # Only log user IDs
  #     config.add(BigDecimal, :round, 2)  # Round decimals to 2 places
  #   end
  class Formatter
    require_relative "formatter/date_time_formatter"
    require_relative "formatter/exception_formatter"
    require_relative "formatter/id_formatter"
    require_relative "formatter/inspect_formatter"
    require_relative "formatter/multiply_formatter"
    require_relative "formatter/object_formatter"
    require_relative "formatter/pretty_print_formatter"
    require_relative "formatter/redact_formatter"
    require_relative "formatter/round_formatter"
    require_relative "formatter/string_formatter"
    require_relative "formatter/strip_formatter"
    require_relative "formatter/structured_formatter"
    require_relative "formatter/tags_formatter"
    require_relative "formatter/truncate_formatter"
    require_relative "formatter/tagged_message"

    class << self
      # Build a new formatter using a configuration block. The block receives the new formatter
      # as a parameter, allowing you to configure it with methods like +add+, +remove+, etc.
      #
      # @yield [formatter] A block that configures the formatter.
      # @return [Lumberjack::Formatter] A new configured formatter.
      #
      # @example
      #   formatter = Lumberjack::Formatter.build do |config|
      #     config.add(User, :id)  # Only show user IDs
      #     config.add(SecretToken) { |token| "[REDACTED]" }
      #     config.remove(Exception)  # Don't format exceptions specially
      #   end
      def build(&block)
        formatter = new
        block&.call(formatter)
        formatter
      end

      # Create a new empty formatter with no mappings. This is an alias for #new.
      #
      # @return [Lumberjack::Formatter] A new formatter with no default mappings.
      # @deprecated Use #new instead.
      def empty
        Utils.deprecated("Formatter.empty", "Lumberjack::Formatter.empty is deprecated and will be removed in version 2.1; use new instead.") do
          new
        end
      end

      # Create a new formatter with default mappings.
      #
      #   Object: inspect formatter
      #   Exception: exception formatter
      #   Enumerable: structured formatter
      #
      # @return [Lumberjack::Formatter] A new formatter with default mappings.
      def default
        build do |config|
          config.add(Object, :inspect)
          config.add(Exception, :exception)
          config.add(Enumerable, :structured)
        end
      end
    end

    # Create a new formatter with default mappings for common Ruby types.
    # The default configuration provides sensible formatting for most use cases:
    # - Object: Uses inspect for debugging-friendly output
    # - Exception: Formats with stack trace details
    # - Enumerable: Recursively formats collections (Arrays, Hashes, etc.)
    #
    # @return [Lumberjack::Formatter] A new formatter with default mappings.
    def initialize
      @class_formatters = {}
      @has_string_formatter = false
      @has_numeric_formatter = false
      @has_boolean_formatter = false
    end

    # Add a formatter for a specific class or classes. The formatter determines how objects
    # of that class will be converted to strings when logged.
    #
    # The formatter can be specified in several ways:
    # - Symbol: References a predefined formatter (see list below)
    # - Class: Will be instantiated with optional arguments
    # - Object: Must respond to +#call(object)+ method
    # - Block: Inline formatting logic
    #
    # Formatters can be referenced by name from the formatter registry. These formatters
    # are available out of the box. Some of them require an argument to be provided as well.
    #
    # - +:date_time+ - Formats time objects with a customizable format (takes the format string as an argument)
    # - +:exception+ - Formats exceptions with stack trace details
    # - +:id+ - Extracts object ID or specified ID field
    # - +:inspect+ - Uses Ruby's inspect method for debugging output
    # - +:multiply+ - Multiplies numeric values by a factor (requires the factor as an argument)
    # - +:object+ - Generic object formatter with custom methods
    # - +:pretty_print+ - Pretty-prints objects using PP library
    # - +:redact+ - Redacts sensitive information from objects
    # - +:round+ - Rounds numeric values to specified precision (takes the precision as an argument; defaults to 3 decimal places)
    # - +:string+ - Converts objects to strings using to_s
    # - +:strip+ - Strips whitespace from string representations
    # - +:structured+ - Recursively formats structured data (Arrays, Hashes)
    # - +:tags+ - Formats an array or hash of values in the format "[a] [b] [c=d]"
    # - +:truncate+ - Truncates long strings to specified length (takes the length as an argument)
    #
    # Classes can be specified as:
    #
    # - Class objects: Direct class references
    # - Arrays: Multiple classes at once
    # - Strings: Class names to avoid loading dependencies
    #
    # @param klass [Class, Module, String, Array<Class, Module, String>] The class(es) to format.
    # @param formatter [Symbol, Class, #call, nil] The formatter to use.
    # @param args [Array] Arguments passed to formatter constructor (when formatter is a Class).
    # @yield [obj] Block-based formatter that receives the object to format.
    # @yieldparam obj [Object] The object to format.
    # @yieldreturn [String] The formatted string representation.
    # @return [self] Returns self for method chaining.
    #
    # @example Using predefined formatters
    #   formatter.add(Float, :round, 2)  # Round floats to 2 decimal places
    #   formatter.add(Time, :date_time, "%Y-%m-%d")  # Custom time format
    #   formatter.add([User, Admin], :id)  # Show only IDs for user objects
    #
    # @example Using custom formatters
    #   formatter.add(MyClass, MyFormatter.new)  # Custom formatter object
    #   formatter.add("BigDecimal", RoundFormatter, 4)  # Class with arguments
    #
    # @example Method chaining
    #   formatter.add(User, :id)
    #            .add(BigDecimal, :round, 2)
    def add(klass, formatter = nil, *args, &block)
      formatter ||= block

      return remove(klass) if formatter.nil?

      if formatter.is_a?(Symbol)
        formatter = FormatterRegistry.formatter(formatter, *args)
      elsif formatter.is_a?(Class)
        formatter = formatter.new(*args)
      end

      raise ArgumentError.new("formatter must respond to call") unless formatter.respond_to?(:call)

      Array(klass).each do |k|
        @class_formatters[k.to_s] = formatter
      end

      set_optimized_flags!

      self
    end

    # Remove formatter associations for one or more classes. This reverts the classes
    # to use the default Object formatter (inspect method) or no formatting if no default exists.
    #
    # @param klass [Class, Module, String, Array<Class, Module, String>] The class(es) to remove formatters for.
    # @return [self] Returns self for method chaining.
    def remove(klass)
      Array(klass).each do |k|
        @class_formatters.delete(k.to_s)
      end

      set_optimized_flags!

      self
    end

    # Extend this formatter by merging the formats defined in the provided formatter into this one.
    #
    # @param formatter [Lumberjack::Formatter] The formatter to merge.
    # @return [self] Returns self for method chaining.
    def include(formatter)
      unless formatter.is_a?(Lumberjack::Formatter)
        raise ArgumentError.new("formatter must be a Lumberjack::Formatter")
      end

      formatter.instance_variable_get(:@class_formatters).each do |class_name, fmttr|
        add(class_name, fmttr)
      end

      self
    end

    # Extend this formatter by adding the formats defined in the provided formatter into this one.
    # Formats defined in this formatter will take precedence and not be overridden.
    #
    # @param formatter [Lumberjack::Formatter] The formatter to merge.
    # @return [self] Returns self for method chaining.
    def prepend(formatter)
      unless formatter.is_a?(Lumberjack::Formatter)
        raise ArgumentError.new("formatter must be a Lumberjack::Formatter")
      end

      formatter.instance_variable_get(:@class_formatters).each do |class_name, fmttr|
        add(class_name, fmttr) unless @class_formatters.include?(class_name)
      end

      self
    end

    # Remove all formatter associations, including defaults. This creates a completely
    # empty formatter where all objects will be passed through unchanged.
    #
    # @return [self] Returns self for method chaining.
    def clear
      @class_formatters.clear
      set_optimized_flags!

      self
    end

    # Check if the formatter has any registered formatters.
    #
    # @return [Boolean] true if no formatters are registered, false otherwise.
    def empty?
      @class_formatters.empty?
    end

    # Format an object by applying the appropriate formatter based on its class hierarchy.
    # The formatter searches up the class hierarchy to find the most specific formatter available.
    #
    # @param value [Object] The object to format.
    # @return [Object] The formatted representation (usually a String).
    def format(value)
      # These primitive types are the most common in logs and so are optimized here
      # for the normal case where a custom formatter has not been defined.
      case value
      when String
        return value unless @has_string_formatter
      when Integer, Float
        return value unless @has_numeric_formatter
      when Numeric
        if defined?(BigDecimal) && value.is_a?(BigDecimal)
          return value unless @has_numeric_formatter
        end
      when true, false
        return value unless @has_boolean_formatter
      end

      if value.respond_to?(:to_log_format) && !@class_formatters.include?(value.class.name)
        return value.to_log_format
      end

      formatter = formatter_for(value.class)
      value = formatter.call(value) if formatter&.respond_to?(:call)
      value
    rescue SystemStackError, StandardError => e
      error_message = e.class.name
      error_message = "#{error_message} #{e.message}" if e.message && e.message != ""
      warn("<Error formatting #{value.class.name}: #{error_message}>")
      "<Error formatting #{value.class.name}: #{error_message}>"
    end

    # Compatibility method for Ruby's standard Logger::Formatter interface. This allows
    # the Formatter to be used directly as a logger formatter, though it only uses the
    # message parameter and ignores severity, timestamp, and progname.
    #
    # @param severity [Integer, String, Symbol] The log severity (ignored).
    # @param timestamp [Time] The log timestamp (ignored).
    # @param progname [String] The program name (ignored).
    # @param msg [Object] The message object to format.
    # @return [String] The formatted message with line separator.
    def call(severity, timestamp, progname, msg)
      formatted_message = format(msg)
      formatted_message = formatted_message.message if formatted_message.is_a?(MessageAttributes)
      "#{formatted_message}#{Lumberjack::LINE_SEPARATOR}"
    end

    # Find the most appropriate formatter for a class by searching up the class hierarchy.
    # Returns the first formatter found by walking through the class's ancestors.
    #
    # @param klass [Class] The class to find a formatter for.
    # @return [#call, nil] The formatter object, or nil if no formatter is found.
    # @api private
    def formatter_for(klass)
      return nil if @class_formatters.empty?

      unless klass.is_a?(Module)
        begin
          klass = Object.const_get(klass.to_s)
        rescue NameError
          return @class_formatters[klass.to_s]
        end
      end

      formatter = nil
      has_to_log_format = klass.public_method_defined?(:to_log_format) if klass.is_a?(Module)
      klass.ancestors.detect do |ancestor|
        break if has_to_log_format && ancestor == Object

        formatter = @class_formatters[ancestor.name]
        break if formatter
      end
      formatter
    end

    # Check if a formatter exists for a specific class or class name.
    #
    # @param class_or_name [Class, Module, String] The class or class name to check.
    # @return [Boolean] true if a formatter exists, false otherwise.
    def include?(class_or_name)
      @class_formatters.include?(class_or_name.to_s)
    end

    private

    # Update internal optimization flags based on currently registered formatters.
    # This enables fast-path optimization for common primitive types.
    #
    # @return [void]
    # @api private
    def set_optimized_flags!
      @has_string_formatter = @class_formatters.include?("String")
      @has_numeric_formatter = @class_formatters.slice("Integer", "Float", "BigDecimal", "Numeric").any?
      @has_boolean_formatter = @class_formatters.include?("TrueClass") || @class_formatters.include?("FalseClass")
    end
  end
end