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 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451
|
# frozen_string_literal: true
module Lumberjack
# AttributeFormatter provides flexible formatting control for log entry attributes (key-value pairs).
# It allows you to specify different formatting rules for attribute names, object classes, or
# provide a default formatter for all attributes.
#
# The formatter system works in a hierarchical manner:
# 1. Attribute-specific formatters - Applied to specific attribute names (highest priority)
# 2. Class-specific formatters - Applied based on the attribute value's class
# 3. Default formatter - Applied to all other attributes (lowest priority)
#
# Formatters can be specified as:
#
# - Lumberjack::Formatter objects: Full formatter instances with complex logic
# - Callable objects: Any object responding to +#call(value)+
# - Blocks: Inline formatting logic
# - Symbols: References to predefined formatter classes (e.g., +:strip+, +:truncate+)
#
# @example Basic usage with build
# formatter = Lumberjack::AttributeFormatter.build do |config|
# config.add_attribute(["password", "secret", "token"]) { |value| "[REDACTED]" }
# config.add_attribute("user.email") { |email| email.downcase }
# config.add_class(Time, :date_time, "%Y-%m-%d %H:%M:%S")
# end
#
# If the value returned by a formatter is a +Lumberjack::RemapAttributes+ instance, then
# the attributes will be remapped to the new attributes.
#
# @example
# formatter = Lumberjack::AttributeFormatter.new
# formatter.add_attribute("duration_ms") { |value| Lumberjack::RemapAttributes.new(duration: value.to_f / 1000) }
# formatter.format({ "duration_ms" => 1234 }) # => { "duration" => 1.234 }
#
# @see Lumberjack::Formatter
# @see Lumberjack::EntryFormatter
class AttributeFormatter
class << self
# Build a new attribute formatter using a configuration block. The block receives the
# new formatter as a parameter, allowing you to configure it with methods like +add_attribute+,
# +add_class+, +default+, etc.
#
# @yield [formatter] A block that configures the attribute formatter.
# @return [Lumberjack::AttributeFormatter] A new configured attribute formatter.
#
# @example
# formatter = Lumberjack::AttributeFormatter.build do |config|
# config.default { |value| value.to_s.strip }
# config.add_attribute(["password", "secret"]) { |value| "[REDACTED]" }
# config.add_attribute("email") { |email| email.downcase }
# config.add_class(Time, :date_time, "%Y-%m-%d %H:%M:%S")
# end
def build(&block)
formatter = new
block&.call(formatter)
formatter
end
end
# Create a new attribute formatter with no default formatters configured.
# You'll need to add specific formatters using {#add_class}, {#add_attribute}, or {#default}.
#
# @return [Lumberjack::AttributeFormatter] A new empty attribute formatter.
def initialize
@attribute_formatter = {}
@class_formatter = Formatter.new
@default_formatter = nil
end
# Set a default formatter applied to all attribute values that don't have specific formatters.
# This serves as the fallback formatting behavior for any attributes not covered by
# attribute-specific or class-specific formatters.
#
# @param formatter [Lumberjack::Formatter, #call, Class, nil] The formatter to use.
# If nil, the block will be used as the formatter. If a class is passed, it will be
# instantiated with the args passed in.
# @param args [Array] The arguments to pass to the constructor if formatter is a Class.
# @yield [value] Block-based formatter that receives the attribute value.
# @yieldparam value [Object] The attribute value to format.
# @yieldreturn [Object] The formatted attribute value.
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
def default(formatter = nil, *args, &block)
formatter ||= block
formatter = dereference_formatter(formatter, args)
@default_formatter = formatter
self
end
# Remove the default formatter. After calling this, attributes without specific formatters
# will be passed through unchanged.
#
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
def remove_default
@default_formatter = nil
self
end
# Add formatters for specific attribute names or object classes. This is a convenience method
# that automatically delegates to {#add_class} or {#add_attribute} based on the input type.
#
# When you pass a Module/Class, it creates a class-based formatter that applies to all
# attribute values of that type. When you pass a String, it creates an attribute-specific
# formatter for that exact attribute name.
#
# Class formatters are applied recursively to nested hashes and arrays, making them
# powerful for formatting complex nested structures.
#
# @param names_or_classes [String, Module, Array<String, Module>] Attribute names or object classes.
# @param formatter [Lumberjack::Formatter, #call, Symbol, nil] The formatter to use.
# @yield [value] Block-based formatter that receives the attribute value.
# @yieldparam value [Object] The attribute value to format.
# @yieldreturn [Object] The formatted attribute value.
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
# @deprecated Use {#add_class} or {#add_attribute} instead.
def add(names_or_classes, formatter = nil, *args, &block)
Utils.deprecated("AttributeFormatter#add", "AttributeFormatter#add is deprecated and will be removed in version 2.1; use #add_class or #add_attribute instead.") do
Array(names_or_classes).each do |obj|
if obj.is_a?(Module)
add_class(obj, formatter, *args, &block)
else
add_attribute(obj, formatter, *args, &block)
end
end
end
self
end
# Add formatters for specific object classes. The formatter will be applied to any attribute
# value that is an instance of the registered class. This is particularly useful for formatting
# all instances of specific data types consistently across your logs.
#
# Class formatters are recursive - they will be applied to matching objects found within
# nested hashes and arrays.
#
# @param classes_or_names [String, Module, Array<String, Module>] Class names or modules.
# @param formatter [Lumberjack::Formatter, #call, Symbol, Class, nil] The formatter to use.
# If a Class is provided, it will be instantiated with the provided args.
# @param args [Array] The arguments to pass to the constructor if formatter is a Class.
# @yield [value] Block-based formatter that receives the attribute value.
# @yieldparam value [Object] The attribute value to format.
# @yieldreturn [Object] The formatted attribute value.
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
#
# @example Time formatting
# formatter.add_class(Time, :date_time, "%Y-%m-%d %H:%M:%S")
# formatter.add_class([Date, DateTime]) { |dt| dt.strftime("%Y-%m-%d") }
def add_class(classes_or_names, formatter = nil, *args, &block)
formatter ||= block
formatter = dereference_formatter(formatter, args)
Array(classes_or_names).each do |class_or_name|
class_name = class_or_name.to_s
if formatter.nil?
@class_formatter.remove(class_name)
else
@class_formatter.add(class_name, formatter)
end
end
self
end
# Add formatters for specific attribute names. These formatters take precedence over
# class formatters and the default formatter.
#
# Supports dot notation for nested attributes (e.g., "user.profile.email"). This allows
# you to format specific values deep within nested hash structures.
#
# @param attribute_names [String, Symbol, Array<String, Symbol>] The attribute names to format.
# @param formatter [Lumberjack::Formatter, #call, Symbol, nil] The formatter to use.
# @yield [value] Block-based formatter that receives the attribute value.
# @yieldparam value [Object] The attribute value to format.
# @yieldreturn [Object] The formatted attribute value.
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
#
# @example Basic attribute formatting
# formatter.add_attribute("password") { |pwd| "[REDACTED]" }
# formatter.add_attribute("email") { |email| email.downcase }
#
# @example Nested attribute formatting
# formatter.add_attribute("user.profile.email") { |email| email.downcase }
# formatter.add_attribute("config.database.password") { "[HIDDEN]" }
#
# @example Multiple attributes
# formatter.add_attribute(["secret", "token", "api_key"]) { "[REDACTED]" }
def add_attribute(attribute_names, formatter = nil, *args, &block)
formatter ||= block
formatter = dereference_formatter(formatter, args)
Array(attribute_names).collect(&:to_s).each do |attribute_name|
if attribute_name.is_a?(Module)
raise ArgumentError.new("attribute_name cannot be a Module/Class; use #add_class to add class-based formatters")
end
if formatter.nil?
@attribute_formatter.delete(attribute_name)
else
@attribute_formatter[attribute_name] = formatter
end
end
self
end
# Remove formatters for specific attribute names or classes. This reverts the specified
# attributes or classes to use the default formatter (if configured) or no formatting.
#
# @param names_or_classes [String, Module, Array<String, Module>] Attribute names or classes
# to remove formatters for.
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
# @deprecated Use {#remove_class} or {#remove_attribute} instead.
def remove(names_or_classes)
Utils.deprecated("AttributeFormatter#remove", "AttributeFormatter#remove is deprecated and will be removed in version 2.1; use #remove_class or #remove_attribute instead.") do
Array(names_or_classes).each do |key|
if key.is_a?(Module)
@class_formatter.remove(key)
else
@attribute_formatter.delete(key.to_s)
end
end
end
self
end
# Remove formatters for specific object classes. This reverts the specified classes
# to use the default formatter (if configured) or no formatting.
#
# @param classes_or_names [String, Module, Array<String, Module>] The classes or names to remove.
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
def remove_class(classes_or_names)
Array(classes_or_names).each do |class_or_name|
@class_formatter.remove(class_or_name)
end
self
end
# Remove formatters for specific attribute names. This reverts the specified attributes
# to use the default formatter (if configured) or no formatting.
#
# @param attribute_names [String, Symbol, Array<String, Symbol>] The attribute names to remove.
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
def remove_attribute(attribute_names)
Array(attribute_names).collect(&:to_s).each do |attribute_name|
@attribute_formatter.delete(attribute_name)
end
self
end
# Extend this formatter by merging the formats defined in the provided formatter into this one.
#
# @param formatter [Lumberjack::AttributeFormatter] The formatter to merge.
# @return [self] Returns self for method chaining.
def include(formatter)
unless formatter.is_a?(Lumberjack::AttributeFormatter)
raise ArgumentError.new("formatter must be a Lumberjack::AttributeFormatter")
end
@class_formatter.include(formatter.instance_variable_get(:@class_formatter))
@attribute_formatter.merge!(formatter.instance_variable_get(:@attribute_formatter))
default_formatter = formatter.instance_variable_get(:@default_formatter)
@default_formatter = default_formatter if default_formatter
self
end
# Extend this formatter by merging 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::AttributeFormatter] The formatter to merge.
# @return [self] Returns self for method chaining.
def prepend(formatter)
unless formatter.is_a?(Lumberjack::AttributeFormatter)
raise ArgumentError.new("formatter must be a Lumberjack::AttributeFormatter")
end
@class_formatter.prepend(formatter.instance_variable_get(:@class_formatter))
formatter.instance_variable_get(:@attribute_formatter).each do |key, value|
@attribute_formatter[key] = value unless @attribute_formatter.include?(key)
end
@default_formatter ||= formatter.instance_variable_get(:@default_formatter)
self
end
# Remove all configured formatters, including the default formatter. This resets the
# formatter to a completely empty state where all attributes pass through unchanged.
#
# @return [Lumberjack::AttributeFormatter] Returns self for method chaining.
def clear
@default_formatter = nil
@attribute_formatter.clear
@class_formatter.clear
self
end
# Check if the formatter has any configured formatters (attribute, class, or default).
#
# @return [Boolean] true if no formatters are configured, false otherwise.
def empty?
@attribute_formatter.empty? && @class_formatter.empty? && @default_formatter.nil?
end
# Format a hash of attributes using the configured formatters. This is the main
# method that applies all formatting rules to transform attribute values.
#
# The formatting process follows this precedence:
# 1. Attribute-specific formatters (highest priority)
# 2. Class-specific formatters
# 3. Default formatter (lowest priority)
#
# Nested hashes and arrays are processed recursively, and dot notation attribute
# formatters are applied to nested structures.
#
# @param attributes [Hash, nil] The attributes hash to format.
# @return [Hash, nil] The formatted attributes hash, or nil if input was nil.
def format(attributes)
return nil if attributes.nil?
return attributes if empty?
formated_attributes(attributes)
end
# Get the formatter for a specific class or class name.
#
# @param klass [String, Module] The class or class name to get the formatter for.
# @return [#call, nil] The formatter for the class, or nil if not found.
def formatter_for_class(klass)
@class_formatter.formatter_for(klass)
end
# Get the formatter for a specific attribute.
#
# @param name [String, Symbol] The attribute name to get the formatter for.
# @return [#call, nil] The formatter for the attribute, or nil if not found.
def formatter_for_attribute(name)
@attribute_formatter[name.to_s]
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?(class_or_name)
@class_formatter.include?(class_or_name.to_s)
end
# Check if a formatter exists for a specific attribute name.
#
# @param name [String, Symbol] The attribute name to check.
# @return [Boolean] true if a formatter exists, false otherwise.
def include_attribute?(name)
@attribute_formatter.include?(name.to_s)
end
private
# Recursively format all attributes in a hash, handling nested structures.
#
# @param attributes [Hash] The attributes to format.
# @param skip_classes [Array<Class>, nil] Classes to skip during recursive formatting.
# @param prefix [String, nil] Dot notation prefix for nested attribute names.
# @return [Hash] The formatted attributes hash.
def formated_attributes(attributes, skip_classes: nil, prefix: nil)
formatted = {}
attributes.each do |name, value|
name = name.to_s
value = formatted_attribute_value(name, value, skip_classes: skip_classes, prefix: prefix)
if value.is_a?(RemapAttribute)
formatted.merge!(value.attributes)
else
formatted[name] = value
end
end
formatted
end
# Format a single attribute value using the appropriate formatter.
#
# @param name [String] The attribute name.
# @param value [Object] The attribute value to format.
# @param skip_classes [Array<Class>, nil] Classes to skip during recursive formatting.
# @param prefix [String, nil] Dot notation prefix for nested attribute names.
# @return [Object] The formatted attribute value.
def formatted_attribute_value(name, value, skip_classes: nil, prefix: nil)
prefixed_name = prefix ? "#{prefix}#{name}" : name
using_class_formatter = false
formatter = @attribute_formatter[prefixed_name]
if formatter.nil? && (skip_classes.nil? || !skip_classes.include?(value.class))
formatter = @class_formatter.formatter_for(value.class)
using_class_formatter = true if formatter
end
formatter ||= @default_formatter
formatted_value = begin
if formatter.is_a?(Lumberjack::Formatter)
formatter.format(value)
elsif formatter.respond_to?(:call)
formatter.call(value)
else
value
end
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
if formatted_value.is_a?(MessageAttributes)
formatted_value = formatted_value.attributes
end
if formatted_value.is_a?(Enumerable)
skip_classes ||= []
skip_classes << value.class if using_class_formatter
sub_prefix = "#{prefixed_name}."
formatted_value = if formatted_value.is_a?(Hash)
formated_attributes(formatted_value, skip_classes: skip_classes, prefix: sub_prefix)
else
formatted_value.collect do |item|
formatted_attribute_value(nil, item, skip_classes: skip_classes, prefix: sub_prefix)
end
end
end
formatted_value
end
# Convert symbol formatter references to actual formatter instances.
#
# @param formatter [Symbol, Class, #call] The formatter to dereference.
# @param args [Array] The arguments to pass to the constructor if formatter is a Class.
# @return [#call] The actual formatter instance.
def dereference_formatter(formatter, args)
if formatter.is_a?(Symbol)
FormatterRegistry.formatter(formatter, *args)
else
formatter
end
end
end
end
|