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
|