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,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 &amp; drop · max 5, 5&nbsp;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