File: template.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 (323 lines) | stat: -rw-r--r-- 14,057 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
# frozen_string_literal: true

module Lumberjack
  # A flexible template system for converting log entries into formatted strings.
  # Templates use mustache style placeholders to create customizable log output formats.
  #
  # The template system supports the following built-in placeholders:
  #
  # - <code>{{time}}</code> - The log entry timestamp
  # - <code>{{severity}}</code> - The severity level (DEBUG, INFO, WARN, ERROR, FATAL). The severity
  #   can also be formatted in a variety of ways with an optional format specifier.
  #   Supported formats include:
  #   - <code>{{severity(padded)}}</code> - Right padded so that all values are five characters
  #   - <code>{{severity(char)}}</code> - Single character representation (D, I, W, E, F)
  #   - <code>{{severity(emoji)}}</code> - Emoji representation
  #   - <code>{{severity(level)}}</code> - Numeric level representation
  # - <code>{{progname}}</code> - The program name that generated the entry
  # - <code>{{pid}}</code> - The process ID
  # - <code>{{message}}</code> - The main log message content
  # - <code>{{attributes}}</code> - All custom attributes formatted as key:value pairs
  #
  # Custom attribute placeholders can also be put in the double bracket placeholders.
  # Any attributes explicitly added to the template in their own placeholder will be removed
  # from the general list of attributes.
  #
  # @example Basic template usage
  #   template = Lumberjack::Template.new("[{{time}} {{severity}}] {{message}}")
  #   # Output: [2023-08-21T10:30:15.123 INFO] User logged in
  #
  # @example Multi-line message formatting
  #   template = Lumberjack::Template.new(
  #     "[{{time}} {{severity}}] {{message}}",
  #     additional_lines: "\n    | {{message}}"
  #   )
  #   # Output:
  #   # [2023-08-21T10:30:15.123 INFO] First line
  #   #     | Second line
  #   #     | Third line
  #
  # @example Custom attribute placeholders
  #   # The user_id attribute will be put before the message instead of with the rest of the attributes.
  #   template = Lumberjack::Template.new("[{{time}} {{severity}}] (usr:{{user_id}} {{message}} -- {{attributes}})")
  class Template
    DEFAULT_FIRST_LINE_TEMPLATE = "[{{time}} {{severity(padded)}} {{progname}}({{pid}})] {{message}} {{attributes}}"
    STDLIB_FIRST_LINE_TEMPLATE = "{{severity(char)}}, [{{time}} {{pid}}] {{severity(padded)}} -- {{progname}}: {{message}} {{attributes}}"
    DEFAULT_ADDITIONAL_LINES_TEMPLATE = "#{Lumberjack::LINE_SEPARATOR}> {{message}}"
    DEFAULT_ATTRIBUTE_FORMAT = "[%s:%s]"

    TemplateRegistry.add(:default, DEFAULT_FIRST_LINE_TEMPLATE)
    TemplateRegistry.add(:stdlib, STDLIB_FIRST_LINE_TEMPLATE)
    TemplateRegistry.add(:message, "{{message}}")

    # A wrapper template that delegates formatting to a standard Ruby Logger formatter.
    # This provides compatibility with existing Logger::Formatter implementations while
    # maintaining the Template interface for consistent usage within Lumberjack.
    class StandardFormatterTemplate < Template
      # Create a new wrapper for a standard Ruby Logger formatter.
      #
      # @param formatter [Logger::Formatter] The formatter to wrap
      def initialize(formatter)
        @formatter = formatter
      end

      # Format a log entry using the wrapped formatter.
      #
      # @param entry [Lumberjack::LogEntry] The log entry to format
      # @return [String] The formatted log entry
      def call(entry)
        @formatter.call(entry.severity_label, entry.time, entry.progname, entry.message)
      end

      # Set the datetime format on the wrapped formatter if supported.
      #
      # @param value [String] The datetime format string
      # @return [void]
      def datetime_format=(value)
        @formatter.datetime_format = value if @formatter.respond_to?(:datetime_format=)
      end

      # Get the datetime format from the wrapped formatter if supported.
      #
      # @return [String, nil] The datetime format string, or nil if not supported
      def datetime_format
        @formatter.datetime_format if @formatter.respond_to?(:datetime_format)
      end
    end

    TEMPLATE_ARGUMENT_ORDER = %w[
      time
      severity
      severity(padded)
      severity(char)
      severity(emoji)
      severity(level)
      progname
      pid
      message
      attributes
    ].freeze

    MILLISECOND_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%3N"
    MICROSECOND_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%6N"
    PLACEHOLDER_PATTERN = /{{ *((?:[^}]|}(?!}))*) *}}/i
    V1_PLACEHOLDER_PATTERN = /:[a-z0-9_.-]+/i
    RESET_CHAR = "\e[0m"
    private_constant :TEMPLATE_ARGUMENT_ORDER, :MILLISECOND_TIME_FORMAT, :MICROSECOND_TIME_FORMAT, :PLACEHOLDER_PATTERN, :V1_PLACEHOLDER_PATTERN, :RESET_CHAR

    class << self
      def colorize_entry(formatted_string, entry)
        color_start = entry.severity_data.terminal_color
        formatted_string.split(Lumberjack::LINE_SEPARATOR).collect do |line|
          "\e7#{color_start}#{line}\e8"
        end.join(Lumberjack::LINE_SEPARATOR)
      end
    end

    # Create a new template with customizable formatting options. The template
    # supports different formatting for single-line and multi-line messages,
    # custom time formatting, and configurable attribute display.
    #
    # @param first_line [String, nil] Template for formatting the first line of messages.
    #   Defaults to <code>[{{ time }} {{ severity(padded) }} {{ progname }}({{ pid }})] {{ message }} {{ attributes }}</code>
    # @param additional_lines [String, nil] Template for formatting additional lines
    #   in multi-line messages. Defaults to <code>\\n> {{ message }}</code>
    # @param time_format [String, Symbol, nil] Time formatting specification. Can be:
    #   - A strftime format string (e.g., "%Y-%m-%d %H:%M:%S")
    #   - +:milliseconds+ for ISO format with millisecond precision (default)
    #   - +:microseconds+ for ISO format with microsecond precision
    # @param attribute_format [String, nil] Printf-style format for individual attributes.
    #   Must contain exactly two %s placeholders for name and value. Defaults to "[%s:%s]"
    # @param colorize [Boolean] Whether to colorize the log entry based on severity (default: false)
    # @raise [ArgumentError] If attribute_format doesn't contain exactly two %s placeholders
    def initialize(first_line = nil, additional_lines: nil, time_format: nil, attribute_format: nil, colorize: false)
      first_line ||= DEFAULT_FIRST_LINE_TEMPLATE
      first_line = "#{first_line.chomp}#{Lumberjack::LINE_SEPARATOR}"
      if !first_line.include?("{{") && first_line.match?(V1_PLACEHOLDER_PATTERN)
        Utils.deprecated("Template.v1", "Templates now use {{placeholder}} instead of :placeholder and :tags has been replaced with {{attributes}}.") do
          @first_line_template, @first_line_attributes = compile_v1(first_line)
        end
      else
        @first_line_template, @first_line_attributes = compile(first_line)
      end

      additional_lines ||= DEFAULT_ADDITIONAL_LINES_TEMPLATE
      if !additional_lines.include?("{{") && additional_lines.match?(V1_PLACEHOLDER_PATTERN)
        Utils.deprecated("Template.v1", "Templates now use {{placeholder}} instead of :placeholder and :tags has been replaced with {{attributes}}.") do
          @additional_line_template, @additional_line_attributes = compile_v1(additional_lines)
        end
      else
        @additional_line_template, @additional_line_attributes = compile(additional_lines)
      end

      @attribute_template = attribute_format || DEFAULT_ATTRIBUTE_FORMAT
      unless @attribute_template.scan("%s").size == 2
        raise ArgumentError.new("attribute_format must be a printf template with exactly two '%s' placeholders")
      end

      # Formatting the time is relatively expensive, so only do it if it will be used
      @template_include_time = "#{@first_line_template} #{@additional_line_template}".include?("%1$s")
      self.datetime_format = (time_format || :milliseconds)

      @colorize = colorize
    end

    # Set the datetime format used for timestamp formatting in the template.
    # This method accepts both strftime format strings and symbolic shortcuts.
    #
    # @param format [String, Symbol] The datetime format specification:
    #   - String: A strftime format pattern (e.g., "%Y-%m-%d %H:%M:%S")
    #   - +:milliseconds+: ISO format with millisecond precision (YYYY-MM-DDTHH:MM:SS.sss)
    #   - +:microseconds+: ISO format with microsecond precision (YYYY-MM-DDTHH:MM:SS.ssssss)
    # @return [void]
    def datetime_format=(format)
      if format == :milliseconds
        format = MILLISECOND_TIME_FORMAT
      elsif format == :microseconds
        format = MICROSECOND_TIME_FORMAT
      end
      @time_formatter = Formatter::DateTimeFormatter.new(format)
    end

    # Get the current datetime format string used for timestamp formatting.
    #
    # @return [String] The strftime format string currently in use
    def datetime_format
      @time_formatter.format
    end

    # Convert a log entry into a formatted string using the template. This method
    # handles both single-line and multi-line messages, applying the appropriate
    # templates and performing placeholder substitution.
    #
    # @param entry [Lumberjack::LogEntry] The log entry to format
    # @return [String] The formatted log entry string
    def call(entry)
      return entry unless entry.is_a?(LogEntry)

      first_line = entry.message.to_s
      additional_lines = nil
      if first_line.include?(Lumberjack::LINE_SEPARATOR)
        additional_lines = first_line.split(Lumberjack::LINE_SEPARATOR)
        first_line = additional_lines.shift
      end

      formatted_time = @time_formatter.call(entry.time) if @template_include_time
      severity = entry.severity_data
      format_args = [
        formatted_time,
        severity.label,
        severity.padded_label,
        severity.char,
        severity.emoji,
        severity.level,
        entry.progname,
        entry.pid,
        first_line
      ]
      append_attribute_args!(format_args, entry.attributes, @first_line_attributes)
      message = (@first_line_template % format_args)

      if additional_lines && !additional_lines.empty?
        format_args.slice!(9, format_args.size)
        append_attribute_args!(format_args, entry.attributes, @additional_line_attributes)

        message_length = message.length
        message.chomp!(Lumberjack::LINE_SEPARATOR)
        chomped = message.length != message_length

        additional_lines.each do |line|
          format_args[8] = line
          line_message = @additional_line_template % format_args
          message << line_message
        end

        message << Lumberjack::LINE_SEPARATOR if chomped
      end

      message = Template.colorize_entry(message, entry) if @colorize

      message
    end

    private

    # Build the arguments array for sprintf formatting by appending attribute values.
    # This method handles both the general :attributes placeholder and specific
    # attribute placeholders defined in the template.
    #
    # @param args [Array] The existing format arguments array to modify
    # @param attributes [Hash, nil] The log entry attributes hash
    # @param attribute_vars [Array<String>] List of specific attribute names used in template
    # @return [void]
    def append_attribute_args!(args, attributes, attribute_vars)
      if attributes.nil? || attributes.size == 0
        (attribute_vars.length + 1).times { args << nil }
        return
      end

      attributes_string = +""
      attributes.each do |name, value|
        unless value.nil? || attribute_vars.include?(name)
          value = value.to_s
          value = value.gsub(Lumberjack::LINE_SEPARATOR, " ") if value.include?(Lumberjack::LINE_SEPARATOR)
          attributes_string << " "
          attributes_string << @attribute_template % [name, value]
        end
      end

      args << attributes_string
      attribute_vars.each do |name|
        args << attributes[name]
      end
    end

    # Parse and compile a template string into a sprintf-compatible format string
    # and extract attribute variable names. This method handles placeholder
    # substitution and escape sequence processing.
    #
    # @param template [String] The raw template string with placeholders
    # @return [Array<String, Array<String>>] A tuple of [compiled_template, attribute_vars]
    def compile(template) # :nodoc:
      template = template.gsub(/ ({{ *)attributes( *}})/, "\\1attributes\\2")
      template = template.gsub(/%(?!%)/, "%%")

      attribute_vars = []
      template = template.gsub(PLACEHOLDER_PATTERN) do |match|
        var_name = match.sub(/{{ */, "").sub(/ *}}/, "")
        position = TEMPLATE_ARGUMENT_ORDER.index(var_name)
        if position
          "%#{position + 1}$s"
        else
          attribute_vars << var_name
          "%#{TEMPLATE_ARGUMENT_ORDER.size + attribute_vars.size}$s"
        end
      end
      [template, attribute_vars]
    end

    # Parse and compile a template string into a sprintf-compatible format string
    # and extract attribute variable names. This method handles placeholder
    # substitution and escape sequence processing.
    #
    # @param template [String] The raw template string with placeholders
    # @return [Array<String, Array<String>>] A tuple of [compiled_template, attribute_vars]
    def compile_v1(template) # :nodoc:
      template = template.gsub(":tags", ":attributes").gsub(/ ?:attributes/, ":attributes")
      template = template.gsub(/%(?!%)/, "%%")

      attribute_vars = []
      template = template.gsub(V1_PLACEHOLDER_PATTERN) do |match|
        var_name = match[1, match.length]
        position = TEMPLATE_ARGUMENT_ORDER.index(var_name)
        if position
          "%#{position + 1}$s"
        else
          attribute_vars << var_name
          "%#{TEMPLATE_ARGUMENT_ORDER.size + attribute_vars.size}$s"
        end
      end
      [template, attribute_vars]
    end
  end
end