rails-pretty-logger 0.2.9 → 0.3.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +215 -35
  3. data/app/assets/javascripts/rails/pretty/logger/application.js +16 -23
  4. data/app/assets/stylesheets/rails/pretty/logger/application.css +1 -15
  5. data/app/assets/stylesheets/rails/pretty/logger/dashboards.css +463 -141
  6. data/app/assets/stylesheets/rails/pretty/logger/list.css +1 -94
  7. data/app/controllers/rails/pretty/logger/application_controller.rb +31 -0
  8. data/app/controllers/rails/pretty/logger/dashboards_controller.rb +9 -4
  9. data/app/controllers/rails/pretty/logger/hourly_logs_controller.rb +36 -4
  10. data/app/helpers/rails/pretty/logger/application_helper.rb +32 -0
  11. data/app/helpers/rails/pretty/logger/dashboards_helper.rb +114 -14
  12. data/app/views/layouts/rails/pretty/logger/application.html.erb +9 -8
  13. data/app/views/partials/_error_pagination.html.erb +12 -10
  14. data/app/views/partials/_log_entries.html.erb +5 -0
  15. data/app/views/partials/_log_filters.html.erb +14 -0
  16. data/app/views/partials/_pretyyloggernavbar.html.erb +14 -9
  17. data/app/views/rails/pretty/logger/dashboards/index.html.erb +37 -20
  18. data/app/views/rails/pretty/logger/dashboards/logs.html.erb +33 -14
  19. data/app/views/rails/pretty/logger/hourly_logs/index.html.erb +49 -25
  20. data/app/views/rails/pretty/logger/hourly_logs/logs.html.erb +35 -14
  21. data/config/locales/rails_pretty_logger.en.yml +35 -0
  22. data/config/locales/rails_pretty_logger.tr.yml +35 -0
  23. data/lib/generators/rails_pretty_logger/install/install_generator.rb +29 -0
  24. data/lib/generators/rails_pretty_logger/install/templates/rails_pretty_logger.rb +20 -0
  25. data/lib/rails/pretty/logger/active_support_logger.rb +3 -7
  26. data/lib/rails/pretty/logger/config/logger_config.rb +0 -16
  27. data/lib/rails/pretty/logger/configuration.rb +18 -0
  28. data/lib/rails/pretty/logger/console_logger.rb +2 -2
  29. data/lib/rails/pretty/logger/engine.rb +22 -4
  30. data/lib/rails/pretty/logger/rails_logger.rb +62 -41
  31. data/lib/rails/pretty/logger/version.rb +1 -1
  32. data/lib/rails/pretty/logger.rb +547 -31
  33. data/lib/tasks/rails/pretty/logger_tasks.rake +36 -23
  34. metadata +79 -38
  35. data/Rakefile +0 -22
  36. data/app/assets/javascripts/rails/pretty/logger/dashboards.js +0 -2
  37. data/app/assets/javascripts/rails/pretty/logger/list.min.js +0 -2
  38. data/app/models/rails/pretty/logger/application_record.rb +0 -9
  39. data/app/assets/config/{rails_pretty_logger_manifest.js → manifest.js} +1 -1
@@ -1,12 +1,39 @@
1
+ require "active_support/core_ext/object/blank"
2
+ require "active_support/core_ext/string/conversions"
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require "rails/pretty/logger/configuration"
1
8
  require "rails/pretty/logger/engine"
2
9
 
3
10
  module Rails::Pretty::Logger
11
+ def self.configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def self.configure
16
+ yield configuration
17
+ end
18
+
19
+ def self.reset_configuration!
20
+ @configuration = Configuration.new
21
+ end
4
22
 
5
23
  class PrettyLogger
24
+ class InvalidLogFile < StandardError; end
25
+ class FileTooLarge < StandardError; end
26
+ SEVERITIES = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
27
+ LINE_INDEX_CACHE_LIMIT = 32
28
+ TAIL_READ_CHUNK_SIZE = 64 * 1024
29
+ STRUCTURED_TIMESTAMP_KEYS = %w[@timestamp timestamp time datetime created_at].freeze
30
+ STRUCTURED_SEVERITY_KEYS = %w[severity level log_level].freeze
31
+
32
+ attr_reader :log_file
6
33
 
7
34
  def initialize(params)
8
- @log_file = params[:log_file]
9
35
  @filter_params = params
36
+ @log_file = self.class.resolve_log_file(params[:log_file])
10
37
  end
11
38
 
12
39
  def self.logger
@@ -21,14 +48,43 @@ module Rails::Pretty::Logger
21
48
  File.size?("#{log_file}").to_f / 2**20
22
49
  end
23
50
 
51
+ def self.log_root
52
+ Rails.root.join("log")
53
+ end
54
+
55
+ def self.resolve_log_file(log_file)
56
+ raise InvalidLogFile if log_file.blank?
57
+
58
+ candidate = Pathname.new(log_file.to_s)
59
+ candidate = log_root.join(candidate) unless candidate.absolute?
60
+
61
+ root_path = real_log_root
62
+ real_path = candidate.realpath
63
+
64
+ unless real_path.to_s == root_path.to_s || real_path.to_s.start_with?("#{root_path}/")
65
+ raise InvalidLogFile
66
+ end
67
+
68
+ raise InvalidLogFile unless real_path.file?
69
+
70
+ real_path.to_s
71
+ rescue Errno::ENOENT, Errno::EACCES, ArgumentError
72
+ raise InvalidLogFile
73
+ end
74
+
75
+ def self.real_log_root
76
+ FileUtils.mkdir_p(log_root)
77
+ log_root.realpath
78
+ end
79
+
24
80
  def self.get_log_file_list
25
- log_files = Dir["#{File.join(Rails.root, 'log')}" + "/**.*"]
26
- self.logs_atr(log_files)
81
+ log_files = Dir[File.join(log_root, "*")].select { |file| File.file?(file) }
82
+ logs_atr(log_files)
27
83
  end
28
84
 
29
85
  def self.get_hourly_log_file_list
30
- log_files = Dir["#{Rails.root}/log/hourly/**/*.*"].sort
31
- self.logs_atr(log_files)
86
+ log_files = Dir[File.join(log_root, "hourly", "**", "*")].select { |file| File.file?(file) }.sort
87
+ logs_atr(log_files)
32
88
  end
33
89
 
34
90
  def self.logs_atr(log_files)
@@ -41,8 +97,129 @@ module Rails::Pretty::Logger
41
97
  log
42
98
  end
43
99
 
100
+ def self.ensure_file_size_within_limit!(log_file)
101
+ max_file_size = Rails::Pretty::Logger.configuration.max_file_size
102
+ return if max_file_size.blank?
103
+
104
+ raise FileTooLarge if File.size(log_file) > max_file_size.to_i
105
+ end
106
+
107
+ def self.tail_lines
108
+ Rails::Pretty::Logger.configuration.tail_lines.to_i.positive? ? Rails::Pretty::Logger.configuration.tail_lines.to_i : 500
109
+ end
110
+
111
+ def self.structured_log_payload(line)
112
+ payload = JSON.parse(line)
113
+ payload if payload.is_a?(Hash)
114
+ rescue JSON::ParserError, TypeError
115
+ nil
116
+ end
117
+
118
+ def self.custom_log_metadata(line)
119
+ parser = Rails::Pretty::Logger.configuration.log_line_parser
120
+ return {} unless parser.respond_to?(:call)
121
+
122
+ metadata = parser.call(line)
123
+ metadata.is_a?(Hash) ? metadata : {}
124
+ end
125
+
126
+ def self.fetch_line_index_cache(cache_key, signature)
127
+ entry_key = [cache_key, signature]
128
+
129
+ line_index_cache_mutex.synchronize do
130
+ if line_index_cache.key?(entry_key)
131
+ line_index_cache_order.delete(entry_key)
132
+ line_index_cache_order << entry_key
133
+ return line_index_cache.fetch(entry_key)
134
+ end
135
+ end
136
+
137
+ if (offsets = read_persistent_line_index(cache_key, signature))
138
+ store_line_index_cache(entry_key, offsets)
139
+ return offsets
140
+ end
141
+
142
+ offsets = yield.freeze
143
+ write_persistent_line_index(cache_key, signature, offsets)
144
+ store_line_index_cache(entry_key, offsets)
145
+
146
+ offsets
147
+ end
148
+
149
+ def self.store_line_index_cache(entry_key, offsets)
150
+ line_index_cache_mutex.synchronize do
151
+ line_index_cache[entry_key] = offsets
152
+ line_index_cache_order.delete(entry_key)
153
+ line_index_cache_order << entry_key
154
+
155
+ while line_index_cache_order.length > LINE_INDEX_CACHE_LIMIT
156
+ line_index_cache.delete(line_index_cache_order.shift)
157
+ end
158
+ end
159
+ end
160
+
161
+ def self.clear_line_index_cache!
162
+ clear_line_index_memory_cache!
163
+ FileUtils.rm_rf(line_index_cache_root)
164
+ end
165
+
166
+ def self.clear_line_index_memory_cache!
167
+ line_index_cache_mutex.synchronize do
168
+ line_index_cache.clear
169
+ line_index_cache_order.clear
170
+ end
171
+ end
172
+
173
+ def self.clear_line_index_cache_for!(log_file)
174
+ line_index_cache_mutex.synchronize do
175
+ line_index_cache.delete_if { |(cache_key, _signature), _offsets| cache_key.first == log_file || cache_key[1] == log_file }
176
+ line_index_cache_order.delete_if { |cache_key, _signature| cache_key.first == log_file || cache_key[1] == log_file }
177
+ end
178
+ FileUtils.rm_rf(line_index_cache_root)
179
+ end
180
+
181
+ def self.line_index_cache
182
+ @line_index_cache ||= {}
183
+ end
184
+
185
+ def self.line_index_cache_order
186
+ @line_index_cache_order ||= []
187
+ end
188
+
189
+ def self.line_index_cache_mutex
190
+ @line_index_cache_mutex ||= Mutex.new
191
+ end
192
+
193
+ def self.read_persistent_line_index(cache_key, signature)
194
+ payload = Marshal.load(File.binread(line_index_cache_path(cache_key)))
195
+ return unless payload[:signature] == signature
196
+
197
+ payload[:offsets].freeze
198
+ rescue Errno::ENOENT, EOFError, TypeError, ArgumentError
199
+ nil
200
+ end
201
+
202
+ def self.write_persistent_line_index(cache_key, signature, offsets)
203
+ FileUtils.mkdir_p(line_index_cache_root)
204
+ path = line_index_cache_path(cache_key)
205
+ temp_path = "#{path}.#{$$}.tmp"
206
+ File.binwrite(temp_path, Marshal.dump(signature: signature, offsets: offsets))
207
+ FileUtils.mv(temp_path, path)
208
+ rescue SystemCallError, TypeError, ArgumentError
209
+ FileUtils.rm_f(temp_path) if temp_path
210
+ end
211
+
212
+ def self.line_index_cache_path(cache_key)
213
+ line_index_cache_root.join("#{Digest::SHA256.hexdigest(Marshal.dump(cache_key))}.marshal")
214
+ end
215
+
216
+ def self.line_index_cache_root
217
+ Rails.root.join("tmp", "cache", "rails_pretty_logger", "line_indexes")
218
+ end
219
+
44
220
  def clear_logs
45
- open(@log_file, File::TRUNC) {}
221
+ File.open(@log_file, File::TRUNC) {}
222
+ self.class.clear_line_index_cache_for!(@log_file)
46
223
  end
47
224
 
48
225
  def start_date
@@ -54,44 +231,71 @@ module Rails::Pretty::Logger
54
231
  end
55
232
 
56
233
  def filter_logs_with_date(file)
57
- arr = []
234
+ each_filtered_log_line(file).to_a
235
+ end
236
+
237
+ def each_filtered_log_line(file)
238
+ return enum_for(:each_filtered_log_line, file) unless block_given?
239
+
240
+ each_filtered_log_line_with_offset(file) { |line, _offset| yield line }
241
+ end
242
+
243
+ def each_filtered_log_line_with_offset(file)
244
+ return enum_for(:each_filtered_log_line_with_offset, file) unless block_given?
245
+
58
246
  start = false
59
247
 
60
- IO.foreach(file) do |line|
248
+ each_raw_log_line_with_offset(file) do |line, offset|
61
249
  if get_date_from_log_line(line)
62
250
  start = true
63
- arr.push(line)
251
+ yield line, offset
64
252
  elsif start && !(line_include_date?(line))
65
- arr.push(line)
253
+ yield line, offset
66
254
  else
67
255
  start = false
68
256
  end
69
257
  end
70
- return arr
71
258
  end
72
259
 
73
260
  def get_test_logs(file)
74
- arr = []
75
- IO.foreach(file) do |line|
76
- arr.push(line)
77
- end
78
- return arr
261
+ IO.foreach(file).to_a
79
262
  end
80
263
 
81
264
  def get_logs_from_file(file)
82
- if @filter_params[:log_file].include?("test") || @filter_params[:log_file].include?("hourly")
83
- get_test_logs(file)
265
+ each_log_line(file).to_a
266
+ end
267
+
268
+ def each_log_line(file)
269
+ return enum_for(:each_log_line, file) unless block_given?
270
+
271
+ each_log_line_with_offset(file) { |line, _offset| yield line }
272
+ end
273
+
274
+ def each_log_line_with_offset(file)
275
+ return enum_for(:each_log_line_with_offset, file) unless block_given?
276
+
277
+ if test_log?(file) || hourly_log?(file)
278
+ each_raw_log_line_with_offset(file) { |line, offset| yield line, offset }
84
279
  else
85
- filter_logs_with_date(file)
280
+ each_filtered_log_line_with_offset(file) { |line, offset| yield line, offset }
281
+ end
282
+ end
283
+
284
+ def each_raw_log_line_with_offset(file)
285
+ return enum_for(:each_raw_log_line_with_offset, file) unless block_given?
286
+
287
+ File.open(file, "r") do |io|
288
+ until io.eof?
289
+ offset = io.pos
290
+ line = io.gets
291
+ yield line, offset if line
292
+ end
86
293
  end
87
294
  end
88
295
 
89
296
  def get_date_from_log_line(line)
90
- params = @filter_params[:date_range]
91
- if line_include_date?(line)
92
- date_string_index = line.index("at ")
93
- string_date = line[date_string_index .. date_string_index + 13]
94
- date = string_date.to_date.strftime("%Y-%m-%d")
297
+ date = date_from_log_line(line)
298
+ if date.present?
95
299
  start_date = @filter_params.dig(:date_range, :start)
96
300
  end_date = @filter_params.dig(:date_range, :end)
97
301
  if start_date.present? && end_date.present?
@@ -103,7 +307,7 @@ module Rails::Pretty::Logger
103
307
  end
104
308
 
105
309
  def line_include_date?(line)
106
- line.include?("Started")
310
+ date_from_log_line(line).present?
107
311
  end
108
312
 
109
313
  def validate_date
@@ -121,25 +325,337 @@ module Rails::Pretty::Logger
121
325
  def log_data
122
326
  error = validate_date
123
327
  divider = set_divider_value
124
- logs = get_logs_from_file(@log_file)
125
- logs_count = (logs.count.to_f / divider).ceil
126
- paginated_logs = logs[ @filter_params[:page].to_i * divider ..
127
- (@filter_params[:page].to_i * divider) + divider ]
328
+ return grouped_log_data(error, divider) if request_grouping?
329
+
330
+ page_start = @filter_params[:page].to_i * divider
331
+ page_end = page_start + divider
332
+
333
+ self.class.ensure_file_size_within_limit!(@log_file)
334
+
335
+ line_offsets = cached_log_line_offsets
336
+ paginated_logs = read_log_lines_at_offsets(@log_file, line_offsets[page_start...page_end] || [])
337
+
128
338
  data = {}
129
- data[:logs_count] = logs_count
339
+ data[:logs_count] = (line_offsets.length.to_f / divider).ceil
130
340
  data[:paginated_logs] = paginated_logs
131
341
  data[:error] = error
132
342
  return data
133
343
  end
134
344
 
345
+ def tail_log_data
346
+ self.class.ensure_file_size_within_limit!(@log_file)
347
+
348
+ lines = tail_lines(@log_file, self.class.tail_lines).select { |line| line_matches_filters?(line) }
349
+ {
350
+ logs_count: lines.any? ? 1 : 0,
351
+ paginated_logs: lines,
352
+ error: nil
353
+ }
354
+ end
355
+
135
356
  def set_divider_value
136
357
  if @filter_params[:date_range].blank?
137
358
  100
138
359
  elsif @filter_params[:date_range][:divider].blank?
139
360
  100
140
361
  else
141
- @filter_params[:date_range][:divider].to_i
362
+ divider = @filter_params[:date_range][:divider].to_i
363
+ divider.positive? ? divider : 100
364
+ end
365
+ end
366
+
367
+ def test_log?(file)
368
+ File.basename(file).include?("test")
369
+ end
370
+
371
+ def hourly_log?(file)
372
+ file.include?("#{File::SEPARATOR}hourly#{File::SEPARATOR}")
373
+ end
374
+
375
+ def line_matches_filters?(line)
376
+ query = @filter_params[:query].to_s.strip
377
+ return false if query.present? && !line.downcase.include?(query.downcase)
378
+
379
+ severity = @filter_params[:severity].to_s.upcase
380
+ return true unless SEVERITIES.include?(severity)
381
+
382
+ structured_severity = structured_log_severity(line)
383
+ return structured_severity == severity if structured_severity.present?
384
+
385
+ line.match?(/\b#{Regexp.escape(severity)}\b/i)
386
+ end
387
+
388
+ def tail_lines(file, count)
389
+ count = count.to_i
390
+ return [] unless count.positive?
391
+
392
+ File.open(file, "rb") do |io|
393
+ offset = io.size
394
+ return [] if offset.zero?
395
+
396
+ chunks = []
397
+ newline_count = 0
398
+
399
+ while offset.positive? && newline_count <= count
400
+ chunk_size = [TAIL_READ_CHUNK_SIZE, offset].min
401
+ offset -= chunk_size
402
+ io.seek(offset)
403
+
404
+ chunk = io.read(chunk_size)
405
+ chunks.unshift(chunk)
406
+ newline_count += chunk.count("\n")
407
+ end
408
+
409
+ chunks.join.lines.last(count).map { |line| line.force_encoding(Encoding.default_external) }
410
+ end
411
+ end
412
+
413
+ def request_grouping?
414
+ @filter_params[:group].to_s == "request"
415
+ end
416
+
417
+ def cached_log_line_offsets
418
+ self.class.fetch_line_index_cache(log_line_index_cache_key, log_file_signature) do
419
+ build_log_line_offsets
420
+ end
421
+ end
422
+
423
+ def build_log_line_offsets
424
+ offsets = []
425
+
426
+ each_log_line_with_offset(@log_file) do |line, offset|
427
+ offsets << offset if line_matches_filters?(line)
428
+ end
429
+
430
+ offsets
431
+ end
432
+
433
+ def read_log_lines_at_offsets(file, offsets)
434
+ File.open(file, "r") do |io|
435
+ offsets.map do |offset|
436
+ io.seek(offset)
437
+ io.gets
438
+ end.compact
439
+ end
440
+ end
441
+
442
+ def log_line_index_cache_key
443
+ [
444
+ @log_file,
445
+ date_filtered_log? ? start_date : nil,
446
+ date_filtered_log? ? end_date : nil,
447
+ @filter_params[:query].to_s.strip.downcase,
448
+ normalized_severity_filter,
449
+ Rails::Pretty::Logger.configuration.log_line_parser&.object_id
450
+ ]
451
+ end
452
+
453
+ def log_file_signature
454
+ stat = File.stat(@log_file)
455
+ [stat.size, stat.mtime.to_f, stat.ctime.to_f]
456
+ end
457
+
458
+ def date_filtered_log?
459
+ !test_log?(@log_file) && !hourly_log?(@log_file)
460
+ end
461
+
462
+ def normalized_severity_filter
463
+ severity = @filter_params[:severity].to_s.upcase
464
+ SEVERITIES.include?(severity) ? severity : nil
465
+ end
466
+
467
+ def grouped_log_data(error, divider)
468
+ self.class.ensure_file_size_within_limit!(@log_file)
469
+
470
+ page_start = @filter_params[:page].to_i * divider
471
+ page_end = page_start + divider
472
+ matching_groups = matching_request_group_index
473
+
474
+ paginated_groups = (matching_groups[page_start...page_end] || []).map { |group| request_group_from_index(group) }
475
+
476
+ {
477
+ logs_count: (matching_groups.length.to_f / divider).ceil,
478
+ paginated_logs: paginated_groups,
479
+ error: error
480
+ }
481
+ end
482
+
483
+ def each_request_group(file)
484
+ return enum_for(:each_request_group, file) unless block_given?
485
+
486
+ cached_request_group_index.each do |group|
487
+ yield request_group_from_index(group)
488
+ end
489
+ end
490
+
491
+ def cached_request_group_index
492
+ self.class.fetch_line_index_cache(request_group_index_cache_key, log_file_signature) do
493
+ build_request_group_index
494
+ end
495
+ end
496
+
497
+ def build_request_group_index
498
+ groups = []
499
+ current_group = nil
500
+
501
+ each_log_line_with_offset(@log_file) do |line, offset|
502
+ if (metadata = request_start_metadata(line))
503
+ groups << current_group if current_group
504
+ current_group = metadata.merge(line_offsets: [offset])
505
+ elsif current_group
506
+ current_group[:line_offsets] << offset
507
+ current_group.merge!(request_completion_metadata(line) || {})
508
+ else
509
+ current_group = { type: :ungrouped, line_offsets: [offset] }
510
+ end
142
511
  end
512
+
513
+ groups << current_group if current_group
514
+ groups
515
+ end
516
+
517
+ def matching_request_group_index
518
+ groups = cached_request_group_index
519
+ return groups unless filtered_request_groups?
520
+
521
+ groups.select { |group| group_matches_filters?(request_group_from_index(group)) }
522
+ end
523
+
524
+ def request_group_from_index(group)
525
+ group.merge(lines: read_log_lines_at_offsets(@log_file, group.fetch(:line_offsets))).tap do |request_group|
526
+ request_group.delete(:line_offsets)
527
+ end
528
+ end
529
+
530
+ def filtered_request_groups?
531
+ @filter_params[:query].to_s.strip.present? || normalized_severity_filter.present?
532
+ end
533
+
534
+ def group_matches_filters?(group)
535
+ group.fetch(:lines).any? { |line| line_matches_filters?(line) }
536
+ end
537
+
538
+ def request_group_index_cache_key
539
+ [
540
+ :request_groups,
541
+ @log_file,
542
+ date_filtered_log? ? start_date : nil,
543
+ date_filtered_log? ? end_date : nil,
544
+ Rails::Pretty::Logger.configuration.log_line_parser&.object_id
545
+ ]
546
+ end
547
+
548
+ def request_start_metadata(line)
549
+ metadata = custom_log_metadata(line)
550
+ request_method = metadata_value(metadata, :request_method, :method)
551
+ request_path = metadata_value(metadata, :request_path, :path)
552
+
553
+ if request_method.present? && request_path.present?
554
+ return {
555
+ type: :request,
556
+ method: request_method.to_s,
557
+ path: request_path.to_s,
558
+ ip: metadata_value(metadata, :request_ip, :ip),
559
+ started_at: metadata_value(metadata, :request_started_at, :started_at, :timestamp, :time).to_s
560
+ }
561
+ end
562
+
563
+ match = line.strip.match(/\AStarted\s+(?<method>[A-Z]+)\s+"(?<path>[^"]+)"(?:\s+for\s+(?<ip>\S+))?\s+at\s+(?<timestamp>.+)\z/)
564
+ return unless match
565
+
566
+ {
567
+ type: :request,
568
+ method: match[:method],
569
+ path: match[:path],
570
+ ip: match[:ip],
571
+ started_at: match[:timestamp].strip
572
+ }
573
+ end
574
+
575
+ def request_completion_metadata(line)
576
+ metadata = custom_log_metadata(line)
577
+ response_status = metadata_value(metadata, :response_status, :status)
578
+ duration = metadata_value(metadata, :duration, :request_duration)
579
+
580
+ if response_status.present? || duration.present?
581
+ return {
582
+ status: response_status.to_s,
583
+ duration: duration.to_s
584
+ }
585
+ end
586
+
587
+ match = line.strip.match(/\ACompleted\s+(?<status>\d{3}).*?\sin\s+(?<duration>[\d.]+ms)/)
588
+ return unless match
589
+
590
+ {
591
+ status: match[:status],
592
+ duration: match[:duration]
593
+ }
594
+ end
595
+
596
+ def date_from_log_line(line)
597
+ timestamp = custom_log_timestamp(line) || request_timestamp(line) || structured_log_timestamp(line)
598
+ timestamp&.to_date&.strftime("%Y-%m-%d")
599
+ rescue Date::Error, NoMethodError
600
+ nil
601
+ end
602
+
603
+ def request_timestamp(line)
604
+ match = line.strip.match(/\AStarted\s+.*\sat\s+(?<timestamp>.+)\z/)
605
+ match[:timestamp] if match
606
+ end
607
+
608
+ def structured_log_timestamp(line)
609
+ payload = self.class.structured_log_payload(line)
610
+ return unless payload
611
+
612
+ STRUCTURED_TIMESTAMP_KEYS.each do |key|
613
+ return payload[key].to_s if payload[key].present?
614
+ end
615
+
616
+ nil
617
+ end
618
+
619
+ def structured_log_severity(line)
620
+ severity = custom_log_severity(line)
621
+ return severity if severity.present?
622
+
623
+ payload = self.class.structured_log_payload(line)
624
+ return unless payload
625
+
626
+ STRUCTURED_SEVERITY_KEYS.each do |key|
627
+ severity = payload[key].to_s.upcase
628
+ return severity if SEVERITIES.include?(severity)
629
+ end
630
+
631
+ nested_log = payload["log"]
632
+ return unless nested_log.respond_to?(:[])
633
+
634
+ nested_severity = nested_log["level"].to_s.upcase
635
+ nested_severity if SEVERITIES.include?(nested_severity)
636
+ end
637
+
638
+ def custom_log_timestamp(line)
639
+ metadata_value(custom_log_metadata(line), :timestamp, :time, :datetime, :created_at)
640
+ end
641
+
642
+ def custom_log_severity(line)
643
+ severity = metadata_value(custom_log_metadata(line), :severity, :level, :log_level).to_s.upcase
644
+ severity if SEVERITIES.include?(severity)
645
+ end
646
+
647
+ def custom_log_metadata(line)
648
+ self.class.custom_log_metadata(line)
649
+ end
650
+
651
+ def metadata_value(metadata, *keys)
652
+ keys.each do |key|
653
+ string_key = key.to_s
654
+ return metadata[string_key] if metadata.key?(string_key)
655
+ return metadata[key] if metadata.key?(key)
656
+ end
657
+
658
+ nil
143
659
  end
144
660
 
145
661
  end