dispatch-rails 0.7.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.
@@ -0,0 +1,110 @@
1
+ require "zlib"
2
+
3
+ module Dispatch
4
+ module Rails
5
+ # Process-global, thread-safe accumulator of per-transaction request/error
6
+ # counts, bucketed into fixed time windows. A background thread flushes
7
+ # completed windows to Dispatch as a single small POST each — so the wire cost
8
+ # is one request per window, not one per HTTP request. The counts are the raw
9
+ # material for the server-side confound guard.
10
+ class HeartbeatAggregator
11
+ class << self
12
+ def instance
13
+ @instance ||= new
14
+ end
15
+
16
+ # Test seam.
17
+ def reset!
18
+ @instance&.stop
19
+ @instance = nil
20
+ end
21
+ end
22
+
23
+ def initialize
24
+ @mutex = Mutex.new
25
+ # { window_start_epoch => { "controller#action" => { requests:, errors: } } }
26
+ @windows = Hash.new { |h, w| h[w] = Hash.new { |t, name| t[name] = { requests: 0, errors: 0 } } }
27
+ end
28
+
29
+ def record(transaction:, errored:, now: Time.now)
30
+ return if transaction.to_s.empty?
31
+
32
+ @mutex.synchronize do
33
+ counts = @windows[window_for(now)][transaction]
34
+ counts[:requests] += 1
35
+ counts[:errors] += 1 if errored
36
+ end
37
+ ensure_flusher
38
+ end
39
+
40
+ # Payloads for every window whose time has fully elapsed; those windows are
41
+ # then dropped. Incomplete (current) windows are left to keep accumulating —
42
+ # unless include_current, which drains everything (the shutdown path, where
43
+ # waiting for the window to elapse means losing it).
44
+ def flush(now: Time.now, include_current: false)
45
+ @mutex.synchronize do
46
+ ready = @windows.keys
47
+ ready = ready.select { |w| w + window_seconds <= now.to_i } unless include_current
48
+ payloads = ready.map { |w| build_payload(w) }
49
+ ready.each { |w| @windows.delete(w) }
50
+ payloads
51
+ end
52
+ end
53
+
54
+ # Flush and ship — the unit of work the background thread repeats.
55
+ def deliver_ready(now: Time.now)
56
+ flush(now: now).each { |payload| Transport.instance.deliver_heartbeat(payload) }
57
+ end
58
+
59
+ # Ship everything, including the still-open window. Called at process exit
60
+ # so a deploy/restart doesn't drop the final window of traffic counts — the
61
+ # window the confound guard needs most.
62
+ def deliver_all(now: Time.now)
63
+ flush(now: now, include_current: true).each { |payload| Transport.instance.deliver_heartbeat(payload) }
64
+ end
65
+
66
+ def stop
67
+ @flusher&.kill
68
+ @flusher = nil
69
+ end
70
+
71
+ private
72
+
73
+ def build_payload(window)
74
+ transactions = @windows[window].transform_values do |c|
75
+ { "requests" => c[:requests], "errors" => c[:errors] }
76
+ end
77
+ { "window_start" => window, "transactions" => transactions }
78
+ end
79
+
80
+ def window_seconds
81
+ seconds = Dispatch::Rails.configuration.heartbeat_flush_seconds.to_i
82
+ seconds.positive? ? seconds : 60
83
+ end
84
+
85
+ def window_for(now)
86
+ (now.to_i / window_seconds) * window_seconds
87
+ end
88
+
89
+ def ensure_flusher
90
+ return if @flusher&.alive?
91
+
92
+ @mutex.synchronize do
93
+ return if @flusher&.alive?
94
+
95
+ @flusher = Thread.new do
96
+ loop do
97
+ sleep(window_seconds)
98
+ begin
99
+ deliver_ready
100
+ rescue StandardError => e
101
+ warn "[dispatch-rails] heartbeat flush failed: #{e.class}: #{e.message}"
102
+ end
103
+ end
104
+ end
105
+ @flusher.name = "dispatch-heartbeat-flusher" if @flusher.respond_to?(:name=)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,60 @@
1
+ require "zlib"
2
+
3
+ module Dispatch
4
+ module Rails
5
+ # Counts every request per controller#action into the HeartbeatAggregator, so
6
+ # Dispatch can later tell whether a code path is still being exercised. A 5xx
7
+ # (or a propagating exception) counts as an errored request; everything else is
8
+ # a success. No-ops entirely unless traffic tracking is enabled, and never
9
+ # affects the response — recording failures are swallowed.
10
+ class HeartbeatMiddleware
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ status, headers, body = @app.call(env)
17
+ record(env, errored: status.to_i >= 500)
18
+ [status, headers, body]
19
+ rescue Exception # rubocop:disable Lint/RescueException
20
+ record(env, errored: true) # the request happened and failed
21
+ raise
22
+ end
23
+
24
+ private
25
+
26
+ def record(env, errored:)
27
+ config = Dispatch::Rails.configuration
28
+ return unless config.traffic_tracking_enabled?
29
+
30
+ transaction = transaction_for(env)
31
+ return if transaction.nil? || sampled_out?(config, transaction)
32
+
33
+ HeartbeatAggregator.instance.record(transaction: transaction, errored: errored)
34
+ rescue StandardError => e
35
+ warn "[dispatch-rails] heartbeat record failed: #{e.class}: #{e.message}"
36
+ end
37
+
38
+ def transaction_for(env)
39
+ ctrl = env["action_controller.instance"]
40
+ return nil unless ctrl
41
+
42
+ parts = [ctrl.try(:controller_name), ctrl.try(:action_name)].compact
43
+ parts.empty? ? nil : parts.join("#")
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ # Independent of error_sample_rate; deterministic on transaction so a given
49
+ # action is always tracked-or-not (consistent counts), and at 1.0 nothing is
50
+ # dropped.
51
+ def sampled_out?(config, transaction)
52
+ rate = config.traffic_sample_rate.to_f
53
+ return false if rate >= 1.0
54
+ return true if rate <= 0.0
55
+
56
+ (Zlib.crc32(transaction) % 100) >= (rate * 100)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,20 @@
1
+ module Dispatch
2
+ module Rails
3
+ # Innermost Rack middleware: it wraps the app directly, so an unhandled
4
+ # exception is seen here (with the full request env, including the controller
5
+ # instance for user resolution) before any exception-rendering middleware.
6
+ # We capture and re-raise — never swallow.
7
+ class Middleware
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ @app.call(env)
14
+ rescue Exception => e # rubocop:disable Lint/RescueException
15
+ Dispatch::Rails::Reporter.capture(e, handled: false, env: env)
16
+ raise
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ module Dispatch
2
+ module Rails
3
+ # Captures rake task failures. Rake rescues the exception itself
4
+ # (display_error_message, then exit(false)), so the at_exit hook only ever
5
+ # sees SystemExit — this is the one place the real exception is visible,
6
+ # with the failing command attached. Capture enqueues; the at_exit flush
7
+ # drains the queue before the process dies.
8
+ module RakeHandler
9
+ def display_error_message(ex)
10
+ Dispatch::Rails::Reporter.capture(
11
+ ex,
12
+ handled: false,
13
+ context: { tags: { source: "rake", command: "rake #{ARGV.join(' ')}".strip } }
14
+ )
15
+ super
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,93 @@
1
+ require "zlib"
2
+
3
+ module Dispatch
4
+ module Rails
5
+ # The capture entry point shared by the middleware and the error subscriber.
6
+ # Resolves the affected user, builds the event, applies client-side sampling
7
+ # and the before_send hook, and hands it to the transport. Never raises.
8
+ module Reporter
9
+ CAPTURED_IVAR = :@__dispatch_captured
10
+
11
+ module_function
12
+
13
+ def capture(exception, handled:, env: nil, context: {}, level: "error")
14
+ config = Dispatch::Rails.configuration
15
+ return unless config.error_tracking_enabled?
16
+ return unless config.environment_enabled?
17
+ return if already_captured?(exception)
18
+ return if sampled_out?(config)
19
+
20
+ mark_captured(exception)
21
+ user = resolve_user(config, env, context)
22
+ tags = merged_tags(config, env, context)
23
+ event = EventBuilder.call(exception, handled: handled, env: env, user: user,
24
+ tags: tags, level: level)
25
+ event = config.before_send.call(event) if config.before_send.respond_to?(:call)
26
+ return if event.nil?
27
+
28
+ Transport.instance.deliver(event)
29
+ rescue StandardError => e
30
+ warn "[dispatch-rails] capture failed: #{e.class}: #{e.message}"
31
+ nil
32
+ end
33
+
34
+ # Mark the exception object so the same instance reported again (e.g. by the
35
+ # Rails executor after our middleware already handled it) isn't double-sent.
36
+ def mark_captured(exception)
37
+ exception.instance_variable_set(CAPTURED_IVAR, true)
38
+ rescue StandardError
39
+ nil
40
+ end
41
+
42
+ def already_captured?(exception)
43
+ exception.instance_variable_defined?(CAPTURED_IVAR)
44
+ rescue StandardError
45
+ false
46
+ end
47
+
48
+ def sampled_out?(config)
49
+ rate = config.error_sample_rate.to_f
50
+ return false if rate >= 1.0
51
+ return true if rate <= 0.0
52
+
53
+ rand > rate
54
+ end
55
+
56
+ # Tags sent with the event: the host app's explicit context[:tags] merged
57
+ # over whatever the config.context lambda resolves from the controller (an
58
+ # API-only app's seam for X-API-Key user, X-Player-ID, etc.). The explicit
59
+ # context[:tags] wins on conflict.
60
+ def merged_tags(config, env, context)
61
+ base = resolve_context(config, env)
62
+ base.merge(context[:tags] || {})
63
+ rescue StandardError
64
+ context[:tags] || {}
65
+ end
66
+
67
+ def resolve_context(config, env)
68
+ return {} unless config.context.respond_to?(:call)
69
+
70
+ controller = env && env["action_controller.instance"]
71
+ result = config.context.call(controller)
72
+ result.is_a?(Hash) ? result : {}
73
+ rescue StandardError
74
+ {}
75
+ end
76
+
77
+ # Reuse the SAME config.user lambda the widget uses. In a request we have the
78
+ # controller instance in the Rack env, so the lambda's `ctx.current_user`
79
+ # works exactly as configured.
80
+ def resolve_user(config, env, context)
81
+ return context[:user] if context[:user]
82
+ return nil unless env && config.user.respond_to?(:call)
83
+
84
+ controller = env["action_controller.instance"]
85
+ return nil unless controller
86
+
87
+ config.user.call(controller)
88
+ rescue StandardError
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,107 @@
1
+ require "json"
2
+ require "cgi"
3
+
4
+ module Dispatch
5
+ module Rails
6
+ # Opt-in Rack middleware (config.structured_error_responses). On 4xx/5xx
7
+ # responses it stamps the Rails request id — and, when a report base URL is
8
+ # known, a report URL — into response headers, and optionally into JSON error
9
+ # bodies. This is the API-only analogue of the bug-report widget: it hands the
10
+ # caller a correlation id they can quote when filing a curated report, which
11
+ # the Dispatch server then links back to the auto-captured error.
12
+ #
13
+ # It is mounted just outside ActionDispatch::ShowExceptions so it sees the
14
+ # FINAL rendered error response — whether the app rescued the error itself
15
+ # (a normal 4xx) or it propagated and ShowExceptions rendered the 500. It
16
+ # never changes the status code, never swallows a propagating exception, and
17
+ # passes through untouched anything it cannot safely parse.
18
+ class ResponseAnnotator
19
+ HEADER_REQUEST_ID = "X-Dispatch-Request-Id"
20
+ HEADER_REPORT_URL = "X-Dispatch-Report-Url"
21
+ BODY_REQUEST_ID = "dispatch_request_id"
22
+ BODY_REPORT_URL = "dispatch_report_url"
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ # @app.call is deliberately outside the rescue: if ShowExceptions re-raises
30
+ # (e.g. show_exceptions disabled, or in tests), the exception must keep
31
+ # propagating — we never swallow it.
32
+ status, headers, body = @app.call(env)
33
+ begin
34
+ annotate(env, status, headers, body)
35
+ rescue StandardError => e
36
+ warn "[dispatch-rails] response annotation failed: #{e.class}: #{e.message}"
37
+ [status, headers, body]
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def annotate(env, status, headers, body)
44
+ config = Dispatch::Rails.configuration
45
+ return [status, headers, body] unless config.structured_error_responses
46
+ return [status, headers, body] unless status.to_i >= 400
47
+
48
+ request_id = env["action_dispatch.request_id"]
49
+ return [status, headers, body] if request_id.to_s.empty?
50
+
51
+ report_url = report_url_for(config, request_id)
52
+ headers[HEADER_REQUEST_ID] = request_id
53
+ headers[HEADER_REPORT_URL] = report_url if report_url
54
+
55
+ return [status, headers, body] unless config.annotate_error_body
56
+
57
+ annotate_body(status, headers, body, request_id, report_url)
58
+ end
59
+
60
+ def report_url_for(config, request_id)
61
+ base = config.effective_report_base_url
62
+ return nil if base.to_s.empty?
63
+
64
+ "#{base}/report?request_id=#{CGI.escape(request_id)}"
65
+ end
66
+
67
+ # Merge the correlation fields into a JSON Hash body. Anything that isn't a
68
+ # JSON object (HTML error pages, JSON arrays, malformed bodies) is returned
69
+ # unchanged. Content-Length is rewritten so the server doesn't truncate.
70
+ def annotate_body(status, headers, body, request_id, report_url)
71
+ return [status, headers, body] unless json_content?(headers)
72
+
73
+ buffer = +""
74
+ body.each { |part| buffer << part.to_s }
75
+ body.close if body.respond_to?(:close)
76
+
77
+ out = augmented_json(buffer, request_id, report_url) || buffer
78
+ replace_content_length!(headers, out.bytesize)
79
+ [status, headers, [out]]
80
+ end
81
+
82
+ def augmented_json(buffer, request_id, report_url)
83
+ parsed = JSON.parse(buffer)
84
+ return nil unless parsed.is_a?(Hash)
85
+
86
+ parsed[BODY_REQUEST_ID] = request_id
87
+ parsed[BODY_REPORT_URL] = report_url if report_url
88
+ JSON.generate(parsed)
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+
93
+ def json_content?(headers)
94
+ content_type = headers["Content-Type"] || headers["content-type"]
95
+ content_type.to_s.include?("json")
96
+ end
97
+
98
+ # Replace Content-Length regardless of header casing (Rack 3 lowercases),
99
+ # so a grown/shrunk body can't be truncated by a stale length.
100
+ def replace_content_length!(headers, bytesize)
101
+ headers.delete("Content-Length")
102
+ headers.delete("content-length")
103
+ headers["Content-Length"] = bytesize.to_s
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,134 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module Dispatch
6
+ module Rails
7
+ # Ships events to Dispatch off the request path: a single background worker
8
+ # drains a bounded queue and POSTs each event. Bounded so a flood can never
9
+ # grow memory without limit (excess events are dropped, not blocking).
10
+ class Transport
11
+ QUEUE_LIMIT = 100
12
+ OPEN_TIMEOUT = 2
13
+ READ_TIMEOUT = 5
14
+
15
+ # Sentinel pushed by #flush. The worker signals it when popped, which
16
+ # guarantees every event enqueued before it has been fully sent.
17
+ class FlushSignal
18
+ def initialize
19
+ @latch = Queue.new
20
+ end
21
+
22
+ def done!
23
+ @latch << true
24
+ end
25
+
26
+ def wait(timeout)
27
+ !@latch.pop(timeout: timeout).nil?
28
+ end
29
+ end
30
+
31
+ class << self
32
+ def instance
33
+ @instance ||= new
34
+ end
35
+
36
+ # Test seam.
37
+ def reset!
38
+ @instance = nil
39
+ end
40
+ end
41
+
42
+ def initialize
43
+ @queue = Queue.new
44
+ @mutex = Mutex.new
45
+ end
46
+
47
+ def deliver(event)
48
+ return if @queue.size >= QUEUE_LIMIT
49
+
50
+ ensure_worker
51
+ @queue << event
52
+ true
53
+ end
54
+
55
+ # Block until everything queued (and in flight) has been sent, or the
56
+ # deadline passes. Returns true when fully drained. Called at process
57
+ # exit so a shutdown doesn't drop already-captured events.
58
+ def flush(timeout: 3)
59
+ return true if @queue.empty? && @worker.nil?
60
+
61
+ signal = FlushSignal.new
62
+ ensure_worker
63
+ @queue << signal
64
+ signal.wait(timeout)
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ # Synchronous send of an exception event — used by tests and as the worker's
70
+ # unit of work. Posts to the Sentry-compatible /store endpoint.
71
+ def send_now(event)
72
+ post(Dispatch::Rails.configuration.effective_error_endpoint, event)
73
+ end
74
+
75
+ # Synchronous send of one flush window of traffic counts. Best-effort: a
76
+ # dropped heartbeat just means the confound guard has one fewer data point.
77
+ def deliver_heartbeat(payload)
78
+ post(Dispatch::Rails.configuration.effective_heartbeat_endpoint, payload)
79
+ end
80
+
81
+ # Synchronous curated-ticket POST backing Dispatch::Rails.report. Posts to
82
+ # the tickets endpoint and returns the parsed response Hash
83
+ # ({ "id", "status", "url" }) on success, or nil on any failure.
84
+ def post_ticket(payload)
85
+ response = post(Dispatch::Rails.configuration.endpoint, payload)
86
+ return nil unless response.is_a?(Net::HTTPSuccess)
87
+
88
+ JSON.parse(response.body)
89
+ rescue StandardError
90
+ nil
91
+ end
92
+
93
+ private
94
+
95
+ # Shared Bearer-authenticated JSON POST. Returns the Net::HTTP response, or
96
+ # nil if the request itself failed. Never raises.
97
+ def post(endpoint, payload)
98
+ config = Dispatch::Rails.configuration
99
+ uri = URI.parse(endpoint)
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = uri.scheme == "https"
102
+ http.open_timeout = OPEN_TIMEOUT
103
+ http.read_timeout = READ_TIMEOUT
104
+
105
+ request = Net::HTTP::Post.new(uri.request_uri)
106
+ request["Content-Type"] = "application/json"
107
+ request["Authorization"] = "Bearer #{config.api_key}"
108
+ request["X-Dispatch-Sdk"] = "dispatch-rails/#{Dispatch::Rails::VERSION}"
109
+ request.body = JSON.generate(payload)
110
+
111
+ http.request(request)
112
+ rescue StandardError => e
113
+ warn "[dispatch-rails] failed to POST to #{endpoint}: #{e.class}: #{e.message}"
114
+ nil
115
+ end
116
+
117
+ def ensure_worker
118
+ return if @worker&.alive?
119
+
120
+ @mutex.synchronize do
121
+ return if @worker&.alive?
122
+
123
+ @worker = Thread.new do
124
+ loop do
125
+ item = @queue.pop
126
+ item.is_a?(FlushSignal) ? item.done! : send_now(item)
127
+ end
128
+ end
129
+ @worker.name = "dispatch-error-transport" if @worker.respond_to?(:name=)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,5 @@
1
+ module Dispatch
2
+ module Rails
3
+ VERSION = "0.7.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,108 @@
1
+ require "dispatch/rails/version"
2
+ require "dispatch/rails/configuration"
3
+ require "dispatch/rails/event_builder"
4
+ require "dispatch/rails/transport"
5
+ require "dispatch/rails/reporter"
6
+ require "dispatch/rails/middleware"
7
+ require "dispatch/rails/response_annotator"
8
+ require "dispatch/rails/heartbeat_aggregator"
9
+ require "dispatch/rails/heartbeat_middleware"
10
+ require "dispatch/rails/error_subscriber"
11
+ require "dispatch/rails/rake_handler"
12
+ require "dispatch/rails/engine"
13
+
14
+ module Dispatch
15
+ module Rails
16
+ class << self
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ def configure
22
+ yield(configuration)
23
+ end
24
+
25
+ def reset!
26
+ @configuration = Configuration.new
27
+ end
28
+
29
+ # Manually report a handled exception, e.g. inside a rescue:
30
+ # rescue => e
31
+ # Dispatch::Rails.capture_exception(e, context: { tags: { area: "import" } })
32
+ # end
33
+ def capture_exception(exception, env: nil, context: {}, level: "error")
34
+ Reporter.capture(exception, handled: true, env: env, context: context, level: level)
35
+ end
36
+
37
+ # File a curated bug report (a ticket) programmatically — the API-only
38
+ # analogue of a human clicking the widget. Ideal for an AI agent or a rescue
39
+ # block that wants to turn a failure into a tracked, human-readable report.
40
+ # Pass a correlation_id (e.g. request.request_id) to link the report to an
41
+ # already-captured error. Synchronous — returns the created ticket's
42
+ # { "id", "status", "url" } Hash, or nil on failure. Never raises.
43
+ #
44
+ # Dispatch::Rails.report(
45
+ # description: "Nightly import aborted: upstream returned 502",
46
+ # severity: "high",
47
+ # correlation_id: request.request_id,
48
+ # metadata: { job: "ImportJob" }
49
+ # )
50
+ def report(description:, severity: nil, source: "api", metadata: {}, reporter: nil, correlation_id: nil)
51
+ return nil unless configuration.configured?
52
+
53
+ meta = (metadata || {}).dup
54
+ meta[:correlation_id] = correlation_id if correlation_id
55
+ ticket = {
56
+ description: description, source: source, severity: severity,
57
+ metadata: meta, reporter: reporter
58
+ }.compact
59
+ Transport.instance.post_ticket(ticket: ticket)
60
+ end
61
+
62
+ # Process-exit safety net: report the exception that is killing the
63
+ # process (a crash during boot, a dying runner/script), then ship the
64
+ # in-progress heartbeat window and drain the transport queue so
65
+ # already-captured events survive the shutdown. Exceptions captured
66
+ # upstream (middleware, rake handler) carry the Reporter dedup marker
67
+ # and aren't re-sent.
68
+ def install_at_exit_callback
69
+ return if @at_exit_installed
70
+
71
+ @at_exit_installed = true
72
+ at_exit { handle_at_exit($!) }
73
+ end
74
+
75
+ # The at_exit body, extracted so it can be exercised in tests.
76
+ def handle_at_exit(exception = nil)
77
+ if exception && !ignored_exit_exception?(exception) && configuration.capture_at_exit
78
+ Reporter.capture(exception, handled: false, context: { tags: { source: "at_exit" } })
79
+ end
80
+
81
+ timeout = configuration.shutdown_timeout.to_f
82
+ return unless timeout.positive?
83
+
84
+ HeartbeatAggregator.instance.deliver_all
85
+ Transport.instance.flush(timeout: timeout)
86
+ end
87
+
88
+ # Normal exits and SIGTERM-driven graceful shutdowns (deploys,
89
+ # scale-downs) are not crashes.
90
+ def ignored_exit_exception?(exception)
91
+ return true if exception.is_a?(SystemExit)
92
+ return false unless exception.is_a?(SignalException)
93
+
94
+ name = exception.respond_to?(:signm) ? exception.signm.to_s : exception.to_s
95
+ name.end_with?("TERM")
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ # Installed at require time (not in a Rails initializer) so a crash during
102
+ # boot — before any initializer runs — is still reported.
103
+ Dispatch::Rails.install_at_exit_callback
104
+
105
+ # Rake is already loaded by the time a task boots the app (`rake t` /
106
+ # `bin/rails t` load the Rakefile, which loads the environment), so prepending
107
+ # here catches task failures. No-op outside rake.
108
+ Rake::Application.prepend(Dispatch::Rails::RakeHandler) if defined?(::Rake::Application)