devbench 0.5.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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'current'
4
+
5
+ module Devbench
6
+ # Who was affected (docs/SERVER_SDK_SPEC.md, Capability 5).
7
+ #
8
+ # Identity travels in its own field of every control message and nowhere
9
+ # else: never in a message, a symbol or a log line, so it can never move a
10
+ # fingerprint.
11
+ module Identity
12
+ MAX = 255
13
+
14
+ # Returns a frozen {email:, account:} hash with empty fields dropped, or
15
+ # nil when neither is present.
16
+ def self.normalize(email, account)
17
+ user = {}
18
+
19
+ email = text(email).strip.downcase
20
+ user[:email] = email[0, MAX] unless email.empty?
21
+
22
+ account = text(account).strip
23
+ user[:account] = account[0, MAX] unless account.empty?
24
+
25
+ user.empty? ? nil : user.freeze
26
+ end
27
+
28
+ # An identity that cannot be read is dropped rather than raised: this is
29
+ # called from a before_action, and a raise there fails the request.
30
+ def self.text(value)
31
+ return '' if value.nil?
32
+
33
+ value.to_s.encode('UTF-8', invalid: :replace, undef: :replace).scrub
34
+ rescue StandardError, SystemStackError
35
+ ''
36
+ end
37
+ private_class_method :text
38
+ end
39
+
40
+ # Records who the current request is for. Call once per request, typically
41
+ # from a before_action; Devbench::Middleware clears it when the request ends.
42
+ #
43
+ # Devbench.set_user(email: current_user.email, account: current_account.id)
44
+ #
45
+ # Each field is optional. Calling again replaces the whole identity, and
46
+ # calling with neither clears it. Never raises.
47
+ def self.set_user(email: nil, account: nil)
48
+ Current.user = Identity.normalize(email, account)
49
+ nil
50
+ rescue StandardError, SystemStackError
51
+ nil
52
+ end
53
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'trace'
4
+ require_relative 'current'
5
+ require_relative 'reporter'
6
+
7
+ module Devbench
8
+ # Rack middleware: accepts the correlation id and makes it available for the
9
+ # duration of the request, and reports exceptions that escape the app.
10
+ #
11
+ # Never rejects a request, never alters the response body, and never raises
12
+ # anything of its own. An exception from the application is reported and
13
+ # then re-raised — the same object, backtrace intact — so whatever sits
14
+ # outside this middleware sees exactly what it would have without it. A
15
+ # diagnostics middleware that can fail a request is worse than no
16
+ # diagnostics.
17
+ #
18
+ # config.middleware.insert_before 0, Devbench::Middleware
19
+ class Middleware
20
+ HANDLED_HEADER = 'x-adt-handled'
21
+ EXPOSE_HEADER = 'Access-Control-Expose-Headers'
22
+
23
+ def initialize(app)
24
+ @app = app
25
+ end
26
+
27
+ def call(env)
28
+ # DEVBENCH_ENABLED=false: not even the trace scope or the header.
29
+ return @app.call(env) unless Devbench.enabled?
30
+
31
+ trace = begin
32
+ Trace.parse(env[Trace::RACK_HEADER])
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ # Scoped even without a trace: identity set during this request must
38
+ # end with it, and a thread's next request must not inherit it.
39
+ status, headers, body = Current.with(trace) do
40
+ result = begin
41
+ @app.call(env)
42
+ rescue Exception => e # rubocop:disable Lint/RescueException
43
+ # Reported inside the scope so the report carries this request's
44
+ # trace and user. capture never raises; if it somehow did, the
45
+ # application's exception would be lost, which is the one outcome
46
+ # this must not have.
47
+ report(e, env)
48
+ raise e
49
+ end
50
+
51
+ # Rails' ShowExceptions turns an exception into a 500 page itself, so
52
+ # nothing escapes to here. It leaves the exception in env.
53
+ report(env['action_dispatch.exception'], env)
54
+
55
+ trace.nil? ? result : mark_handled(result)
56
+ end
57
+
58
+ [status, headers, body]
59
+ end
60
+
61
+ private
62
+
63
+ def report(error, env)
64
+ return if error.nil?
65
+
66
+ Reporter.capture(error, context: 'request', handled: false, symbol: symbol(env))
67
+ rescue Exception # rubocop:disable Lint/RescueException
68
+ nil
69
+ end
70
+
71
+ # Controller#action, when Rails has said which. The controller instance
72
+ # knows its own class name exactly (acronym inflections included); the
73
+ # path parameters are the fallback when the error happened before it ran.
74
+ def symbol(env)
75
+ controller = env['action_controller.instance']
76
+ if controller.respond_to?(:action_name) && controller.action_name
77
+ return "#{controller.class.name}##{controller.action_name}"
78
+ end
79
+
80
+ params = env['action_dispatch.request.path_parameters']
81
+ return nil unless params.is_a?(Hash)
82
+
83
+ name = params[:controller] || params['controller']
84
+ action = params[:action] || params['action']
85
+ return nil if name.to_s.empty? || action.to_s.empty?
86
+
87
+ "#{camelize(name.to_s)}Controller##{action}"
88
+ rescue StandardError, SystemStackError
89
+ nil
90
+ end
91
+
92
+ # 'admin/deal_notes' -> 'Admin::DealNotes'. Plain, without
93
+ # ActiveSupport's inflections, which this gem cannot assume are loaded.
94
+ def camelize(path)
95
+ path.split('/').map { |part| part.split('_').map(&:capitalize).join }.join('::')
96
+ end
97
+
98
+ # Tells the browser that something was handled during this request.
99
+ #
100
+ # This is the whole of detector 2's server half. The browser already knows
101
+ # whether the application showed the user an error; it does not know
102
+ # whether anything went wrong. One header closes that gap, and the join
103
+ # happens where both facts are present at the same moment — rather than in
104
+ # a database, which would need per-trace records from both sides and
105
+ # per-event traffic from the client.
106
+ #
107
+ # The value is a count and nothing else. No class name, no message, no
108
+ # symbol: this header is readable by any script on the page, and the
109
+ # detail already travels to the sidecar over a unix socket on the
110
+ # customer's own host.
111
+ #
112
+ # CROSS-ORIGIN: a browser cannot read a custom response header unless the
113
+ # server lists it in Access-Control-Expose-Headers. An Angular app on a
114
+ # different origin from its Rails API — a common setup — will see
115
+ # nothing without that. See docs/DETECTORS.md.
116
+ def mark_handled(result)
117
+ count = Current.handled_count
118
+ return result if count.zero?
119
+
120
+ status, headers, body = result
121
+ headers = headers.dup
122
+ headers[HANDLED_HEADER] = count.to_s
123
+
124
+ exposed = headers[EXPOSE_HEADER].to_s
125
+ unless exposed.downcase.include?(HANDLED_HEADER)
126
+ headers[EXPOSE_HEADER] = exposed.empty? ? HANDLED_HEADER : "#{exposed}, #{HANDLED_HEADER}"
127
+ end
128
+
129
+ [status, headers, body]
130
+ rescue StandardError
131
+ # Never fail a response over instrumentation.
132
+ result
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'reporter'
4
+
5
+ module Devbench
6
+ # The Rails-side exception hooks (docs/SERVER_SDK_SPEC.md, Capability 2).
7
+ #
8
+ # Plain Ruby: nothing here names a Rails constant, so this file loads
9
+ # without Rails. Devbench::Railtie installs the hooks against the real
10
+ # Rails.error and ActiveSupport::Notifications at boot.
11
+ #
12
+ # Every entry point here runs inside Rails' own error and instrumentation
13
+ # paths. Since Rails 7.1 an exception raised by a notification subscriber
14
+ # is re-raised into the code being instrumented, so a fault in these hooks
15
+ # would fail the customer's job — every one of them is fully guarded.
16
+ module RailsHooks
17
+ JOB_EVENT = 'perform.active_job'
18
+
19
+ # Where Rails' executor says it caught an error, mapped to the context a
20
+ # triager would use. From Rails 7.1, ActionDispatch::Executor reports an
21
+ # unhandled request exception (raised, or rendered as a 500 by
22
+ # ShowExceptions) to Rails.error before Devbench::Middleware, outermost, sees
23
+ # it, so this subscriber is the first hook it reaches. Labelling it
24
+ # `rails_error` would leave `request` empty on every modern Rails app.
25
+ #
26
+ # Jobs need no entry: the perform.active_job hook runs inside the job,
27
+ # before the executor around it reports.
28
+ #
29
+ # Sidekiq's Rails reloader wraps each job in the executor with
30
+ # source 'job.sidekiq', so a failure outside Sidekiq's middleware (a job
31
+ # class that will not load) reaches Rails.error first. It is a job
32
+ # failure.
33
+ SOURCE_CONTEXTS = {
34
+ 'application.action_dispatch' => 'request',
35
+ 'job.sidekiq' => 'job'
36
+ }.freeze
37
+
38
+ # Subscribed to Rails.error (Rails >= 7.0). Receives both Rails.error
39
+ # .report and .handle; `handled` is passed through as Rails gives it.
40
+ class ErrorSubscriber
41
+ def report(error, handled: false, severity: nil, context: nil, source: nil, **_rest)
42
+ Reporter.capture(
43
+ error,
44
+ context: SOURCE_CONTEXTS.fetch(source.to_s, 'rails_error'),
45
+ handled: handled,
46
+ symbol: RailsHooks.symbol_from_context(context)
47
+ )
48
+ nil
49
+ rescue StandardError, SystemStackError
50
+ nil
51
+ end
52
+ end
53
+
54
+ class << self
55
+ # Subscribes once per reporter. Returns true when subscribed.
56
+ def install_error_reporter(reporter)
57
+ return false if reporter.nil? || !reporter.respond_to?(:subscribe)
58
+ return true if error_reporters.any? { |r| r.equal?(reporter) }
59
+
60
+ reporter.subscribe(ErrorSubscriber.new)
61
+ error_reporters << reporter
62
+ true
63
+ rescue StandardError, SystemStackError
64
+ false
65
+ end
66
+
67
+ # Subscribes once per notifier. Returns true when subscribed.
68
+ def install_active_job(notifications)
69
+ return false if notifications.nil? || !notifications.respond_to?(:subscribe)
70
+ return true if job_notifiers.any? { |n| n.equal?(notifications) }
71
+
72
+ notifications.subscribe(JOB_EVENT) do |_name, _started, _finished, _id, payload|
73
+ job_performed(payload)
74
+ end
75
+ job_notifiers << notifications
76
+ true
77
+ rescue StandardError, SystemStackError
78
+ false
79
+ end
80
+
81
+ # A perform.active_job payload. ActiveJob puts the exception that
82
+ # escaped #perform in :exception_object.
83
+ def job_performed(payload)
84
+ return nil unless payload.is_a?(Hash)
85
+
86
+ error = payload[:exception_object]
87
+ return nil if error.nil?
88
+
89
+ Reporter.capture(error, context: 'job', handled: false, symbol: job_symbol(payload[:job]))
90
+ rescue StandardError, SystemStackError
91
+ nil
92
+ end
93
+
94
+ # Rails >= 7.1 puts the controller or job being run into the error
95
+ # context (ActiveSupport::ExecutionContext).
96
+ def symbol_from_context(context)
97
+ return nil unless context.is_a?(Hash)
98
+
99
+ controller = context[:controller]
100
+ if controller.respond_to?(:action_name) && controller.action_name
101
+ return "#{controller.class.name}##{controller.action_name}"
102
+ end
103
+
104
+ job_symbol(context[:job])
105
+ rescue StandardError, SystemStackError
106
+ nil
107
+ end
108
+
109
+ private
110
+
111
+ def job_symbol(job)
112
+ return nil if job.nil?
113
+
114
+ "#{job.class.name}#perform"
115
+ end
116
+
117
+ def error_reporters
118
+ @error_reporters ||= []
119
+ end
120
+
121
+ def job_notifiers
122
+ @job_notifiers ||= []
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'middleware'
4
+ require_relative 'rails_hooks'
5
+ require_relative 'sidekiq_hooks'
6
+
7
+ module Devbench
8
+ # Hooks Dev Bench into Rails at boot. Loaded by lib/devbench.rb only when
9
+ # Rails::Railtie is defined, so the gem still loads, with zero
10
+ # dependencies, in plain Ruby.
11
+ class Railtie < ::Rails::Railtie
12
+ # Inserts Devbench::Middleware first in the stack, as sentry-rails
13
+ # inserts its own, so `gem 'devbench'` is the whole install.
14
+ #
15
+ # Unless the app already did: every install before 0.5 has
16
+ # `config.middleware.insert_before 0, ADT::Middleware` in
17
+ # config/application.rb, and ADT::Middleware is this class. Inserting
18
+ # again would put it in the stack twice.
19
+ #
20
+ # Whether the app inserted it is only knowable once every operation the
21
+ # app recorded has been replayed onto the real stack — including ones
22
+ # from config/initializers, which run after this initializer. So the
23
+ # check is itself recorded as an operation, and in the list Rails
24
+ # replays last (delete/move operations), where it sees the finished
25
+ # stack. Rails >= 7.1 records operations as lambdas taking the stack;
26
+ # 7.0 as [method, args, block] it public_sends, which `then` serves.
27
+ initializer 'devbench.middleware', before: :build_middleware_stack do |app|
28
+ Railtie.auto_insert(app.config.middleware)
29
+ end
30
+
31
+ # Puts the request's trace on every Rails log line (SERVER_SDK_SPEC,
32
+ # Capability 1, requirement 2), so the sidecar can hand triage the server
33
+ # lines of one user action. Appended to whatever the app already tags
34
+ # with — e.g. [:request_id] — never replacing it. Before the middleware
35
+ # stack is built, which is when Rails::Rack::Logger reads log_tags.
36
+ initializer 'devbench.log_tags', before: :build_middleware_stack do |app|
37
+ tags = Array(app.config.log_tags)
38
+ app.config.log_tags = tags + [Devbench::LOG_TAG] unless tags.include?(Devbench::LOG_TAG)
39
+ rescue StandardError, SystemStackError
40
+ nil
41
+ end
42
+
43
+ # rake devbench:test — check the DSN without waiting for a flush.
44
+ rake_tasks do
45
+ namespace :devbench do
46
+ desc 'Send one test exception straight to Dev Bench and print the HTTP result'
47
+ task test: :environment do
48
+ abort('Dev Bench test failed (see above)') unless Devbench.test!
49
+ end
50
+ end
51
+ end
52
+
53
+ config.after_initialize do
54
+ # A diagnostics gem that can fail a boot is worse than none.
55
+ begin
56
+ next unless Devbench.enabled?
57
+
58
+ Devbench::RailsHooks.install_error_reporter(::Rails.error) if ::Rails.respond_to?(:error)
59
+ if defined?(::ActiveSupport::Notifications)
60
+ Devbench::RailsHooks.install_active_job(::ActiveSupport::Notifications)
61
+ end
62
+ # Native Sidekiq jobs never reach the ActiveJob hook. after_initialize
63
+ # runs after every gem is required, so Gemfile order does not matter.
64
+ Devbench::SidekiqHooks.install if defined?(::Sidekiq)
65
+ rescue StandardError, SystemStackError
66
+ nil
67
+ end
68
+ end
69
+
70
+ class << self
71
+ # Records the conditional insert on a MiddlewareStackProxy. Returns
72
+ # true when recorded.
73
+ def auto_insert(proxy)
74
+ operations = proxy.send(:delete_operations)
75
+ check = ->(stack) { insert_unless_present(stack) }
76
+ operations << (lambda_operations?(operations) ? check : [:then, [], check])
77
+ true
78
+ rescue StandardError, SystemStackError
79
+ # A Rails whose proxy no longer looks like this: insert plainly. Two
80
+ # copies (if the app also inserted one) would cost a second scope
81
+ # per request, never a second report — capture dedupes per object.
82
+ begin
83
+ proxy.insert_before(0, Devbench::Middleware)
84
+ rescue StandardError, SystemStackError
85
+ nil
86
+ end
87
+ false
88
+ end
89
+
90
+ def insert_unless_present(stack)
91
+ return stack unless Devbench.enabled?
92
+
93
+ present = stack.middlewares.any? { |m| m.klass.equal?(Devbench::Middleware) }
94
+ stack.unshift(Devbench::Middleware) unless present
95
+ stack
96
+ end
97
+
98
+ private
99
+
100
+ def lambda_operations?(operations)
101
+ return operations.first.is_a?(Proc) unless operations.empty?
102
+
103
+ ::Rails.gem_version >= Gem::Version.new('7.1')
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'current'
4
+ require_relative 'backtrace'
5
+ require_relative 'transport'
6
+
7
+ module Devbench
8
+ # Turns a failure into a report — the control message of
9
+ # docs/SERVER_SDK_SPEC.md, "Control socket — v1" — and hands it to the
10
+ # active transport (Devbench.transport): direct to Dev Bench when a DSN is
11
+ # set, else the local sidecar's socket.
12
+ #
13
+ # The report is built the same way for both, so the direct transport
14
+ # fingerprints exactly what the sidecar would have received.
15
+ #
16
+ # Every failure here is swallowed. This is the one place in the codebase
17
+ # where that is correct — everywhere else it is the exact behavior the
18
+ # product exists to find.
19
+ module Reporter
20
+ DEFAULT_SOCKET = '/tmp/adt-sidecar.sock'
21
+
22
+ # docs/SERVER_SDK_SPEC.md, "Control socket — v1".
23
+ MAX_MESSAGE = 2000
24
+ MAX_HANDLED_MESSAGE = 500
25
+
26
+ CONTEXTS = %w[request rails_error job explicit].freeze
27
+
28
+ # Status codes, not failures. Matched by name against the exception's
29
+ # class and ancestors, so none of these constants needs to be loaded —
30
+ # this gem must work without Rails. Sentry ignores the same by default.
31
+ DEFAULT_IGNORED = %w[
32
+ ActionController::RoutingError
33
+ ActiveRecord::RecordNotFound
34
+ ActionController::InvalidAuthenticityToken
35
+ ActionController::UnknownFormat
36
+ ActionDispatch::Http::MimeNegotiation::InvalidType
37
+ ].freeze
38
+
39
+ # Process control, not application failures: a deploy's SIGTERM or an
40
+ # `exit` passing through the middleware is not something to triage.
41
+ #
42
+ # Sidekiq's own control flow likewise (Sidekiq::Shutdown is an Interrupt,
43
+ # so already a SignalException). JobRetry::Handled (and its subclass
44
+ # Skip) means "the job failed and Sidekiq has dealt with it" — the real
45
+ # failure is its cause, reported on its own; Rails' executor around a
46
+ # Sidekiq job would otherwise hand it to Rails.error. Job::Interrupted is
47
+ # an iterable job being re-queued at shutdown.
48
+ NEVER_REPORTED = %w[
49
+ SystemExit SignalException
50
+ Sidekiq::JobRetry::Handled Sidekiq::Job::Interrupted
51
+ ].freeze
52
+
53
+ REPORTED_IVAR = :@__adt_reported
54
+
55
+ class << self
56
+ attr_writer :socket_path, :app_root
57
+
58
+ def socket_path
59
+ @socket_path ||= ENV.fetch('ADT_SIDECAR_SOCKET', DEFAULT_SOCKET)
60
+ end
61
+
62
+ # Class names never reported. Extend it from an initializer:
63
+ #
64
+ # Devbench.ignore_exceptions << 'Pundit::NotAuthorizedError'
65
+ def ignore_exceptions
66
+ @ignore_exceptions ||= DEFAULT_IGNORED.dup
67
+ end
68
+
69
+ # Frames under this directory are sent relative to it. Rails.root when
70
+ # Rails is loaded, else the working directory.
71
+ def app_root
72
+ return @app_root if @app_root
73
+
74
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
75
+ ::Rails.root.to_s
76
+ else
77
+ Dir.pwd
78
+ end
79
+ rescue StandardError, SystemStackError
80
+ nil
81
+ end
82
+
83
+ # Records that a failure was handled here. Does not change behavior.
84
+ #
85
+ # rescue ActiveRecord::RecordInvalid => e
86
+ # Devbench.report_handled(e, symbol: 'CustomersController#update', reason: 'validation')
87
+ def report_handled(error, symbol:, reason: nil)
88
+ # Counted first, and regardless of whether anything after it works.
89
+ # The count is what the browser is told; a sidecar that is down, or an
90
+ # error object that misbehaves, must not also make the failure
91
+ # invisible to the detector that does not need either.
92
+ begin
93
+ Current.record_handled
94
+ rescue StandardError, SystemStackError
95
+ nil
96
+ end
97
+
98
+ emit(handled_payload(error, symbol, reason))
99
+ nil
100
+ rescue StandardError, SystemStackError
101
+ # This runs inside the customer's rescue blocks. Anything raised here
102
+ # means their render never runs — a handled failure turned into an
103
+ # outage by the tool watching for failures.
104
+ nil
105
+ end
106
+
107
+ # Sends an `exception` message, once per exception object.
108
+ #
109
+ # Called from the middleware's rescue, from Rails.error, from ActiveJob
110
+ # instrumentation and from Devbench.capture_exception. Anything raised here
111
+ # would replace the application's own exception, so nothing is.
112
+ def capture(error, context:, handled:, symbol: nil)
113
+ return nil unless error.is_a?(Exception)
114
+ return nil if reported?(error) || ignored?(error)
115
+
116
+ # Marked before sending, so a second hook reached while this one is
117
+ # still writing does not send it again.
118
+ mark_reported(error)
119
+ emit(exception_payload(error, context, handled, symbol))
120
+ nil
121
+ rescue StandardError, SystemStackError
122
+ nil
123
+ end
124
+
125
+ # Whether an exception has already been sent by any hook.
126
+ def reported?(error)
127
+ if error.instance_variable_defined?(REPORTED_IVAR)
128
+ true
129
+ else
130
+ frozen_reported.key?(error)
131
+ end
132
+ rescue StandardError, SystemStackError
133
+ false
134
+ end
135
+
136
+ def ignored?(error)
137
+ names = error.class.ancestors.map(&:name)
138
+ return true if (names & NEVER_REPORTED).any?
139
+
140
+ (names & ignore_exceptions.map(&:to_s)).any?
141
+ rescue StandardError, SystemStackError
142
+ false
143
+ end
144
+
145
+ # The `exception` report for an error, without sending it. Used by
146
+ # the self-test (Devbench.test!), which sends one synthetic report
147
+ # straight to ingest, built exactly as a real one would be.
148
+ def report_for(error, context:, handled:, symbol: nil)
149
+ exception_payload(error, context, handled, symbol)
150
+ end
151
+
152
+ private
153
+
154
+ def mark_reported(error)
155
+ error.instance_variable_set(REPORTED_IVAR, true)
156
+ rescue FrozenError
157
+ # A frozen exception cannot carry the mark. Held weakly, so the
158
+ # process does not keep every frozen exception it ever saw.
159
+ frozen_reported[error] = true
160
+ end
161
+
162
+ def frozen_reported
163
+ @frozen_reported ||= ObjectSpace::WeakMap.new
164
+ end
165
+
166
+ # Hands the report to whichever transport is active: direct (DSN set),
167
+ # the sidecar socket, or none. Each one returns at once and swallows its
168
+ # own failures; this guard is for the dispatch itself.
169
+ def emit(payload)
170
+ Devbench.transport.deliver(payload)
171
+ rescue StandardError, SystemStackError
172
+ nil
173
+ end
174
+
175
+ # Each field is read on its own: an exception whose #message raises
176
+ # still reports its class and site, which are the fingerprint. The
177
+ # message is detail.
178
+ def handled_payload(error, symbol, reason)
179
+ {
180
+ v: 1,
181
+ kind: 'handled_failure',
182
+ symbol: symbol,
183
+ error: safely { error.class.name },
184
+ message: safely { truncate(error.message, MAX_HANDLED_MESSAGE) },
185
+ reason: reason,
186
+ trace: safely { Current.trace&.to_s },
187
+ user: safely { Current.user }
188
+ }.compact
189
+ end
190
+
191
+ def exception_payload(error, context, handled, symbol)
192
+ {
193
+ v: 1,
194
+ kind: 'exception',
195
+ context: CONTEXTS.include?(context) ? context : 'explicit',
196
+ handled: handled ? true : false,
197
+ error: safely { class_name(error) } || 'Exception',
198
+ message: safely { truncate(error.message, MAX_MESSAGE) },
199
+ symbol: safely { symbol.nil? ? nil : utf8(symbol.to_s) } || '',
200
+ frames: safely { Backtrace.frames(error.backtrace, app_root) } || [],
201
+ trace: safely { Current.trace&.to_s },
202
+ user: safely { Current.user }
203
+ }.compact
204
+ end
205
+
206
+ # An anonymous class has no name; its nearest named ancestor is stable
207
+ # across processes, where Class#to_s would print an address.
208
+ def class_name(error)
209
+ error.class.ancestors.find { |mod| mod.is_a?(Class) && mod.name }&.name
210
+ end
211
+
212
+ def safely
213
+ yield
214
+ rescue StandardError, SystemStackError
215
+ nil
216
+ end
217
+
218
+ # At most `limit` characters, including the ellipsis.
219
+ def truncate(text, limit)
220
+ text = utf8(text.to_s)
221
+ text.length > limit ? "#{text[0, limit - 1]}…" : text
222
+ end
223
+
224
+ def utf8(text)
225
+ text.encode('UTF-8', invalid: :replace, undef: :replace).scrub
226
+ end
227
+ end
228
+ end
229
+
230
+ # Reports an exception the application caught and wants seen, as Sentry's
231
+ # capture_exception does. Returns nil and never raises.
232
+ #
233
+ # rescue Faraday::Error => e
234
+ # Devbench.capture_exception(e, symbol: 'Billing::Sync#run')
235
+ def self.capture_exception(error, symbol: nil)
236
+ Reporter.capture(error, context: 'explicit', handled: true, symbol: symbol)
237
+ end
238
+
239
+ def self.report_handled(error, symbol:, reason: nil)
240
+ Reporter.report_handled(error, symbol: symbol, reason: reason)
241
+ end
242
+
243
+ # Class names never reported as exceptions (see Reporter::DEFAULT_IGNORED).
244
+ def self.ignore_exceptions
245
+ Reporter.ignore_exceptions
246
+ end
247
+ end