rails_semantic_logger 4.20.0 → 5.0.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +55 -98
  3. data/Rakefile +7 -4
  4. data/lib/rails_semantic_logger/action_controller/log_subscriber.rb +86 -16
  5. data/lib/rails_semantic_logger/action_mailer/log_subscriber.rb +36 -22
  6. data/lib/rails_semantic_logger/action_view/log_subscriber.rb +74 -40
  7. data/lib/rails_semantic_logger/active_job/log_subscriber.rb +216 -7
  8. data/lib/rails_semantic_logger/active_record/log_subscriber.rb +62 -160
  9. data/lib/rails_semantic_logger/appenders.rb +91 -0
  10. data/lib/rails_semantic_logger/engine.rb +47 -36
  11. data/lib/rails_semantic_logger/extensions/action_cable/tagged_logger_proxy.rb +44 -3
  12. data/lib/rails_semantic_logger/extensions/action_dispatch/debug_exceptions.rb +5 -14
  13. data/lib/rails_semantic_logger/extensions/active_job/logging.rb +2 -2
  14. data/lib/rails_semantic_logger/extensions/active_model_serializers/logging.rb +2 -2
  15. data/lib/rails_semantic_logger/extensions/active_support/logger.rb +24 -15
  16. data/lib/rails_semantic_logger/extensions/rails/server.rb +1 -1
  17. data/lib/rails_semantic_logger/extensions/sidekiq/sidekiq.rb +4 -4
  18. data/lib/rails_semantic_logger/options.rb +171 -20
  19. data/lib/rails_semantic_logger/rack/logger.rb +6 -13
  20. data/lib/rails_semantic_logger/sidekiq/defaults.rb +4 -2
  21. data/lib/rails_semantic_logger/sidekiq/job_logger.rb +13 -5
  22. data/lib/rails_semantic_logger/solid_queue/log_subscriber.rb +179 -0
  23. data/lib/rails_semantic_logger/version.rb +1 -1
  24. data/lib/rails_semantic_logger.rb +81 -26
  25. metadata +15 -21
  26. data/lib/rails_semantic_logger/delayed_job/plugin.rb +0 -11
  27. data/lib/rails_semantic_logger/extensions/active_support/log_subscriber.rb +0 -13
  28. data/lib/rails_semantic_logger/extensions/rack/server.rb +0 -12
  29. data/lib/rails_semantic_logger/extensions/rackup/server.rb +0 -12
@@ -1,10 +1,22 @@
1
1
  require "active_support/log_subscriber"
2
2
 
3
+ # This subscriber is a reimplementation of Rails' own ActionView::LogSubscriber that emits
4
+ # structured (message + payload) log entries instead of formatted text. When Rails changes its
5
+ # subscriber, those changes must be brought across here. Compare against the upstream source for
6
+ # each supported Rails version:
7
+ #
8
+ # Rails 8.1: https://github.com/rails/rails/blob/8-1-stable/actionview/lib/action_view/log_subscriber.rb
9
+ # Rails 8.0: https://github.com/rails/rails/blob/8-0-stable/actionview/lib/action_view/log_subscriber.rb
10
+ # Rails 7.2: https://github.com/rails/rails/blob/7-2-stable/actionview/lib/action_view/log_subscriber.rb
11
+ #
12
+ # As of these versions the upstream subscriber is identical across 7.2, 8.0, and 8.1, so no
13
+ # version-specific behavior is required here.
14
+ #
3
15
  module RailsSemanticLogger
4
16
  module ActionView
5
17
  # Output Semantic logs from Action View.
6
18
  class LogSubscriber < ActiveSupport::LogSubscriber
7
- VIEWS_PATTERN = %r{^app/views/}.freeze
19
+ VIEWS_PATTERN = %r{^app/views/}
8
20
 
9
21
  class << self
10
22
  attr_reader :logger
@@ -23,13 +35,15 @@ module RailsSemanticLogger
23
35
  template: from_rails_root(event.payload[:identifier])
24
36
  }
25
37
  payload[:within] = from_rails_root(event.payload[:layout]) if event.payload[:layout]
26
- payload[:allocations] = event.allocations if event.respond_to?(:allocations)
38
+ payload[:allocations] = event.allocations
39
+ payload[:gc_time] = event.gc_time.round(2) if event.respond_to?(:gc_time)
27
40
 
28
41
  logger.measure(
29
42
  self.class.rendered_log_level,
30
43
  "Rendered",
31
44
  payload: payload,
32
- duration: event.duration
45
+ duration: event.duration,
46
+ metric: "rails.view.render.template"
33
47
  )
34
48
  end
35
49
 
@@ -41,13 +55,33 @@ module RailsSemanticLogger
41
55
  }
42
56
  payload[:within] = from_rails_root(event.payload[:layout]) if event.payload[:layout]
43
57
  payload[:cache] = event.payload[:cache_hit] unless event.payload[:cache_hit].nil?
44
- payload[:allocations] = event.allocations if event.respond_to?(:allocations)
58
+ payload[:allocations] = event.allocations
59
+ payload[:gc_time] = event.gc_time.round(2) if event.respond_to?(:gc_time)
45
60
 
46
61
  logger.measure(
47
62
  self.class.rendered_log_level,
48
63
  "Rendered",
49
64
  payload: payload,
50
- duration: event.duration
65
+ duration: event.duration,
66
+ metric: "rails.view.render.partial"
67
+ )
68
+ end
69
+
70
+ def render_layout(event)
71
+ return unless should_log?
72
+
73
+ payload = {
74
+ template: from_rails_root(event.payload[:identifier])
75
+ }
76
+ payload[:allocations] = event.allocations
77
+ payload[:gc_time] = event.gc_time.round(2) if event.respond_to?(:gc_time)
78
+
79
+ logger.measure(
80
+ self.class.rendered_log_level,
81
+ "Rendered layout",
82
+ payload: payload,
83
+ duration: event.duration,
84
+ metric: "rails.view.render.layout"
51
85
  )
52
86
  end
53
87
 
@@ -61,13 +95,15 @@ module RailsSemanticLogger
61
95
  count: event.payload[:count]
62
96
  }
63
97
  payload[:cache_hits] = event.payload[:cache_hits] if event.payload[:cache_hits]
64
- payload[:allocations] = event.allocations if event.respond_to?(:allocations)
98
+ payload[:allocations] = event.allocations
99
+ payload[:gc_time] = event.gc_time.round(2) if event.respond_to?(:gc_time)
65
100
 
66
101
  logger.measure(
67
102
  self.class.rendered_log_level,
68
103
  "Rendered",
69
104
  payload: payload,
70
- duration: event.duration
105
+ duration: event.duration,
106
+ metric: "rails.view.render.collection"
71
107
  )
72
108
  end
73
109
 
@@ -83,53 +119,51 @@ module RailsSemanticLogger
83
119
  super
84
120
  end
85
121
 
86
- if (Rails::VERSION::MAJOR == 7 && Rails::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR > 7
87
- class Start
88
- def start(name, _id, payload)
89
- return unless %w[render_template.action_view render_layout.action_view].include?(name)
122
+ class Start
123
+ def start(name, _id, payload)
124
+ return unless %w[render_template.action_view render_layout.action_view].include?(name)
90
125
 
91
- qualifier = " layout" if name == "render_layout.action_view"
92
- payload = {template: from_rails_root(payload[:identifier])}
93
- payload[:within] = from_rails_root(payload[:layout]) if payload[:layout]
126
+ qualifier = " layout" if name == "render_layout.action_view"
127
+ payload = {template: from_rails_root(payload[:identifier])}
128
+ payload[:within] = from_rails_root(payload[:layout]) if payload[:layout]
94
129
 
95
- logger.debug(message: "Rendering#{qualifier}", payload: payload)
96
- end
130
+ logger.debug(message: "Rendering#{qualifier}", payload: payload)
131
+ end
97
132
 
98
- def finish(name, id, payload)
99
- end
133
+ def finish(name, id, payload)
134
+ end
100
135
 
101
- private
136
+ private
102
137
 
103
- def from_rails_root(string)
104
- string = string.sub(rails_root, "")
105
- string.sub!(VIEWS_PATTERN, "")
106
- string
107
- end
138
+ def from_rails_root(string)
139
+ string = string.sub(rails_root, "")
140
+ string.sub!(VIEWS_PATTERN, "")
141
+ string
142
+ end
108
143
 
109
- def rails_root
110
- @root ||= "#{Rails.root}/"
111
- end
144
+ def rails_root
145
+ @root ||= "#{Rails.root}/"
146
+ end
112
147
 
113
- def logger
114
- @logger ||= SemanticLogger["ActionView"]
115
- end
148
+ def logger
149
+ @logger ||= ::ActionView::Base.logger
116
150
  end
151
+ end
117
152
 
118
- def self.attach_to(*)
119
- ActiveSupport::Notifications.unsubscribe("render_template.action_view")
120
- ActiveSupport::Notifications.unsubscribe("render_layout.action_view")
121
- ActiveSupport::Notifications.subscribe("render_template.action_view",
122
- RailsSemanticLogger::ActionView::LogSubscriber::Start.new)
123
- ActiveSupport::Notifications.subscribe("render_layout.action_view",
124
- RailsSemanticLogger::ActionView::LogSubscriber::Start.new)
153
+ def self.attach_to(*)
154
+ ActiveSupport::Notifications.unsubscribe("render_template.action_view")
155
+ ActiveSupport::Notifications.unsubscribe("render_layout.action_view")
156
+ ActiveSupport::Notifications.subscribe("render_template.action_view",
157
+ RailsSemanticLogger::ActionView::LogSubscriber::Start.new)
158
+ ActiveSupport::Notifications.subscribe("render_layout.action_view",
159
+ RailsSemanticLogger::ActionView::LogSubscriber::Start.new)
125
160
 
126
- super
127
- end
161
+ super
128
162
  end
129
163
 
130
164
  private
131
165
 
132
- @logger = SemanticLogger["ActionView"]
166
+ @logger = ::ActionView::Base.logger
133
167
  @rendered_log_level = :debug
134
168
 
135
169
  EMPTY = "".freeze
@@ -1,10 +1,27 @@
1
1
  require "active_job"
2
2
 
3
+ # This subscriber is a reimplementation of Rails' own ActiveJob::LogSubscriber that emits
4
+ # structured (message + payload) log entries instead of formatted text. When Rails changes its
5
+ # subscriber, those changes must be brought across here. Compare against the upstream source for
6
+ # each supported Rails version:
7
+ #
8
+ # Rails 8.1: https://github.com/rails/rails/blob/8-1-stable/activejob/lib/active_job/log_subscriber.rb
9
+ # Rails 8.0: https://github.com/rails/rails/blob/8-0-stable/activejob/lib/active_job/log_subscriber.rb
10
+ # Rails 7.2: https://github.com/rails/rails/blob/7-2-stable/activejob/lib/active_job/log_subscriber.rb
11
+ #
12
+ # Event coverage by Rails version:
13
+ # 7.2 / 8.0: enqueue, enqueue_at, enqueue_all, perform_start, perform,
14
+ # enqueue_retry, retry_stopped, discard
15
+ # 8.1 adds (ActiveJob Continuations): interrupt, resume, step_skipped, step_started, step
16
+ #
17
+ # The Continuation handlers are defined unconditionally. On Rails < 8.1 those notifications are
18
+ # never emitted, so the extra methods are simply never invoked.
19
+ #
3
20
  module RailsSemanticLogger
4
21
  module ActiveJob
5
22
  class LogSubscriber < ::ActiveSupport::LogSubscriber
6
23
  def enqueue(event)
7
- ex = event.payload[:exception_object]
24
+ ex = enqueue_error(event)
8
25
 
9
26
  if ex
10
27
  log_with_formatter level: :error, event: event do |fmt|
@@ -25,7 +42,7 @@ module RailsSemanticLogger
25
42
  end
26
43
 
27
44
  def enqueue_at(event)
28
- ex = event.payload[:exception_object]
45
+ ex = enqueue_error(event)
29
46
 
30
47
  if ex
31
48
  log_with_formatter level: :error, event: event do |fmt|
@@ -60,6 +77,11 @@ module RailsSemanticLogger
60
77
  exception: ex
61
78
  }
62
79
  end
80
+ elsif event.payload[:aborted]
81
+ log_with_formatter event: event, log_duration: true, level: :error do |fmt|
82
+ {message: "Error performing #{fmt.job_info} in #{event.duration.round(2)}ms: " \
83
+ "a before_perform callback halted the job execution"}
84
+ end
63
85
  else
64
86
  log_with_formatter event: event, log_duration: true do |fmt|
65
87
  {message: "Performed #{fmt.job_info} in #{event.duration.round(2)}ms"}
@@ -67,8 +89,177 @@ module RailsSemanticLogger
67
89
  end
68
90
  end
69
91
 
92
+ def enqueue_retry(event)
93
+ ex = event.payload[:error]
94
+ wait = event.payload[:wait]
95
+
96
+ log_with_formatter level: :info, event: event do |fmt|
97
+ base = "Retrying #{fmt.job_info} after #{fmt.executions} attempts in #{wait.to_i} seconds"
98
+ message = ex ? "#{base}, due to a #{ex.class} (#{ex.message})." : "#{base}."
99
+
100
+ {
101
+ message: message,
102
+ exception: ex,
103
+ payload: {executions: fmt.executions, wait: wait.to_i}
104
+ }
105
+ end
106
+ end
107
+
108
+ def retry_stopped(event)
109
+ ex = event.payload[:error]
110
+
111
+ log_with_formatter level: :error, event: event do |fmt|
112
+ {
113
+ message: "Stopped retrying #{fmt.job_info} due to a #{ex.class} (#{ex.message}), " \
114
+ "which reoccurred on #{fmt.executions} attempts.",
115
+ exception: ex,
116
+ payload: {executions: fmt.executions}
117
+ }
118
+ end
119
+ end
120
+
121
+ def discard(event)
122
+ ex = event.payload[:error]
123
+
124
+ log_with_formatter level: :error, event: event do |fmt|
125
+ {
126
+ message: "Discarded #{fmt.job_info} due to a #{ex.class} (#{ex.message}).",
127
+ exception: ex
128
+ }
129
+ end
130
+ end
131
+
132
+ # ActiveJob Continuations (Rails 8.1+)
133
+
134
+ def interrupt(event)
135
+ description = event.payload[:description]
136
+ reason = event.payload[:reason]
137
+
138
+ log_with_formatter level: :info, event: event do |fmt|
139
+ {
140
+ message: "Interrupted #{fmt.job_info} #{description} (#{reason})",
141
+ payload: {description: description, reason: reason}
142
+ }
143
+ end
144
+ end
145
+
146
+ def resume(event)
147
+ description = event.payload[:description]
148
+
149
+ log_with_formatter level: :info, event: event do |fmt|
150
+ {
151
+ message: "Resuming #{fmt.job_info} #{description}",
152
+ payload: {description: description}
153
+ }
154
+ end
155
+ end
156
+
157
+ def step_skipped(event)
158
+ step = event.payload[:step]
159
+
160
+ log_with_formatter level: :info, event: event do |fmt|
161
+ {
162
+ message: "Step '#{step.name}' skipped for #{fmt.job_info}",
163
+ payload: {step_name: step.name}
164
+ }
165
+ end
166
+ end
167
+
168
+ def step_started(event)
169
+ step = event.payload[:step]
170
+
171
+ log_with_formatter level: :info, event: event do |fmt|
172
+ message =
173
+ if step.resumed?
174
+ "Step '#{step.name}' resumed from cursor '#{step.cursor}' for #{fmt.job_info}"
175
+ else
176
+ "Step '#{step.name}' started for #{fmt.job_info}"
177
+ end
178
+
179
+ {
180
+ message: message,
181
+ payload: {step_name: step.name, step_cursor: step.cursor}
182
+ }
183
+ end
184
+ end
185
+
186
+ def step(event)
187
+ step = event.payload[:step]
188
+ ex = event.payload[:exception_object]
189
+
190
+ if event.payload[:interrupted]
191
+ log_with_formatter level: :info, event: event, log_duration: true do |fmt|
192
+ {
193
+ message: "Step '#{step.name}' interrupted at cursor '#{step.cursor}' for " \
194
+ "#{fmt.job_info} in #{event.duration.round(2)}ms",
195
+ payload: {step_name: step.name, step_cursor: step.cursor}
196
+ }
197
+ end
198
+ elsif ex
199
+ log_with_formatter level: :error, event: event, log_duration: true do |fmt|
200
+ {
201
+ message: "Error during step '#{step.name}' at cursor '#{step.cursor}' for " \
202
+ "#{fmt.job_info} in #{event.duration.round(2)}ms: #{ex.class} (#{ex.message})",
203
+ exception: ex,
204
+ payload: {step_name: step.name, step_cursor: step.cursor}
205
+ }
206
+ end
207
+ else
208
+ log_with_formatter level: :info, event: event, log_duration: true do |fmt|
209
+ {
210
+ message: "Step '#{step.name}' completed for #{fmt.job_info} in #{event.duration.round(2)}ms",
211
+ payload: {step_name: step.name, step_cursor: step.cursor}
212
+ }
213
+ end
214
+ end
215
+ end
216
+
217
+ def enqueue_all(event)
218
+ jobs = event.payload[:jobs]
219
+ adapter = event.payload[:adapter]
220
+ enqueued_count = event.payload[:enqueued_count].to_i
221
+ adapter_name = ::ActiveJob.adapter_name(adapter)
222
+ failed_count = jobs.size - enqueued_count
223
+
224
+ message =
225
+ if failed_count.zero?
226
+ enqueued_jobs_message(adapter_name, jobs)
227
+ elsif jobs.any?(&:successfully_enqueued?)
228
+ "#{enqueued_jobs_message(adapter_name, jobs.select(&:successfully_enqueued?))}. " \
229
+ "Failed enqueuing #{failed_count} #{'job'.pluralize(failed_count)}"
230
+ else
231
+ "Failed enqueuing #{failed_count} #{'job'.pluralize(failed_count)} to #{adapter_name}"
232
+ end
233
+
234
+ logger.info(
235
+ message: message,
236
+ metric: "rails.job.enqueue_all",
237
+ payload: {
238
+ event_name: event.name,
239
+ adapter: adapter_name,
240
+ enqueued_count: enqueued_count,
241
+ total_count: jobs.size,
242
+ job_classes: jobs.map { |job| job.class.name }.tally
243
+ }
244
+ )
245
+ end
246
+
70
247
  private
71
248
 
249
+ # Upstream records an enqueue failure either via the event's exception_object or via
250
+ # ActiveJob's job.enqueue_error. Prefer the former, fall back to the latter.
251
+ def enqueue_error(event)
252
+ event.payload[:exception_object] ||
253
+ (event.payload[:job].respond_to?(:enqueue_error) ? event.payload[:job].enqueue_error : nil)
254
+ end
255
+
256
+ def enqueued_jobs_message(adapter_name, enqueued_jobs)
257
+ enqueued_count = enqueued_jobs.size
258
+ job_classes_counts = enqueued_jobs.map(&:class).tally.sort_by { |_k, v| -v }
259
+ "Enqueued #{enqueued_count} #{'job'.pluralize(enqueued_count)} to #{adapter_name} " \
260
+ "(#{job_classes_counts.map { |klass, count| "#{count} #{klass}" }.join(', ')})"
261
+ end
262
+
72
263
  class EventFormatter
73
264
  def initialize(event:, log_duration: false)
74
265
  @event = event
@@ -79,6 +270,9 @@ module RailsSemanticLogger
79
270
  "#{job.class.name} (Job ID: #{job.job_id}) to #{queue_name}"
80
271
  end
81
272
 
273
+ # Standard payload shared by every event. enqueued_at, scheduled_at, and duration are
274
+ # only present when applicable (the job was scheduled, has been enqueued, or the event
275
+ # carries a duration), so that handlers that do not have them never emit blank keys.
82
276
  def payload
83
277
  {}.tap do |h|
84
278
  h[:event_name] = event.name
@@ -86,7 +280,9 @@ module RailsSemanticLogger
86
280
  h[:queue] = job.queue_name
87
281
  h[:job_class] = job.class.name
88
282
  h[:job_id] = job.job_id
89
- h[:provider_job_id] = job.try(:provider_job_id) # Not available in Rails 4.2
283
+ h[:provider_job_id] = job.provider_job_id
284
+ h[:enqueued_at] = job.enqueued_at if job.respond_to?(:enqueued_at) && job.enqueued_at.present?
285
+ h[:scheduled_at] = scheduled_at if job.scheduled_at
90
286
  h[:duration] = event.duration.round(2) if log_duration?
91
287
  h[:arguments] = formatted_args
92
288
  end
@@ -97,7 +293,11 @@ module RailsSemanticLogger
97
293
  end
98
294
 
99
295
  def scheduled_at
100
- Time.at(event.payload[:job].scheduled_at).utc
296
+ Time.at(job.scheduled_at).utc
297
+ end
298
+
299
+ def executions
300
+ job.executions
101
301
  end
102
302
 
103
303
  private
@@ -144,10 +344,19 @@ module RailsSemanticLogger
144
344
  end
145
345
  end
146
346
 
347
+ # Builds the structured log entry for an event. The block is given an EventFormatter and
348
+ # returns a hash with :message, an optional :exception, and an optional :payload of extra
349
+ # fields. Those extra fields are merged on top of the formatter's standard payload, so
350
+ # handlers can add event-specific keys (executions, wait, step_name, ...) without each
351
+ # having to rebuild the common job payload.
147
352
  def log_with_formatter(level: :info, **kw_args)
148
- fmt = EventFormatter.new(**kw_args)
149
- msg = yield fmt
150
- logger.public_send(level, **msg, payload: fmt.payload)
353
+ fmt = EventFormatter.new(**kw_args)
354
+ msg = yield fmt
355
+ extra = msg.delete(:payload) || {}
356
+ # Emit a metric for every info/warn/error entry, named after the notification
357
+ # (e.g. "enqueue.active_job" -> "rails.job.enqueue"). Debug entries are excluded.
358
+ msg[:metric] ||= "rails.job.#{kw_args[:event].name.split('.').first}" unless level == :debug
359
+ logger.public_send(level, **msg, payload: fmt.payload.merge(extra))
151
360
  end
152
361
 
153
362
  def logger