88end
99
1010require_relative "good_job/logger" if defined? ( ::GoodJob )
11- require_relative "good_job/log_subscriber" if defined? ( ::GoodJob )
1211
1312module LogStruct
1413 module Integrations
1514 # GoodJob integration for structured logging
1615 #
17- # GoodJob is a PostgreSQL-based ActiveJob backend that provides reliable,
18- # scalable job processing for Rails applications. This integration provides
19- # structured logging for all GoodJob operations.
16+ # GoodJob is a PostgreSQL-based ActiveJob backend. Its jobs are logged by the ActiveJob
17+ # integration (enqueued, started, finished, failed), so this integration covers GoodJob itself:
2018 #
21- # ## Features:
22- # - Structured logging for job execution lifecycle
23- # - Error tracking and retry logging
24- # - Performance metrics and timing data
25- # - Database operation logging
26- # - Thread and process tracking
27- # - Custom GoodJob logger with LogStruct formatting
28- #
29- # ## Integration Points:
30- # - Replaces GoodJob.logger with LogStruct-compatible logger
31- # - Subscribes to GoodJob's ActiveSupport notifications
32- # - Captures job execution events, errors, and performance metrics
33- # - Logs database operations and connection information
19+ # - Replaces GoodJob.logger with a LogStruct logger, so GoodJob's own messages are JSON lines
20+ # - Logs each thread error as a structured error line, then calls the app's `on_thread_error`
21+ # - Stops GoodJob's own log subscriber logging those errors again with the whole backtrace
3422 #
3523 # ## Configuration:
3624 # The integration is automatically enabled when GoodJob is detected and
@@ -45,8 +33,7 @@ module GoodJob
4533
4634 # Set up GoodJob structured logging
4735 #
48- # This method configures GoodJob to use LogStruct's structured logging
49- # by replacing the default logger and subscribing to job events.
36+ # This method replaces GoodJob's logger and logs its thread errors as structured lines.
5037 #
5138 # @param config [LogStruct::Configuration] The LogStruct configuration
5239 # @return [Boolean, nil] Returns true if setup was successful, nil if skipped
@@ -59,13 +46,55 @@ def self.setup(config)
5946 # Replace GoodJob's logger with our structured logger
6047 configure_logger
6148
62- # Subscribe to GoodJob's ActiveSupport notifications
63- subscribe_to_notifications
49+ compact_error_lines
6450
6551 true
6652 end
6753
68- @thread_error_handler = T . let ( nil , T . nilable ( T . proc . params ( exception : StandardError ) . void ) )
54+ # GoodJob::LogSubscriber logs errors as text with the whole backtrace in the message (over 10KB).
55+ # GoodJob passes every error its job tasks raise, every systemd watchdog error and every notifier
56+ # error except connection errors to `on_thread_error`, which LogThreadErrors logs as a structured
57+ # line, so GoodJob's own lines for those are off. The notifier passes connection errors on only
58+ # once they repeat, so its line for them is kept, without the backtrace.
59+ module CompactErrorLines
60+ extend T ::Sig
61+ extend T ::Helpers
62+
63+ requires_ancestor { ::GoodJob ::LogSubscriber }
64+
65+ sig { params ( _event : T . untyped ) . void }
66+ def finished_job_task ( _event )
67+ end
68+
69+ sig { params ( event : T . untyped ) . void }
70+ def notifier_notify_error ( event )
71+ exception = event . payload [ :error ]
72+ return unless exception
73+ return unless LogStruct ::Integrations ::GoodJob . notifier_connection_error? ( exception )
74+
75+ error { "Notifier errored: #{ exception . class } : #{ exception } " }
76+ end
77+
78+ sig { params ( _event : T . untyped ) . void }
79+ def systemd_watchdog_error ( _event )
80+ end
81+ end
82+
83+ # Logs each thread error as a structured line, then calls the handler the app set as
84+ # `on_thread_error` (GoodJob's README sets one to report errors to an error tracker). Every
85+ # GoodJob component passes its thread errors to `GoodJob._on_thread_error`, so this works
86+ # whenever the app sets its handler: GoodJob's engine applies config.good_job.on_thread_error
87+ # after LogStruct's setup. GoodJob passes any Exception its handled_exceptions setting lets
88+ # through, not only StandardErrors.
89+ module LogThreadErrors
90+ extend T ::Sig
91+
92+ sig { params ( exception : Exception ) . void }
93+ def _on_thread_error ( exception )
94+ LogStruct ::Integrations ::GoodJob . log_thread_error ( exception )
95+ super
96+ end
97+ end
6998
7099 # Configure GoodJob to use LogStruct's structured logger
71100 sig { void }
@@ -78,40 +107,70 @@ def self.configure_logger
78107 # Replace GoodJob.logger with our structured logger if GoodJob is available
79108 if goodjob_module . respond_to? ( :logger= )
80109 goodjob_module . logger = LogStruct ::Integrations ::GoodJob ::Logger . new ( "GoodJob" )
81- end
82110
83- # Log thread errors, after calling the handler the app set (GoodJob's README sets one
84- # to report them to an error tracker). Setting up again keeps the one handler.
85- if goodjob_module . respond_to? ( :on_thread_error= )
86- app_handler = goodjob_module . on_thread_error
87- return if app_handler &.equal? ( @thread_error_handler )
88-
89- @thread_error_handler = -> ( exception ) do
90- app_handler &.call ( exception )
91- log_entry = LogStruct ::Log ::GoodJob ::Error . new (
92- error_class : exception . class . name ,
93- error_message : exception . message ,
94- backtrace : exception . backtrace ,
95- process_id : ::Process . pid ,
96- thread_id : Thread . current . object_id . to_s ( 36 )
97- )
98- goodjob_module . logger . error ( log_entry )
111+ # GoodJob's executable adds a plain text STDOUT logger unless GoodJob.logger already writes
112+ # to STDOUT, which it can't tell for this logger
113+ if defined? ( ::GoodJob ::CLI ) && ::GoodJob ::CLI . respond_to? ( :log_to_stdout= )
114+ ::GoodJob ::CLI . log_to_stdout = false
99115 end
100- goodjob_module . on_thread_error = @thread_error_handler
101116 end
117+
118+ configure_thread_error_handler
102119 end
103120
104- # Subscribe to GoodJob's ActiveSupport notifications
105121 sig { void }
106- def self . subscribe_to_notifications
122+ def self . configure_thread_error_handler
107123 return unless defined? ( ::GoodJob )
108124
109- # Subscribe to our custom log subscriber for GoodJob events
110- LogStruct ::Integrations ::GoodJob ::LogSubscriber . attach_to :good_job
125+ # Not GoodJob, which here is this module
126+ goodjob_singleton = ::GoodJob . singleton_class
127+ return unless goodjob_singleton . method_defined? ( :_on_thread_error )
128+
129+ goodjob_singleton . prepend ( LogThreadErrors ) unless goodjob_singleton . ancestors . include? ( LogThreadErrors )
130+ end
131+
132+ # A failed job's exception was already logged by the ActiveJob integration, with the job's
133+ # ID and class, so it isn't logged again here
134+ sig { params ( exception : Exception ) . void }
135+ def self . log_thread_error ( exception )
136+ return if LogStruct ::LoggedErrors . logged? ( exception )
137+
138+ ::GoodJob . logger . error (
139+ LogStruct ::Log ::GoodJob ::Error . new (
140+ error_class : exception . class . name || exception . class . to_s ,
141+ error_message : exception . message ,
142+ backtrace : exception . backtrace ,
143+ level : LogStruct ::Level ::Error ,
144+ process_id : ::Process . pid ,
145+ thread_id : Thread . current . object_id . to_s ( 36 )
146+ )
147+ )
148+ end
149+
150+ # The errors GoodJob's notifier holds back until they repeat (GoodJob::Notifier::CONNECTION_ERRORS)
151+ sig { params ( exception : Exception ) . returns ( T ::Boolean ) }
152+ def self . notifier_connection_error? ( exception )
153+ return true unless defined? ( ::GoodJob ::Notifier ::CONNECTION_ERRORS )
154+
155+ ::GoodJob ::Notifier ::CONNECTION_ERRORS . any? do |error_name |
156+ error_class = error_name . safe_constantize
157+ error_class . is_a? ( Class ) && exception . is_a? ( error_class )
158+ end
159+ end
160+
161+ # Only when LogThreadErrors logs thread errors instead
162+ sig { void }
163+ def self . compact_error_lines
164+ return unless defined? ( ::GoodJob ::LogSubscriber )
165+ return unless ::GoodJob . singleton_class . ancestors . include? ( LogThreadErrors )
166+
167+ log_subscriber = ::GoodJob ::LogSubscriber
168+ log_subscriber . prepend ( CompactErrorLines ) unless log_subscriber . ancestors . include? ( CompactErrorLines )
111169 end
112170
113171 private_class_method :configure_logger
114- private_class_method :subscribe_to_notifications
172+ private_class_method :compact_error_lines
173+ private_class_method :configure_thread_error_handler
115174 end
116175 end
117176end
0 commit comments