Class: LogStruct::Formatter

Inherits:
Logger::Formatter
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/log_struct/formatter.rb

Instance Method Summary collapse

Instance Method Details

#call(severity, time, progname, log_value) ⇒ String

Serializes Log (or string) into JSON

Parameters:

  • severity (String, Symbol, Integer)
  • time (Time)
  • progname (String, nil)
  • log_value (T.untyped)

Returns:

  • (String)


185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/log_struct/formatter.rb', line 185

def call(severity, time, progname, log_value)
  level_enum = Level.from_severity(severity)

  data = log_value_to_hash(log_value, time: time)

  # Filter params, scrub sensitive values, format ActiveJob GlobalID arguments
  data = process_values(data)

  # Add standard fields if not already present
  data[:src] ||= Source::App
  data[:evt] ||= Event::Log
  data[:ts] ||= time.iso8601(3)
  data[:lvl] = level_enum # Set level from severity parameter
  data[:prog] = progname if progname.present?

  generate_json(data)
end

#clear_tags!void

This method returns an undefined value.

Add clear_tags! method to support ActiveSupport::TaggedLogging



37
38
39
# File 'lib/log_struct/formatter.rb', line 37

def clear_tags!
  Thread.current[:activesupport_tagged_logging_tags] = []
end

#current_tagsArray<String>

Add current_tags method to support ActiveSupport::TaggedLogging

Returns:

  • (Array<String>)


21
22
23
# File 'lib/log_struct/formatter.rb', line 21

def current_tags
  Thread.current[:activesupport_tagged_logging_tags] ||= []
end

#generate_json(data) ⇒ String

Output as JSON with a newline. We mock this method in tests so we can inspect the data right before it gets turned into a JSON string.

Parameters:

  • data (Hash{T.untyped => T.untyped})

Returns:

  • (String)


206
207
208
# File 'lib/log_struct/formatter.rb', line 206

def generate_json(data)
  "#{data.to_json}\n"
end

#log_value_to_hash(log_value, time:) ⇒ Hash{Symbol => T.untyped}

Parameters:

  • log_value (T.untyped)
  • time (Time)

Returns:

  • (Hash{Symbol => T.untyped})


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
# File 'lib/log_struct/formatter.rb', line 123

def log_value_to_hash(log_value, time:)
  case log_value
  when Log::Interfaces::CommonFields
    # Our log classes all implement a custom #serialize method that use symbol keys
    log_value.serialize

  when T::Struct
    # Default T::Struct.serialize methods returns a hash with string keys, so convert them to symbols
    log_value.serialize.deep_symbolize_keys

  when Hash
    # Use hash as is and convert string keys to symbols
    log_value.dup.deep_symbolize_keys

  else
    # Create a Plain log with the message as a string and serialize it with symbol keys
    # log_value can be literally anything: Integer, Float, Boolean, NilClass, etc.
    log_message = case log_value
    # Handle all the basic types without any further processing
    when String, Symbol, TrueClass, FalseClass, NilClass, Array, Hash, Time, Numeric
      log_value
    else
      # Handle the serialization of complex objects in a useful way:
      #
      # 1. For ActiveRecord models: Use as_json which includes attributes
      # 2. For objects with custom as_json implementations: Use their implementation
      # 3. For basic objects that only have ActiveSupport's as_json: Use to_s
      begin
        method_owner = log_value.method(:as_json).owner

        # If it's ActiveRecord, ActiveModel, or a custom implementation, use as_json
        if method_owner.to_s.include?("ActiveRecord") ||
            method_owner.to_s.include?("ActiveModel") ||
            method_owner.to_s.exclude?("ActiveSupport::CoreExtensions") &&
                method_owner.to_s.exclude?("Object")
          log_value.as_json
        else
          # For plain objects with only the default ActiveSupport as_json
          log_value.to_s
        end
      rescue => e
        # Handle serialization errors
        context = {
          object_class: log_value.class.name,
          object_inspect: log_value.inspect.truncate(100)
        }
        LogStruct.handle_exception(e, source: Source::LogStruct, context: context)

        # Fall back to the string representation to ensure we continue processing
        log_value.to_s
      end
    end

    Log::Plain.new(
      message: log_message,
      timestamp: time
    ).serialize
  end
end

#looks_like_backtrace?(array) ⇒ Boolean

Check if an array looks like a backtrace (array of strings with file:line pattern)

Parameters:

  • array (Array<T.untyped>)

Returns:

  • (Boolean)


212
213
214
215
216
217
218
219
220
221
222
# File 'lib/log_struct/formatter.rb', line 212

def looks_like_backtrace?(array)
  return false if array.empty?

  # Check if most elements look like backtrace lines (file.rb:123 or similar patterns)
  backtrace_like_count = array.first(5).count do |element|
    element.is_a?(String) && element.match?(/\A[^:\s]+:\d+/)
  end

  # If at least 3 out of the first 5 elements look like backtrace lines, treat as backtrace
  backtrace_like_count >= 3
end

#process_values(arg, recursion_depth: 0) ⇒ T.untyped

Parameters:

  • arg (T.untyped)
  • recursion_depth (Integer) (defaults to: 0)

Returns:

  • (T.untyped)


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
# File 'lib/log_struct/formatter.rb', line 53

def process_values(arg, recursion_depth: 0)
  # Prevent infinite recursion in case any args have circular references
  # or are too deeply nested. Just return args.
  return arg if recursion_depth > 20

  case arg
  when Hash
    result = {}

    # Process each key-value pair
    arg.each do |key, value|
      # Check if this key should be filtered at any depth
      result[key] = if ParamFilters.should_filter_key?(key)
        # Filter the value
        {_filtered: ParamFilters.summarize_json_attribute(key, value)}
      else
        # Process the value normally
        process_values(value, recursion_depth: recursion_depth + 1)
      end
    end

    result
  when Array
    result = arg.map { |value| process_values(value, recursion_depth: recursion_depth + 1) }

    # Filter large arrays, but don't truncate backtraces (arrays of strings that look like file:line)
    if result.size > 10 && !looks_like_backtrace?(result)
      result = result.take(10) + ["... and #{result.size - 10} more items"]
    end
    result
  when GlobalID::Identification
    begin
      arg.to_global_id
    rescue
      begin
        case arg
        when ActiveRecord::Base
          "#{arg.class}(##{arg.id})"
        else
          # For non-ActiveRecord objects that failed to_global_id, try to get a string representation
          # If this also fails, we want to catch it and return the error placeholder
          T.unsafe(arg).to_s
        end
      rescue => e
        LogStruct.handle_exception(e, source: Source::LogStruct)
        "[GLOBALID_ERROR]"
      end
    end
  when Source, Event
    arg.serialize
  when String
    scrub_string(arg)
  when Time
    arg.iso8601(3)
  else
    # Any other type (e.g. Symbol, Integer, Float, Boolean etc.)
    arg
  end
rescue => e
  # Report error through LogStruct's framework
  context = {
    processor_method: "process_values",
    value_type: arg.class.name,
    recursion_depth: recursion_depth
  }
  LogStruct.handle_exception(e, source: Source::LogStruct, context: context)
  arg
end

#push_tags(*tags) ⇒ T.untyped

Parameters:

  • tags (Array<String>)

Returns:

  • (T.untyped)


42
43
44
# File 'lib/log_struct/formatter.rb', line 42

def push_tags(*tags)
  current_tags.concat(tags)
end

#scrub_string(string) ⇒ String

Parameters:

  • string (String)

Returns:

  • (String)


47
48
49
50
# File 'lib/log_struct/formatter.rb', line 47

def scrub_string(string)
  # Use StringScrubber module to scrub sensitive information from strings
  StringScrubber.scrub(string)
end

#tagged(*tags, &blk) ⇒ T.untyped

Add tagged method to support ActiveSupport::TaggedLogging

Parameters:

  • tags (Array<String>)
  • blk (T.proc.params(formatter: Formatter).void)

Returns:

  • (T.untyped)


27
28
29
30
31
32
33
# File 'lib/log_struct/formatter.rb', line 27

def tagged(*tags, &blk)
  new_tags = tags.flatten
  current_tags.concat(new_tags) if new_tags.any?
  yield self
ensure
  current_tags.pop(new_tags.size) if new_tags&.any?
end