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.
- checksums.yaml +7 -0
- data/README.md +227 -0
- data/app/assets/javascripts/dispatch/error_tracker.js +152 -0
- data/app/assets/javascripts/dispatch/widget.js +293 -0
- data/app/helpers/dispatch/widget_helper.rb +65 -0
- data/app/views/dispatch/_error_tracker.html.erb +9 -0
- data/app/views/dispatch/_widget.html.erb +71 -0
- data/lib/dispatch/rails/configuration.rb +135 -0
- data/lib/dispatch/rails/engine.rb +61 -0
- data/lib/dispatch/rails/error_subscriber.rb +32 -0
- data/lib/dispatch/rails/event_builder.rb +257 -0
- data/lib/dispatch/rails/heartbeat_aggregator.rb +110 -0
- data/lib/dispatch/rails/heartbeat_middleware.rb +60 -0
- data/lib/dispatch/rails/middleware.rb +20 -0
- data/lib/dispatch/rails/rake_handler.rb +19 -0
- data/lib/dispatch/rails/reporter.rb +93 -0
- data/lib/dispatch/rails/response_annotator.rb +107 -0
- data/lib/dispatch/rails/transport.rb +134 -0
- data/lib/dispatch/rails/version.rb +5 -0
- data/lib/dispatch-rails.rb +108 -0
- metadata +83 -0
|
@@ -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,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)
|