logstruct 0.1.12 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -1
  3. data/README.md +76 -68
  4. data/lib/log_struct/concerns/configuration.rb +139 -91
  5. data/lib/log_struct/concerns/error_handling.rb +36 -6
  6. data/lib/log_struct/config_struct/error_handling_modes.rb +3 -1
  7. data/lib/log_struct/config_struct/filters.rb +116 -33
  8. data/lib/log_struct/config_struct/integrations.rb +0 -4
  9. data/lib/log_struct/config_struct/limits.rb +29 -0
  10. data/lib/log_struct/configuration.rb +2 -0
  11. data/lib/log_struct/enums/event.rb +3 -1
  12. data/lib/log_struct/enums/log_field.rb +5 -0
  13. data/lib/log_struct/formatter.rb +304 -56
  14. data/lib/log_struct/hash_utils.rb +33 -4
  15. data/lib/log_struct/integrations/action_mailer/error_handling.rb +78 -20
  16. data/lib/log_struct/integrations/action_mailer/fallback_error_handlers.rb +81 -0
  17. data/lib/log_struct/integrations/action_mailer/metadata_collection.rb +0 -20
  18. data/lib/log_struct/integrations/action_mailer.rb +22 -17
  19. data/lib/log_struct/integrations/active_job/log_subscriber.rb +177 -25
  20. data/lib/log_struct/integrations/active_job.rb +2 -7
  21. data/lib/log_struct/integrations/active_model_serializers.rb +7 -1
  22. data/lib/log_struct/integrations/active_record.rb +39 -17
  23. data/lib/log_struct/integrations/active_storage.rb +31 -31
  24. data/lib/log_struct/integrations/ahoy.rb +19 -15
  25. data/lib/log_struct/integrations/carrierwave.rb +55 -51
  26. data/lib/log_struct/integrations/dotenv.rb +22 -32
  27. data/lib/log_struct/integrations/good_job/logger.rb +30 -24
  28. data/lib/log_struct/integrations/good_job.rb +111 -40
  29. data/lib/log_struct/integrations/host_authorization.rb +47 -36
  30. data/lib/log_struct/integrations/lograge.rb +80 -4
  31. data/lib/log_struct/integrations/puma.rb +74 -34
  32. data/lib/log_struct/integrations/rack_error_handler/debug_exceptions_logging.rb +35 -0
  33. data/lib/log_struct/integrations/rack_error_handler/error_lines.rb +160 -0
  34. data/lib/log_struct/integrations/rack_error_handler/middleware.rb +14 -169
  35. data/lib/log_struct/integrations/rack_error_handler/parse_error_logging.rb +36 -0
  36. data/lib/log_struct/integrations/rack_error_handler.rb +20 -4
  37. data/lib/log_struct/integrations/rack_middleware.rb +23 -0
  38. data/lib/log_struct/integrations/rails_log_subscribers.rb +32 -0
  39. data/lib/log_struct/integrations/request_context/middleware.rb +4 -9
  40. data/lib/log_struct/integrations/shrine.rb +48 -73
  41. data/lib/log_struct/integrations/sidekiq/job_context_middleware.rb +32 -0
  42. data/lib/log_struct/integrations/sidekiq/logger.rb +17 -9
  43. data/lib/log_struct/integrations/sidekiq.rb +98 -7
  44. data/lib/log_struct/integrations/sorbet.rb +10 -46
  45. data/lib/log_struct/integrations.rb +3 -2
  46. data/lib/log_struct/log/{good_job/enqueue.rb → active_job/discard.rb} +13 -13
  47. data/lib/log_struct/log/active_job/enqueue.rb +0 -4
  48. data/lib/log_struct/log/{good_job/schedule.rb → active_job/error.rb} +15 -15
  49. data/lib/log_struct/log/{good_job/start.rb → active_job/retry.rb} +13 -13
  50. data/lib/log_struct/log/{good_job/finish.rb → active_job/retry_stopped.rb} +13 -17
  51. data/lib/log_struct/log/active_job.rb +4 -0
  52. data/lib/log_struct/log/active_storage/delete.rb +6 -2
  53. data/lib/log_struct/log/active_storage/download.rb +2 -4
  54. data/lib/log_struct/log/active_storage/exist.rb +2 -2
  55. data/lib/log_struct/log/active_storage/metadata.rb +4 -4
  56. data/lib/log_struct/log/active_storage/upload.rb +2 -10
  57. data/lib/log_struct/log/active_storage/url.rb +2 -6
  58. data/lib/log_struct/log/active_storage.rb +2 -3
  59. data/lib/log_struct/log/error.rb +24 -1
  60. data/lib/log_struct/log/good_job.rb +0 -4
  61. data/lib/log_struct/log/puma/error.rb +63 -0
  62. data/lib/log_struct/log/puma.rb +1 -0
  63. data/lib/log_struct/log/security/blocked_host.rb +0 -4
  64. data/lib/log_struct/log/shrine/exist.rb +0 -2
  65. data/lib/log_struct/log/shrine/metadata.rb +2 -4
  66. data/lib/log_struct/log/shrine/upload.rb +4 -0
  67. data/lib/log_struct/log/sidekiq/error.rb +63 -0
  68. data/lib/log_struct/log/{active_storage/stream.rb → sidekiq/log.rb} +17 -11
  69. data/lib/log_struct/log/sidekiq.rb +3 -45
  70. data/lib/log_struct/log.rb +4 -2
  71. data/lib/log_struct/logged_errors.rb +25 -0
  72. data/lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb +2 -3
  73. data/lib/log_struct/param_filters.rb +51 -38
  74. data/lib/log_struct/params_errors.rb +78 -0
  75. data/lib/log_struct/rails_boot_banner_silencer.rb +7 -2
  76. data/lib/log_struct/railtie.rb +29 -14
  77. data/lib/log_struct/semantic_logger/color_formatter.rb +60 -53
  78. data/lib/log_struct/semantic_logger/concerns/log_methods.rb +65 -57
  79. data/lib/log_struct/semantic_logger/formatter.rb +79 -57
  80. data/lib/log_struct/semantic_logger/logger.rb +29 -53
  81. data/lib/log_struct/semantic_logger/reopen_after_fork.rb +40 -0
  82. data/lib/log_struct/semantic_logger/setup.rb +115 -112
  83. data/lib/log_struct/shared/serialize_common.rb +2 -7
  84. data/lib/log_struct/string_scrubber.rb +229 -20
  85. data/lib/log_struct/version.rb +1 -1
  86. data/lib/log_struct.rb +6 -4
  87. data/logstruct.gemspec +19 -10
  88. metadata +63 -11
  89. data/lib/log_struct/builders/active_job.rb +0 -87
  90. data/lib/log_struct/integrations/good_job/log_subscriber.rb +0 -189
  91. data/lib/log_struct/integrations/rack.rb +0 -31
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module LogStruct
5
+ module ConfigStruct
6
+ # How much of each logged value LogStruct writes. The limits keep a huge value (a request body,
7
+ # a large Hash) from slowing down the thread that writes the logs.
8
+ class Limits < T::Struct
9
+ include Sorbet::SerializeSymbolKeys
10
+
11
+ # The most keys written from a Hash. The rest are counted in _more_keys.
12
+ # Default: 100
13
+ prop :max_hash_keys, Integer, default: 100
14
+
15
+ # The most items written from an Array, followed by "... and N more items"
16
+ # Default: 10
17
+ prop :max_array_items, Integer, default: 10
18
+
19
+ # The most lines written from an Array that looks like a backtrace, such as an error line's
20
+ # backtrace. The rest are counted in a last "... and N more lines" item.
21
+ # Default: 20
22
+ prop :max_backtrace_lines, Integer, default: 20
23
+
24
+ # Strings longer than this many bytes are cut, and say how long they were
25
+ # Default: 32768 (32 KB)
26
+ prop :max_string_bytes, Integer, default: 32_768
27
+ end
28
+ end
29
+ end
@@ -5,6 +5,7 @@ require_relative "handlers"
5
5
  require_relative "config_struct/error_handling_modes"
6
6
  require_relative "config_struct/integrations"
7
7
  require_relative "config_struct/filters"
8
+ require_relative "config_struct/limits"
8
9
 
9
10
  module LogStruct
10
11
  # Core configuration class that provides a type-safe API
@@ -35,6 +36,7 @@ module LogStruct
35
36
 
36
37
  const :integrations, ConfigStruct::Integrations, factory: -> { ConfigStruct::Integrations.new }
37
38
  const :filters, ConfigStruct::Filters, factory: -> { ConfigStruct::Filters.new }
39
+ const :limits, ConfigStruct::Limits, factory: -> { ConfigStruct::Limits.new }
38
40
 
39
41
  # Custom log scrubbing handler for any additional string scrubbing
40
42
  # Default: nil
@@ -16,6 +16,9 @@ module LogStruct
16
16
  Schedule = new(:schedule)
17
17
  Start = new(:start)
18
18
  Finish = new(:finish)
19
+ Retry = new(:retry)
20
+ RetryStopped = new(:retry_stopped)
21
+ Discard = new(:discard)
19
22
 
20
23
  # File storage events (ActiveStorage, Shrine, CarrierWave, etc.)
21
24
  Upload = new(:upload)
@@ -23,7 +26,6 @@ module LogStruct
23
26
  Delete = new(:delete)
24
27
  Metadata = new(:metadata)
25
28
  Exist = new(:exist)
26
- Stream = new(:stream)
27
29
  Url = new(:url)
28
30
 
29
31
  # Data generation events
@@ -91,6 +91,11 @@ module LogStruct
91
91
  Priority = new(:priority)
92
92
  CronKey = new(:cron_key)
93
93
  ErrorMessage = new(:error_message)
94
+ # What the integration was doing when the error happened, e.g. Sidekiq's "Job raised exception"
95
+ ErrorContext = new(:error_context)
96
+ # The cause of an error the app didn't handle in a request (Exception#cause)
97
+ CauseClass = new(:cause_class)
98
+ CauseMessage = new(:cause_message)
94
99
  Result = new(:result)
95
100
  EnqueueCaller = new(:enqueue_caller)
96
101
 
@@ -3,10 +3,12 @@
3
3
 
4
4
  require "logger"
5
5
  require "active_support/core_ext/object/blank"
6
+ require "active_support/hash_with_indifferent_access"
6
7
  require "json"
7
8
  require "globalid"
8
9
  require_relative "enums/source"
9
10
  require_relative "enums/event"
11
+ require_relative "enums/log_field"
10
12
  require_relative "string_scrubber"
11
13
  require_relative "log"
12
14
  require_relative "param_filters"
@@ -49,43 +51,60 @@ module LogStruct
49
51
  StringScrubber.scrub(string)
50
52
  end
51
53
 
52
- sig { params(arg: T.untyped, recursion_depth: Integer).returns(T.untyped) }
53
- def process_values(arg, recursion_depth: 0)
54
- # Prevent infinite recursion in case any args have circular references
55
- # or are too deeply nested. Just return args.
56
- return arg if recursion_depth > 20
54
+ MAX_RECURSION_DEPTH = 20
55
+
56
+ # The mask Rails' parameter filter puts in place of filtered values
57
+ RAILS_FILTERED_MASK = "[FILTERED]"
58
+
59
+ # LogStruct's own fields, which some filters don't apply to at the top of a line
60
+ LOG_FIELD_KEYS = T.let(LogField.values.map(&:serialize).to_set.freeze, T::Set[T.untyped])
61
+
62
+ # path and root are for filters that match a key by its path (see ParamFilters.should_filter_key?).
63
+ # They're only tracked when such filters exist: path is nil otherwise.
64
+ sig do
65
+ params(
66
+ arg: T.untyped,
67
+ recursion_depth: Integer,
68
+ ancestors: T::Hash[T.untyped, T::Boolean],
69
+ path: T.nilable(T::Array[String]),
70
+ root: T.untyped
71
+ ).returns(T.untyped)
72
+ end
73
+ def process_values(arg, recursion_depth: 0, ancestors: {}.compare_by_identity, path: nil, root: nil)
74
+ if recursion_depth == 0 && path.nil? && !LogStruct.config.filters.path_matchers.empty?
75
+ path = []
76
+ end
77
+
78
+ # Circular references and deeply nested values stop here. Values below this depth
79
+ # would skip filtering and scrubbing, so they're summarized instead of logged.
80
+ return summarize_unprocessed_value(arg) if recursion_depth > MAX_RECURSION_DEPTH || ancestors.key?(arg)
57
81
 
58
82
  case arg
59
- when Hash
60
- result = {}
61
-
62
- # Process each key-value pair
63
- arg.each do |key, value|
64
- # Check if this key should be filtered at any depth
65
- result[key] = if ParamFilters.should_filter_key?(key, value)
66
- # Filter the value
67
- {_filtered: ParamFilters.summarize_json_attribute(key, value)}
83
+ when Hash, Array
84
+ ancestors[arg] = true
85
+ begin
86
+ if arg.is_a?(Hash)
87
+ process_hash(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
68
88
  else
69
- # Process the value normally
70
- process_values(value, recursion_depth: recursion_depth + 1)
89
+ process_array(arg, recursion_depth: recursion_depth, ancestors: ancestors, path: path, root: root)
71
90
  end
91
+ ensure
92
+ ancestors.delete(arg)
72
93
  end
73
-
74
- result
75
- when Array
76
- process_array(arg, recursion_depth: recursion_depth)
77
94
  when GlobalID::Identification
95
+ # Scrubbed like any String: a record's ID can be an email address
78
96
  begin
79
- arg.to_global_id
97
+ scrub_string(arg.to_global_id.to_s)
80
98
  rescue
81
99
  begin
82
- case arg
83
- when ActiveRecord::Base
84
- "#{arg.class}(##{arg.id})"
100
+ object = T.cast(arg, Object)
101
+ # Active Record is optional
102
+ if defined?(::ActiveRecord::Base) && object.is_a?(::ActiveRecord::Base)
103
+ scrub_string("#{object.class}(##{object.id})")
85
104
  else
86
105
  # For non-ActiveRecord objects that failed to_global_id, try to get a string representation
87
106
  # If this also fails, we want to catch it and return the error placeholder
88
- String(T.cast(arg, Object))
107
+ scrub_string(String(object))
89
108
  end
90
109
  rescue => e
91
110
  LogStruct.handle_exception(e, source: Source::Internal)
@@ -95,14 +114,24 @@ module LogStruct
95
114
  when Source, Event
96
115
  arg.serialize
97
116
  when String
98
- scrub_string(arg)
117
+ scrub_limited_string(valid_utf8(arg))
99
118
  when Time
100
119
  arg.iso8601(3)
101
- else
102
- # Any other type (e.g. Symbol, Integer, Float, Boolean etc.)
120
+ when Symbol
121
+ StringScrubber.scrub_name(arg)
122
+ when Float
123
+ # JSON has no NaN or Infinity
124
+ arg.finite? ? arg : arg.to_s
125
+ when Numeric, TrueClass, FalseClass, NilClass
103
126
  arg
127
+ else
128
+ # Structs, ActionController::Parameters and other objects: filter and scrub what
129
+ # to_json would write
130
+ process_values(arg.as_json, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: path, root: root)
104
131
  end
105
- rescue => e
132
+ rescue SystemStackError, StandardError => e
133
+ # A SystemStackError comes from an object whose as_json refers back to itself
134
+ e = RuntimeError.new("#{e.class}: #{e.message}") unless e.is_a?(StandardError)
106
135
  # Report error through LogStruct's framework
107
136
  context = {
108
137
  processor_method: "process_values",
@@ -110,7 +139,141 @@ module LogStruct
110
139
  recursion_depth: recursion_depth
111
140
  }
112
141
  LogStruct.handle_exception(e, source: Source::Internal, context: context)
113
- arg
142
+ summarize_unprocessed_value(arg)
143
+ end
144
+
145
+ sig do
146
+ params(
147
+ hash: T::Hash[T.untyped, T.untyped],
148
+ recursion_depth: Integer,
149
+ ancestors: T::Hash[T.untyped, T::Boolean],
150
+ path: T.nilable(T::Array[String]),
151
+ root: T.untyped
152
+ ).returns(T::Hash[T.untyped, T.untyped])
153
+ end
154
+ def process_hash(hash, recursion_depth:, ancestors:, path: nil, root: nil)
155
+ result = {}
156
+
157
+ # Path matchers' roots are built only if a matcher reads them (most don't)
158
+ line_root = (path && recursion_depth == 0) ? ConfigStruct::LazyRoot.new(hash) : nil
159
+
160
+ max_keys = LogStruct.config.limits.max_hash_keys
161
+ hash.each_with_index do |(key, value), index|
162
+ if index >= max_keys
163
+ result[:_more_keys] = hash.size - max_keys
164
+ break
165
+ end
166
+
167
+ key = valid_utf8_key(key)
168
+ logstruct_field = recursion_depth == 0 && LOG_FIELD_KEYS.include?(key)
169
+ key_path = path && [*path, key.to_s]
170
+ # A top-level key's root is the line; the keys below it get the top-level field's value
171
+ key_root = (recursion_depth == 0) ? line_root : root
172
+ children_root = (path && recursion_depth == 0) ? ConfigStruct::LazyRoot.new(value) : root
173
+ # Check if this key should be filtered at any depth
174
+ filtered = ParamFilters.should_filter_key?(key, value, logstruct_field: logstruct_field, path: key_path, root: key_root)
175
+ # Keys are scrubbed too: an email address can be a key
176
+ result[StringScrubber.scrub_name(key)] = if !filtered
177
+ process_values(value, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: key_path, root: children_root)
178
+ elsif value == RAILS_FILTERED_MASK
179
+ # Rails' parameter filter has already replaced the value
180
+ value
181
+ else
182
+ {_filtered: ParamFilters.summarize_json_attribute(key, value)}
183
+ end
184
+ end
185
+
186
+ result
187
+ end
188
+
189
+ # The string as valid UTF-8. JSON can't hold other bytes: Puma passes headers as binary
190
+ # strings, and a byte like \xFF in a User-Agent would stop the whole line from being written.
191
+ # Bytes that aren't valid UTF-8 become U+FFFD.
192
+ sig { params(string: String).returns(String) }
193
+ def valid_utf8(string)
194
+ StringScrubber.valid_utf8(string)
195
+ end
196
+
197
+ # A String or Symbol key as valid UTF-8 (symbolize_keys makes Symbols of binary String keys)
198
+ sig { params(key: T.untyped).returns(T.untyped) }
199
+ def valid_utf8_key(key)
200
+ case key
201
+ when String
202
+ valid_utf8(key)
203
+ when Symbol
204
+ name = key.name
205
+ fixed = valid_utf8(name)
206
+ fixed.equal?(name) ? key : fixed.to_sym
207
+ else
208
+ key
209
+ end
210
+ end
211
+
212
+ # The scrubbers see a little more than a cut string keeps, so data at the cut is scrubbed whole
213
+ SCRUB_MARGIN_BYTES = 256
214
+
215
+ # A string scrubbed, and cut to limits.max_string_bytes. The scrubbers see its first max_string_bytes plus
216
+ # SCRUB_MARGIN_BYTES: a value running past that was only partly seen, so scrub_cut_end replaces it if it can
217
+ # be a credential.
218
+ sig { params(string: String).returns(String) }
219
+ def scrub_limited_string(string)
220
+ max_bytes = LogStruct.config.limits.max_string_bytes
221
+ return scrub_string(string) if string.bytesize <= max_bytes
222
+
223
+ seen_bytes = max_bytes + SCRUB_MARGIN_BYTES
224
+ scrubbed = scrub_string(string.byteslice(0, seen_bytes).to_s.scrub(""))
225
+ if string.bytesize > seen_bytes
226
+ scrubbed = StringScrubber.scrub_cut_end(scrubbed)
227
+ elsif scrubbed.bytesize <= max_bytes
228
+ # The scrubbers saw all of it, and their replacements made it short enough
229
+ return scrubbed
230
+ end
231
+ kept = scrubbed.byteslice(0, max_bytes).to_s.scrub("")
232
+ "#{kept}... (cut from #{string.bytesize} bytes)"
233
+ end
234
+
235
+ # Like deep_symbolize_keys, but stops at circular references and at the depth where
236
+ # process_values stops, leaving those values for process_values to summarize
237
+ sig { params(value: T.untyped, recursion_depth: Integer, ancestors: T::Hash[T.untyped, T::Boolean]).returns(T.untyped) }
238
+ def symbolize_keys(value, recursion_depth: 0, ancestors: {}.compare_by_identity)
239
+ return value if recursion_depth > MAX_RECURSION_DEPTH || ancestors.key?(value)
240
+
241
+ case value
242
+ when Hash
243
+ ancestors[value] = true
244
+ result = value.each_with_object({}) do |(key, nested_value), symbolized|
245
+ symbol_key = key.respond_to?(:to_sym) ? key.to_sym : key
246
+ symbolized[symbol_key] = symbolize_keys(nested_value, recursion_depth: recursion_depth + 1, ancestors: ancestors)
247
+ end
248
+ ancestors.delete(value)
249
+ result
250
+ when Array
251
+ ancestors[value] = true
252
+ result = value.map { |item| symbolize_keys(item, recursion_depth: recursion_depth + 1, ancestors: ancestors) }
253
+ ancestors.delete(value)
254
+ result
255
+ else
256
+ value
257
+ end
258
+ end
259
+
260
+ # Describes a value without filtering, scrubbing or serializing it, so that values which
261
+ # couldn't be processed are never logged raw. Hashes and arrays are not serialized because
262
+ # they may contain circular references.
263
+ sig { params(arg: T.untyped).returns(T.untyped) }
264
+ def summarize_unprocessed_value(arg)
265
+ case arg
266
+ when Hash
267
+ {_filtered: {_class: arg.class.name, _keys_count: arg.size}}
268
+ when Array
269
+ {_filtered: {_class: arg.class.name, _count: arg.size}}
270
+ when String
271
+ {_filtered: {_class: arg.class.name}}
272
+ when Numeric, Symbol, TrueClass, FalseClass, NilClass
273
+ arg
274
+ else
275
+ {_filtered: {_class: arg.class.name}}
276
+ end
114
277
  end
115
278
 
116
279
  sig { params(log_value: T.untyped, time: Time).returns(T::Hash[Symbol, T.untyped]) }
@@ -122,11 +285,15 @@ module LogStruct
122
285
 
123
286
  when T::Struct
124
287
  # Default T::Struct.serialize methods returns a hash with string keys, so convert them to symbols
125
- log_value.serialize.deep_symbolize_keys
288
+ symbolize_keys(log_value.serialize)
126
289
 
127
290
  when Hash
128
291
  # Use hash as is and convert string keys to symbols
129
- log_value.dup.deep_symbolize_keys
292
+ symbolize_keys(log_value)
293
+
294
+ when Exception
295
+ # logger.error(exception): its class, message and backtrace
296
+ Log.from_exception(Source::App, log_value, {}, time).serialize
130
297
 
131
298
  else
132
299
  # Create a Plain log with the message as a string and serialize it with symbol keys
@@ -174,9 +341,22 @@ module LogStruct
174
341
  end
175
342
  end
176
343
 
177
- # Serializes Log (or string) into JSON
178
- sig { params(severity: T.any(String, Symbol, Integer), time: Time, progname: T.nilable(String), log_value: T.untyped).returns(String) }
179
- def call(severity, time, progname, log_value)
344
+ # Serializes Log (or string) into JSON.
345
+ # request_id: the request's ID (default: the one RequestContext stored for this thread).
346
+ # context: fields for the line's ctx, like the running job's ID and class. The line's own ctx keys win.
347
+ # tags: the logger's tags (logger.tagged("billing")), written as tags unless the line has its own.
348
+ sig do
349
+ params(
350
+ severity: T.any(String, Symbol, Integer),
351
+ time: Time,
352
+ progname: T.nilable(String),
353
+ log_value: T.untyped,
354
+ request_id: T.nilable(String),
355
+ context: T.nilable(T::Hash[T.untyped, T.untyped]),
356
+ tags: T.nilable(T::Array[T.untyped])
357
+ ).returns(String)
358
+ end
359
+ def call(severity, time, progname, log_value, request_id: nil, context: nil, tags: nil)
180
360
  level_enum = Level.from_severity(severity)
181
361
 
182
362
  data = log_value_to_hash(log_value, time: time)
@@ -189,13 +369,77 @@ module LogStruct
189
369
  data[:evt] ||= Event::Log
190
370
  data[:ts] ||= time.iso8601(3)
191
371
  data[:lvl] = level_enum # Set level from severity parameter
192
- data[:prog] = progname if progname.present?
193
- request_id = Thread.current[:logstruct_request_id]
372
+ add_metadata(data, progname: progname, request_id: request_id || Thread.current[:logstruct_request_id], context: context, tags: tags)
373
+
374
+ begin
375
+ generate_json(data)
376
+ rescue SystemStackError, StandardError => e
377
+ unwritable_line_json(data, e)
378
+ end
379
+ end
380
+
381
+ # A line that can't be written as JSON is replaced by one that says so, instead of being lost
382
+ sig { params(data: T::Hash[T.untyped, T.untyped], error: Exception).returns(String) }
383
+ def unwritable_line_json(data, error)
384
+ level = data[:lvl]
385
+ line = {
386
+ src: enum_or_string(data[:src]) || Source::Internal.serialize,
387
+ evt: enum_or_string(data[:evt]) || Event::Error.serialize,
388
+ ts: data[:ts].is_a?(String) ? data[:ts] : Time.now.iso8601(3),
389
+ lvl: level.is_a?(Level) ? level.serialize : Level::Error.serialize,
390
+ msg: "LogStruct couldn't write this line as JSON",
391
+ error_class: error.class.name
392
+ }
393
+ unless Thread.current[:logstruct_reporting_unwritable_line]
394
+ begin
395
+ Thread.current[:logstruct_reporting_unwritable_line] = true
396
+ reported = error.is_a?(StandardError) ? error : RuntimeError.new("#{error.class}: #{error.message}")
397
+ LogStruct.handle_exception(reported, source: Source::Internal, context: {src: line[:src], evt: line[:evt]})
398
+ ensure
399
+ Thread.current[:logstruct_reporting_unwritable_line] = nil
400
+ end
401
+ end
402
+ "#{::JSON.generate(line)}\n"
403
+ end
404
+
405
+ # The program name, request ID and job context, filtered and scrubbed like the rest of the line:
406
+ # an X-Request-Id header can carry anything
407
+ sig do
408
+ params(
409
+ data: T::Hash[T.untyped, T.untyped],
410
+ progname: T.nilable(String),
411
+ request_id: T.untyped,
412
+ context: T.nilable(T::Hash[T.untyped, T.untyped]),
413
+ tags: T.nilable(T::Array[T.untyped])
414
+ ).void
415
+ end
416
+ def add_metadata(data, progname:, request_id:, context:, tags:)
417
+ metadata = {}
418
+ metadata[:prog] = progname if progname.present?
419
+ metadata[:tags] = tags if tags.is_a?(Array) && !tags.empty? && !data.key?(:tags)
194
420
  if request_id.is_a?(String) && !request_id.empty? && !data.key?(:req_id)
195
- data[:req_id] = request_id
421
+ metadata[:req_id] = request_id
196
422
  end
423
+ metadata[:ctx] = symbolize_keys(context) if context.present?
424
+ return if metadata.empty?
425
+
426
+ processed = process_values(metadata)
427
+ job_context = processed.delete(:ctx)
428
+ data.merge!(processed)
429
+ return if job_context.nil?
430
+
431
+ line_context = data[:ctx]
432
+ # A logged Hash's own ctx keeps its keys and gains the job's
433
+ data[:ctx] = line_context.is_a?(Hash) ? job_context.merge(line_context) : (line_context || job_context)
434
+ end
197
435
 
198
- generate_json(data)
436
+ sig { params(value: T.untyped).returns(T.nilable(String)) }
437
+ def enum_or_string(value)
438
+ if value.is_a?(Source) || value.is_a?(Event)
439
+ value.serialize.to_s
440
+ elsif value.is_a?(String)
441
+ value
442
+ end
199
443
  end
200
444
 
201
445
  # Output as JSON with a newline. We mock this method in tests so we can
@@ -205,26 +449,30 @@ module LogStruct
205
449
  "#{data.to_json}\n"
206
450
  end
207
451
 
208
- sig { params(array: T::Array[T.untyped], recursion_depth: Integer).returns(T::Array[T.untyped]) }
209
- def process_array(array, recursion_depth:)
452
+ # An array's items keep the array's path, as in Rails (users.name filters each user's name)
453
+ sig do
454
+ params(
455
+ array: T::Array[T.untyped],
456
+ recursion_depth: Integer,
457
+ ancestors: T::Hash[T.untyped, T::Boolean],
458
+ path: T.nilable(T::Array[String]),
459
+ root: T.untyped
460
+ ).returns(T::Array[T.untyped])
461
+ end
462
+ def process_array(array, recursion_depth:, ancestors:, path: nil, root: nil)
210
463
  return [] if array.empty?
211
464
 
212
- if looks_like_backtrace_array?(array)
213
- array.map { |value| process_values(value, recursion_depth: recursion_depth + 1) }
214
- else
215
- processed = []
216
- array.each_with_index do |value, index|
217
- break if index >= 10
218
-
219
- processed << process_values(value, recursion_depth: recursion_depth + 1)
220
- end
221
-
222
- if array.size > 10
223
- processed << "... and #{array.size - 10} more items"
224
- end
225
-
226
- processed
465
+ limits = LogStruct.config.limits
466
+ backtrace = looks_like_backtrace_array?(array)
467
+ max_items = backtrace ? limits.max_backtrace_lines : limits.max_array_items
468
+ processed = array.first(max_items).map do |value|
469
+ process_values(value, recursion_depth: recursion_depth + 1, ancestors: ancestors, path: path, root: root)
227
470
  end
471
+ if array.size > max_items
472
+ processed << "... and #{array.size - max_items} more #{backtrace ? "lines" : "items"}"
473
+ end
474
+
475
+ processed
228
476
  end
229
477
 
230
478
  # Check if an array looks like a backtrace (array of strings with file:line pattern)
@@ -1,20 +1,49 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
- require "digest"
4
+ require "openssl"
5
5
 
6
6
  module LogStruct
7
7
  # Utility module for hashing sensitive data
8
8
  module HashUtils
9
+ # The key when there's neither a configured hash_salt nor a Rails app with a secret_key_base.
10
+ # It's public, so these hashes can be matched to a list of candidate values.
11
+ FALLBACK_KEY = "l0g5t0p"
12
+
9
13
  class << self
10
14
  extend T::Sig
11
15
 
12
- # Create a hash of a string value for tracing while preserving privacy
16
+ # An HMAC-SHA256 of the value, so lines about the same email can be traced without logging it
13
17
  sig { params(value: String).returns(String) }
14
18
  def hash_value(value)
15
- salt = LogStruct.config.filters.hash_salt
16
19
  length = LogStruct.config.filters.hash_length
17
- Digest::SHA256.hexdigest("#{salt}#{value}")[0...length] || "error"
20
+ OpenSSL::HMAC.hexdigest("SHA256", hash_key, value)[0...length] || "error"
21
+ end
22
+
23
+ # The configured hash_salt, or a key derived from the app's secret_key_base
24
+ sig { returns(String) }
25
+ def hash_key
26
+ LogStruct.config.filters.hash_salt || derived_key || FALLBACK_KEY
27
+ end
28
+
29
+ # Forget the derived key (for tests that change the Rails app)
30
+ sig { void }
31
+ def reset!
32
+ @derived_key = T.let(nil, T.nilable(String))
33
+ end
34
+
35
+ private
36
+
37
+ sig { returns(T.nilable(String)) }
38
+ def derived_key
39
+ @derived_key ||= T.let(begin
40
+ application = ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
41
+ # Rails derives keys like this one for cookies and messages; nil until the app has its secret
42
+ application&.key_generator&.generate_key("LogStruct hashed values", 32)
43
+ rescue
44
+ nil
45
+ end,
46
+ T.nilable(String))
18
47
  end
19
48
  end
20
49
  end