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,65 @@
|
|
|
1
|
+
module Dispatch
|
|
2
|
+
module WidgetHelper
|
|
3
|
+
# Render the floating bug-report widget.
|
|
4
|
+
#
|
|
5
|
+
# Options:
|
|
6
|
+
# severity: one of "low", "medium", "high", "critical" — pre-classifies reports
|
|
7
|
+
# from this page (useful for marking a /checkout page as critical-by-default)
|
|
8
|
+
# labels: array of strings merged into metadata.labels so reports can be tagged per-surface
|
|
9
|
+
# extra_metadata: arbitrary hash merged into the metadata blob
|
|
10
|
+
def dispatch_widget_tag(severity: nil, labels: nil, extra_metadata: {})
|
|
11
|
+
config = Dispatch::Rails.configuration
|
|
12
|
+
return nil if config.errors_only? # headless / API-only apps have no widget
|
|
13
|
+
return nil unless config.configured?
|
|
14
|
+
|
|
15
|
+
user = config.user.respond_to?(:call) ? config.user.call(self) : nil
|
|
16
|
+
base_metadata = config.metadata.respond_to?(:call) ? config.metadata.call(self) : {}
|
|
17
|
+
|
|
18
|
+
page_metadata = base_metadata.to_h.merge(extra_metadata)
|
|
19
|
+
page_metadata[:labels] = Array(labels).map(&:to_s) if labels.present?
|
|
20
|
+
page_metadata[:severity_hint] = severity.to_s if severity.present?
|
|
21
|
+
|
|
22
|
+
render(
|
|
23
|
+
partial: "dispatch/widget",
|
|
24
|
+
locals: {
|
|
25
|
+
api_key: config.api_key,
|
|
26
|
+
endpoint: config.endpoint,
|
|
27
|
+
user: user,
|
|
28
|
+
metadata: page_metadata,
|
|
29
|
+
severity: severity&.to_s,
|
|
30
|
+
capture_console: config.capture_console,
|
|
31
|
+
capture_clicks: config.capture_clicks,
|
|
32
|
+
button_position: config.button_position
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Render the browser exception tracker (captures uncaught JS errors and
|
|
38
|
+
# unhandled promise rejections). Place once in your layout's <head>.
|
|
39
|
+
def dispatch_error_tracker_tag(tags: {})
|
|
40
|
+
config = Dispatch::Rails.configuration
|
|
41
|
+
return nil if config.errors_only? # no browser surface in an API-only app
|
|
42
|
+
return nil unless config.configured? && config.capture_browser_errors
|
|
43
|
+
return nil unless config.environment_enabled?
|
|
44
|
+
|
|
45
|
+
user = config.user.respond_to?(:call) ? config.user.call(self) : nil
|
|
46
|
+
normalized_user = user && { email: user[:email], id: user[:external_id] || user[:id] }.compact
|
|
47
|
+
|
|
48
|
+
render(
|
|
49
|
+
partial: "dispatch/error_tracker",
|
|
50
|
+
locals: {
|
|
51
|
+
error_config_json: {
|
|
52
|
+
endpoint: config.effective_error_endpoint,
|
|
53
|
+
apiKey: config.api_key,
|
|
54
|
+
environment: config.effective_environment,
|
|
55
|
+
release: config.release,
|
|
56
|
+
sampleRate: config.error_sample_rate,
|
|
57
|
+
captureClicks: config.capture_clicks,
|
|
58
|
+
user: normalized_user,
|
|
59
|
+
tags: tags
|
|
60
|
+
}.to_json
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script type="application/json" id="dispatch-error-config"><%= raw error_config_json.gsub("</", "<\\/") %></script>
|
|
2
|
+
<%# nonce: true picks up the host app's CSP nonce; the JSON config island above is non-executable and needs none %>
|
|
3
|
+
<%= javascript_tag type: "module", nonce: true do %>
|
|
4
|
+
if (!window.__dispatchErrorTrackerLoaded) {
|
|
5
|
+
import("/dispatch/error_tracker.js").catch(() => {
|
|
6
|
+
console.error("[dispatch-rails] failed to load error_tracker.js — ensure the engine assets are mounted");
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<div data-controller="dispatch-widget"
|
|
2
|
+
data-dispatch-widget-endpoint-value="<%= endpoint %>"
|
|
3
|
+
data-dispatch-widget-api-key-value="<%= api_key %>"
|
|
4
|
+
data-dispatch-widget-user-value="<%= user.to_json %>"
|
|
5
|
+
data-dispatch-widget-metadata-value="<%= metadata.to_json %>"
|
|
6
|
+
data-dispatch-widget-severity-value="<%= severity %>"
|
|
7
|
+
data-dispatch-widget-capture-console-value="<%= capture_console %>"
|
|
8
|
+
data-dispatch-widget-capture-clicks-value="<%= capture_clicks %>">
|
|
9
|
+
<button type="button"
|
|
10
|
+
data-action="click->dispatch-widget#open"
|
|
11
|
+
data-dispatch-widget-target="button"
|
|
12
|
+
aria-label="Send feedback"
|
|
13
|
+
style="position:fixed;<%= button_position.include?('bottom') ? 'bottom:24px' : 'top:24px' %>;<%= button_position.include?('right') ? 'right:24px' : 'left:24px' %>;width:56px;height:56px;border-radius:9999px;background:#2563eb;color:#fff;border:none;box-shadow:0 8px 24px rgba(37,99,235,0.35);cursor:pointer;font-size:24px;display:flex;align-items:center;justify-content:center;z-index:9999;">
|
|
14
|
+
💬
|
|
15
|
+
</button>
|
|
16
|
+
|
|
17
|
+
<div data-dispatch-widget-target="modal"
|
|
18
|
+
hidden
|
|
19
|
+
style="position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:10000;">
|
|
20
|
+
<div data-dispatch-widget-target="dropzone"
|
|
21
|
+
data-action="dragover->dispatch-widget#dragover dragleave->dispatch-widget#dragleave drop->dispatch-widget#drop paste->dispatch-widget#paste"
|
|
22
|
+
style="background:#0a0a0a;color:#e5e7eb;border:1px solid #1f2937;border-radius:12px;padding:24px;width:min(480px,calc(100% - 32px));font-family:ui-sans-serif,system-ui,sans-serif;">
|
|
23
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
|
24
|
+
<h2 style="font-family:ui-monospace,monospace;letter-spacing:0.05em;color:#60a5fa;margin:0;">SEND FEEDBACK</h2>
|
|
25
|
+
<button type="button" data-action="click->dispatch-widget#close" style="background:none;border:none;color:#9ca3af;font-size:20px;cursor:pointer;">×</button>
|
|
26
|
+
</div>
|
|
27
|
+
<p style="font-size:13px;color:#9ca3af;margin:0 0 12px;">Found a bug, want a feature, or think something should work differently? Tell us. We'll capture the URL, browser, and environment automatically.</p>
|
|
28
|
+
<textarea data-dispatch-widget-target="description"
|
|
29
|
+
rows="5"
|
|
30
|
+
placeholder="When I clicked Save, the page returned a 500… or: It'd be great if I could export this list as CSV."
|
|
31
|
+
style="width:100%;background:#0a0a0a;color:#e5e7eb;border:1px solid #1f2937;border-radius:6px;padding:10px;font-family:ui-monospace,monospace;font-size:13px;box-sizing:border-box;"></textarea>
|
|
32
|
+
|
|
33
|
+
<input type="file"
|
|
34
|
+
data-dispatch-widget-target="fileInput"
|
|
35
|
+
data-action="change->dispatch-widget#filesPicked"
|
|
36
|
+
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
37
|
+
multiple
|
|
38
|
+
hidden>
|
|
39
|
+
<div style="display:flex;align-items:center;gap:10px;margin-top:10px;flex-wrap:wrap;">
|
|
40
|
+
<button type="button"
|
|
41
|
+
data-action="click->dispatch-widget#openFilePicker"
|
|
42
|
+
data-dispatch-widget-target="attachButton"
|
|
43
|
+
style="padding:6px 12px;background:#1f2937;border:1px solid #374151;color:#e5e7eb;border-radius:6px;cursor:pointer;font-family:ui-monospace,monospace;font-size:12px;">📎 Attach screenshots (0/5)</button>
|
|
44
|
+
<span style="font-size:11px;color:#6b7280;">or paste / drag & drop · max 5, 5 MB each</span>
|
|
45
|
+
</div>
|
|
46
|
+
<div data-dispatch-widget-target="previews" style="display:flex;flex-wrap:wrap;gap:8px;margin-top:10px;"></div>
|
|
47
|
+
|
|
48
|
+
<div data-dispatch-widget-target="error" style="color:#f87171;font-size:12px;margin-top:8px;display:none;"></div>
|
|
49
|
+
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
|
|
50
|
+
<button type="button" data-action="click->dispatch-widget#close" style="padding:8px 14px;background:#1f2937;border:1px solid #374151;color:#e5e7eb;border-radius:6px;cursor:pointer;font-family:ui-monospace,monospace;font-size:13px;">Cancel</button>
|
|
51
|
+
<button type="button" data-action="click->dispatch-widget#submit" data-dispatch-widget-target="submit" style="padding:8px 14px;background:#2563eb;border:none;color:#fff;border-radius:6px;cursor:pointer;font-family:ui-monospace,monospace;font-size:13px;">Send</button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div data-dispatch-widget-target="toast"
|
|
57
|
+
hidden
|
|
58
|
+
style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#064e3b;color:#a7f3d0;border:1px solid #34d399;border-radius:6px;padding:10px 16px;font-family:ui-monospace,monospace;font-size:13px;z-index:10001;box-shadow:0 8px 24px rgba(0,0,0,0.4);">
|
|
59
|
+
✓ Feedback sent. Thanks!
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<%# nonce: true picks up the host app's CSP nonce; omitted when no nonce generator is configured %>
|
|
64
|
+
<%= javascript_tag type: "module", nonce: true do %>
|
|
65
|
+
if (!window.__dispatchWidgetLoaded) {
|
|
66
|
+
window.__dispatchWidgetLoaded = true;
|
|
67
|
+
import("/dispatch/widget.js").catch(() => {
|
|
68
|
+
console.error("[dispatch-rails] failed to load widget.js — make sure the engine assets are mounted");
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
<% end %>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Dispatch
|
|
4
|
+
module Rails
|
|
5
|
+
class Configuration
|
|
6
|
+
# Install mode. :widget is the default (a UI app embeds the bug-report
|
|
7
|
+
# button); :errors_only is for API-only / headless apps that have no UI to
|
|
8
|
+
# render into — server-side exception capture and manual reporting still
|
|
9
|
+
# work, but the widget and browser-error tags become no-ops.
|
|
10
|
+
MODES = %i[widget errors_only].freeze
|
|
11
|
+
|
|
12
|
+
# Bug-report widget
|
|
13
|
+
attr_accessor :api_key, :endpoint, :user, :metadata, :capture_console, :capture_clicks, :button_position
|
|
14
|
+
# Mode + API-only context
|
|
15
|
+
attr_accessor :mode, :context, :send_default_params
|
|
16
|
+
# Exception tracking
|
|
17
|
+
attr_accessor :capture_exceptions, :capture_browser_errors, :error_endpoint,
|
|
18
|
+
:environment, :release, :enabled_environments, :error_sample_rate, :before_send
|
|
19
|
+
# Process lifecycle (crash-at-exit capture, rake failures, shutdown flush)
|
|
20
|
+
attr_accessor :capture_at_exit, :shutdown_timeout
|
|
21
|
+
# Structured error responses (API-only)
|
|
22
|
+
attr_accessor :structured_error_responses, :annotate_error_body, :report_base_url
|
|
23
|
+
# Traffic heartbeats — per-transaction success counts that let the Dispatch
|
|
24
|
+
# server tell "the error stopped because we fixed it" from "…because the
|
|
25
|
+
# path went quiet" (the confound guard behind fix verification).
|
|
26
|
+
attr_accessor :capture_traffic, :traffic_sample_rate, :heartbeat_flush_seconds, :heartbeat_endpoint
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@api_key = nil
|
|
30
|
+
@endpoint = "https://dispatchit.app/api/v1/tickets"
|
|
31
|
+
@user = ->(_ctx) { nil }
|
|
32
|
+
@metadata = ->(_ctx) { {} }
|
|
33
|
+
@capture_console = false
|
|
34
|
+
@capture_clicks = true # track the last few clicks as a "user path" for context
|
|
35
|
+
@button_position = "bottom-right"
|
|
36
|
+
|
|
37
|
+
# Mode + API-only context
|
|
38
|
+
@mode = :widget
|
|
39
|
+
@context = ->(_ctx) { {} } # extra tags resolved from the controller (API key user, headers, …)
|
|
40
|
+
@send_default_params = false # opt-in: include Rails' filtered_parameters in the event
|
|
41
|
+
|
|
42
|
+
# Exception tracking defaults
|
|
43
|
+
@capture_exceptions = true
|
|
44
|
+
@capture_browser_errors = true
|
|
45
|
+
@error_endpoint = nil # derived from endpoint when nil
|
|
46
|
+
@environment = nil # derived from Rails.env when nil
|
|
47
|
+
@release = nil # e.g. ENV["GIT_SHA"]
|
|
48
|
+
@enabled_environments = %w[production staging]
|
|
49
|
+
@error_sample_rate = 1.0
|
|
50
|
+
@before_send = nil # ->(event) { event or nil to drop }
|
|
51
|
+
|
|
52
|
+
# Process lifecycle. Report the exception killing the process (a crash
|
|
53
|
+
# during boot, a dying runner) and drain the send queue before exit so
|
|
54
|
+
# deploys/restarts don't drop captured events.
|
|
55
|
+
@capture_at_exit = true
|
|
56
|
+
@shutdown_timeout = 3 # seconds to wait for the queue to drain at exit; 0 skips the flush
|
|
57
|
+
|
|
58
|
+
# Structured error responses (off by default — opt-in so we never alter a
|
|
59
|
+
# host app's error contract without being asked).
|
|
60
|
+
@structured_error_responses = false
|
|
61
|
+
@annotate_error_body = false # headers-only unless explicitly enabled
|
|
62
|
+
@report_base_url = nil # derived from endpoint host when nil
|
|
63
|
+
|
|
64
|
+
# Traffic heartbeats. On by default in enabled environments: aggregate
|
|
65
|
+
# counts only (one small POST per flush window, regardless of request
|
|
66
|
+
# volume), so the cost is negligible and the confound guard works out of
|
|
67
|
+
# the box. Sampling is independent of error_sample_rate.
|
|
68
|
+
@capture_traffic = true
|
|
69
|
+
@traffic_sample_rate = 1.0
|
|
70
|
+
@heartbeat_flush_seconds = 60
|
|
71
|
+
@heartbeat_endpoint = nil # derived from endpoint when nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def configured?
|
|
75
|
+
api_key.present? && endpoint.present?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def errors_only?
|
|
79
|
+
mode.to_sym == :errors_only
|
|
80
|
+
rescue StandardError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Where exception events are posted. Defaults to the same host as the widget
|
|
85
|
+
# endpoint with the path swapped to the Sentry-compatible /store endpoint.
|
|
86
|
+
def effective_error_endpoint
|
|
87
|
+
return @error_endpoint if @error_endpoint.present?
|
|
88
|
+
|
|
89
|
+
endpoint.to_s.sub(%r{/[^/]+\z}, "/store")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The base URL (scheme + host) where the Dispatch tenant lives, used to build
|
|
93
|
+
# the human-facing report link surfaced in structured error responses.
|
|
94
|
+
# Falls back to the origin of `endpoint` (https://dispatchit.app).
|
|
95
|
+
def effective_report_base_url
|
|
96
|
+
return @report_base_url.to_s.chomp("/") if @report_base_url.present?
|
|
97
|
+
|
|
98
|
+
uri = URI.parse(endpoint.to_s)
|
|
99
|
+
return nil if uri.host.nil?
|
|
100
|
+
|
|
101
|
+
port = uri.port && ![80, 443].include?(uri.port) ? ":#{uri.port}" : ""
|
|
102
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Where per-transaction traffic rollups are posted. Defaults to the same host
|
|
108
|
+
# as the widget endpoint with the path swapped to /heartbeats.
|
|
109
|
+
def effective_heartbeat_endpoint
|
|
110
|
+
return @heartbeat_endpoint if @heartbeat_endpoint.present?
|
|
111
|
+
|
|
112
|
+
endpoint.to_s.sub(%r{/[^/]+\z}, "/heartbeats")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def effective_environment
|
|
116
|
+
@environment.presence || (defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : "production")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def error_tracking_enabled?
|
|
120
|
+
configured? && @capture_exceptions
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Heartbeats piggyback on the same gating as error capture, plus their own
|
|
124
|
+
# toggle. Off in non-enabled environments (so dev/test never phone home).
|
|
125
|
+
def traffic_tracking_enabled?
|
|
126
|
+
@capture_traffic && error_tracking_enabled? && environment_enabled?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def environment_enabled?
|
|
130
|
+
list = Array(@enabled_environments).map(&:to_s)
|
|
131
|
+
list.empty? || list.include?(effective_environment)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "rails"
|
|
2
|
+
|
|
3
|
+
module Dispatch
|
|
4
|
+
module Rails
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace Dispatch::Rails
|
|
7
|
+
|
|
8
|
+
# Expose the widget/tracker helpers to HOST app views. The engine is
|
|
9
|
+
# isolated, so Rails won't share its helpers automatically — and registering
|
|
10
|
+
# from inside the helper file only works when something loads that file,
|
|
11
|
+
# which eager loading does in production but nothing does in development
|
|
12
|
+
# (the layout's dispatch_widget_tag call would raise NoMethodError there).
|
|
13
|
+
# to_prepare runs at boot and on every code reload, where autoloading the
|
|
14
|
+
# reloadable helper constant is permitted.
|
|
15
|
+
config.to_prepare do
|
|
16
|
+
ActiveSupport.on_load(:action_view) { include Dispatch::WidgetHelper }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "dispatch-rails.assets" do |app|
|
|
20
|
+
if app.config.respond_to?(:assets)
|
|
21
|
+
app.config.assets.paths << root.join("app/assets/javascripts").to_s
|
|
22
|
+
end
|
|
23
|
+
if defined?(::Importmap) && app.respond_to?(:importmap)
|
|
24
|
+
# Host app can pin "dispatch-widget" / "dispatch-error-tracker" in its importmap.
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Server-side exception capture. The middleware is added last (innermost),
|
|
29
|
+
# so it wraps the app directly and sees raw exceptions with full request
|
|
30
|
+
# context before any exception-rendering middleware.
|
|
31
|
+
initializer "dispatch-rails.error_capture" do |app|
|
|
32
|
+
app.config.middleware.use Dispatch::Rails::Middleware
|
|
33
|
+
|
|
34
|
+
# Catch background/non-request errors (ActiveJob, runners, handle blocks).
|
|
35
|
+
if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
|
|
36
|
+
::Rails.error.subscribe(Dispatch::Rails::ErrorSubscriber.new)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Per-transaction traffic heartbeats. Mounted unconditionally; the middleware
|
|
41
|
+
# no-ops at request time unless config.traffic_tracking_enabled? (false in
|
|
42
|
+
# dev/test), so it never phones home outside enabled environments.
|
|
43
|
+
initializer "dispatch-rails.traffic_heartbeats" do |app|
|
|
44
|
+
app.config.middleware.use Dispatch::Rails::HeartbeatMiddleware
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Structured error responses (API-only). Inserted just OUTSIDE
|
|
48
|
+
# ActionDispatch::ShowExceptions so it sees the final rendered error
|
|
49
|
+
# response — both app-rescued 4xx and propagated-then-rendered 500s — and so
|
|
50
|
+
# action_dispatch.request_id (set further out by RequestId) is populated.
|
|
51
|
+
# Mounted unconditionally; it no-ops at request time unless the host opted in
|
|
52
|
+
# via config.structured_error_responses, which sidesteps boot-order races
|
|
53
|
+
# with the host's initializer.
|
|
54
|
+
initializer "dispatch-rails.structured_responses" do |app|
|
|
55
|
+
app.config.middleware.insert_before(
|
|
56
|
+
ActionDispatch::ShowExceptions, Dispatch::Rails::ResponseAnnotator
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Dispatch
|
|
2
|
+
module Rails
|
|
3
|
+
# Subscribes to Rails.error (ActiveSupport::ErrorReporter). Catches errors
|
|
4
|
+
# outside the web request — ActiveJob failures, runners, and explicit
|
|
5
|
+
# Rails.error.handle/record blocks. Request errors already captured by the
|
|
6
|
+
# Rack middleware carry a marker and are skipped here, so nothing is
|
|
7
|
+
# double-counted.
|
|
8
|
+
class ErrorSubscriber
|
|
9
|
+
SEVERITY_TO_LEVEL = { error: "error", warning: "warning", info: "info" }.freeze
|
|
10
|
+
|
|
11
|
+
def report(error, handled:, severity: :error, context: {}, source: nil)
|
|
12
|
+
return if source.to_s.start_with?("dispatch")
|
|
13
|
+
|
|
14
|
+
Dispatch::Rails::Reporter.capture(
|
|
15
|
+
error,
|
|
16
|
+
handled: handled,
|
|
17
|
+
context: { tags: context_tags(context, source) },
|
|
18
|
+
level: SEVERITY_TO_LEVEL.fetch(severity, "error")
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def context_tags(context, source)
|
|
25
|
+
tags = {}
|
|
26
|
+
tags[:source] = source.to_s if source
|
|
27
|
+
tags[:job] = context[:job_class] if context.is_a?(Hash) && context[:job_class]
|
|
28
|
+
tags
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "socket"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Dispatch
|
|
6
|
+
module Rails
|
|
7
|
+
# Turns a Ruby exception (+ optional Rack env / user) into the Sentry-shaped
|
|
8
|
+
# event hash the Dispatch ingest endpoint understands. In-app frames are
|
|
9
|
+
# flagged and given source context (the lines around the failing line), which
|
|
10
|
+
# is what makes the AI fix pipeline's patches accurate.
|
|
11
|
+
class EventBuilder
|
|
12
|
+
CONTEXT_LINES = 5
|
|
13
|
+
MAX_CONTEXT_FRAMES = 12
|
|
14
|
+
MAX_FRAMES = 100
|
|
15
|
+
MAX_CAUSES = 5
|
|
16
|
+
MAX_PARAMS_BYTES = 8_000
|
|
17
|
+
SAFE_HEADERS = %w[User-Agent Referer Accept Content-Type Host X-Request-Id].freeze
|
|
18
|
+
|
|
19
|
+
def self.call(exception, handled:, env: nil, user: nil, tags: {}, level: "error")
|
|
20
|
+
new(exception, handled: handled, env: env, user: user, tags: tags, level: level).call
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(exception, handled:, env: nil, user: nil, tags: {}, level: "error")
|
|
24
|
+
@exception = exception
|
|
25
|
+
@handled = handled
|
|
26
|
+
@env = env
|
|
27
|
+
@user = user
|
|
28
|
+
@tags = tags || {}
|
|
29
|
+
@level = level
|
|
30
|
+
@source_cache = {}
|
|
31
|
+
@config = Dispatch::Rails.configuration
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
{
|
|
36
|
+
event_id: SecureRandom.uuid.delete("-"),
|
|
37
|
+
timestamp: Time.now.to_f,
|
|
38
|
+
platform: "ruby",
|
|
39
|
+
level: @level,
|
|
40
|
+
environment: @config.effective_environment,
|
|
41
|
+
release: @config.release,
|
|
42
|
+
server_name: hostname,
|
|
43
|
+
transaction: transaction, # Sentry's name for the controller#action
|
|
44
|
+
exception: { values: exception_values },
|
|
45
|
+
request: request_hash,
|
|
46
|
+
user: user_hash,
|
|
47
|
+
tags: tags_hash,
|
|
48
|
+
sdk: { name: "dispatch-rails", version: Dispatch::Rails::VERSION }
|
|
49
|
+
}.compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# The Rails request id (always present under Rails) doubles as the public
|
|
55
|
+
# correlation key surfaced in structured error responses, so a human-curated
|
|
56
|
+
# report can be tied back to this captured event server-side.
|
|
57
|
+
def request_id
|
|
58
|
+
return @request_id if defined?(@request_id)
|
|
59
|
+
|
|
60
|
+
@request_id = @env && @env["action_dispatch.request_id"]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# "players#show" — derived from the controller in the Rack env. nil for
|
|
64
|
+
# background/job errors where there is no controller.
|
|
65
|
+
def transaction
|
|
66
|
+
return @transaction if defined?(@transaction)
|
|
67
|
+
|
|
68
|
+
@transaction = begin
|
|
69
|
+
ctrl = controller_instance
|
|
70
|
+
parts = ctrl && [ctrl.try(:controller_name), ctrl.try(:action_name)].compact
|
|
71
|
+
parts.present? ? parts.join("#") : nil
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def controller_instance
|
|
78
|
+
@env && @env["action_controller.instance"]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Surface request_id and transaction as tags too (handy for filtering in the
|
|
82
|
+
# dashboard). The host app's explicit tags win on any key conflict.
|
|
83
|
+
def tags_hash
|
|
84
|
+
derived = { request_id: request_id, transaction: transaction }.compact
|
|
85
|
+
derived.merge(@tags)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def exception_values
|
|
89
|
+
chain = []
|
|
90
|
+
exc = @exception
|
|
91
|
+
MAX_CAUSES.times do
|
|
92
|
+
break if exc.nil?
|
|
93
|
+
|
|
94
|
+
chain << exc
|
|
95
|
+
exc = exc.cause
|
|
96
|
+
end
|
|
97
|
+
# Sentry expects oldest cause first, the raised exception last.
|
|
98
|
+
chain.reverse.map { |e| exception_value(e) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def exception_value(exc)
|
|
102
|
+
{
|
|
103
|
+
type: exc.class.name,
|
|
104
|
+
value: exc.message.to_s[0, 2_000],
|
|
105
|
+
mechanism: { type: "generic", handled: @handled },
|
|
106
|
+
stacktrace: { frames: frames_for(exc) }
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def frames_for(exc)
|
|
111
|
+
locations = exc.backtrace_locations
|
|
112
|
+
raw = locations || Array(exc.backtrace).map { |line| parse_line(line) }.compact
|
|
113
|
+
# newest-first → reverse to oldest-first (failing frame last, Sentry-style)
|
|
114
|
+
frames = raw.first(MAX_FRAMES).reverse.map { |loc| frame_for(loc) }.compact
|
|
115
|
+
annotate_context(frames)
|
|
116
|
+
frames
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def frame_for(loc)
|
|
120
|
+
if loc.respond_to?(:absolute_path)
|
|
121
|
+
path = loc.absolute_path || loc.path
|
|
122
|
+
{ abs_path: path, function: loc.label, lineno: loc.lineno }
|
|
123
|
+
else
|
|
124
|
+
loc # already a hash from parse_line
|
|
125
|
+
end.then do |h|
|
|
126
|
+
path = h[:abs_path]
|
|
127
|
+
{
|
|
128
|
+
abs_path: path,
|
|
129
|
+
filename: relative_path(path),
|
|
130
|
+
function: h[:function],
|
|
131
|
+
lineno: h[:lineno],
|
|
132
|
+
in_app: in_app?(path)
|
|
133
|
+
}.compact
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# "/path/to/file.rb:42:in `method'"
|
|
138
|
+
def parse_line(line)
|
|
139
|
+
match = line.match(/\A(.+?):(\d+):in [`'](.+)'\z/) || line.match(/\A(.+?):(\d+)\z/)
|
|
140
|
+
return nil unless match
|
|
141
|
+
|
|
142
|
+
{ abs_path: match[1], lineno: match[2].to_i, function: match[3] }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Add source context to the most-recent in-app frames (the tail of the array).
|
|
146
|
+
def annotate_context(frames)
|
|
147
|
+
budget = MAX_CONTEXT_FRAMES
|
|
148
|
+
frames.reverse_each do |frame|
|
|
149
|
+
next unless frame[:in_app] && budget.positive?
|
|
150
|
+
|
|
151
|
+
budget -= 1
|
|
152
|
+
add_source_context(frame)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def add_source_context(frame)
|
|
157
|
+
lines = source_lines(frame[:abs_path])
|
|
158
|
+
return unless lines && frame[:lineno]
|
|
159
|
+
|
|
160
|
+
idx = frame[:lineno] - 1
|
|
161
|
+
return if idx.negative? || idx >= lines.length
|
|
162
|
+
|
|
163
|
+
frame[:pre_context] = lines[[idx - CONTEXT_LINES, 0].max...idx].map(&:rstrip)
|
|
164
|
+
frame[:context_line] = lines[idx].rstrip
|
|
165
|
+
frame[:post_context] = lines[(idx + 1)..(idx + CONTEXT_LINES)].to_a.map(&:rstrip)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def source_lines(path)
|
|
169
|
+
return nil if path.nil? || !File.file?(path)
|
|
170
|
+
|
|
171
|
+
@source_cache[path] ||= File.readlines(path, chomp: true)
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def in_app?(path)
|
|
177
|
+
return false if path.nil?
|
|
178
|
+
|
|
179
|
+
path.start_with?(project_root) && !path.include?("/gems/") &&
|
|
180
|
+
!path.include?("/vendor/") && !path.include?("/ruby/")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def relative_path(path)
|
|
184
|
+
return path if path.nil?
|
|
185
|
+
|
|
186
|
+
path.start_with?(project_root) ? path[project_root.length..].sub(%r{\A/}, "") : path
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def project_root
|
|
190
|
+
@project_root ||= (defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root ? ::Rails.root.to_s : Dir.pwd)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def request_hash
|
|
194
|
+
return nil if @env.nil?
|
|
195
|
+
|
|
196
|
+
{
|
|
197
|
+
url: request_url,
|
|
198
|
+
method: @env["REQUEST_METHOD"],
|
|
199
|
+
query_string: @env["QUERY_STRING"].presence,
|
|
200
|
+
headers: safe_headers,
|
|
201
|
+
data: request_data,
|
|
202
|
+
env: { "REMOTE_ADDR" => @env["REMOTE_ADDR"] }.compact
|
|
203
|
+
}.compact
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Opt-in (config.send_default_params). Uses Rails' own filtered_parameters,
|
|
207
|
+
# so the host app's config.filter_parameters has already redacted secrets.
|
|
208
|
+
# Capped by byte size; the controller/action/format routing keys are dropped.
|
|
209
|
+
def request_data
|
|
210
|
+
return nil unless @config.send_default_params
|
|
211
|
+
|
|
212
|
+
ctrl = controller_instance
|
|
213
|
+
return nil unless ctrl.respond_to?(:request)
|
|
214
|
+
|
|
215
|
+
params = ctrl.request.filtered_parameters.except("controller", "action", "format")
|
|
216
|
+
return nil if params.blank?
|
|
217
|
+
|
|
218
|
+
JSON.generate(params).bytesize > MAX_PARAMS_BYTES ? { "_truncated" => true } : params
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def request_url
|
|
224
|
+
scheme = @env["rack.url_scheme"] || "http"
|
|
225
|
+
host = @env["HTTP_HOST"] || @env["SERVER_NAME"]
|
|
226
|
+
return nil if host.nil?
|
|
227
|
+
|
|
228
|
+
"#{scheme}://#{host}#{@env['PATH_INFO']}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def safe_headers
|
|
232
|
+
SAFE_HEADERS.each_with_object({}) do |name, out|
|
|
233
|
+
key = "HTTP_#{name.upcase.tr('-', '_')}"
|
|
234
|
+
value = @env[key] || (@env[name.upcase.tr("-", "_")] if name == "Content-Type")
|
|
235
|
+
out[name] = value if value
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def user_hash
|
|
240
|
+
return nil if @user.nil?
|
|
241
|
+
|
|
242
|
+
hash = @user.respond_to?(:to_h) ? @user.to_h : {}
|
|
243
|
+
{
|
|
244
|
+
id: (hash[:id] || hash[:external_id] || hash["id"] || hash["external_id"])&.to_s,
|
|
245
|
+
email: hash[:email] || hash["email"],
|
|
246
|
+
ip_address: @env && @env["REMOTE_ADDR"]
|
|
247
|
+
}.compact.presence
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def hostname
|
|
251
|
+
Socket.gethostname
|
|
252
|
+
rescue StandardError
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|