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.
- checksums.yaml +7 -0
- data/README.md +225 -0
- data/lib/adt.rb +5 -0
- data/lib/devbench/backtrace.rb +61 -0
- data/lib/devbench/config.rb +146 -0
- data/lib/devbench/current.rb +74 -0
- data/lib/devbench/direct.rb +470 -0
- data/lib/devbench/fingerprint.rb +180 -0
- data/lib/devbench/http.rb +34 -0
- data/lib/devbench/identity.rb +53 -0
- data/lib/devbench/middleware.rb +135 -0
- data/lib/devbench/rails_hooks.rb +126 -0
- data/lib/devbench/railtie.rb +107 -0
- data/lib/devbench/reporter.rb +247 -0
- data/lib/devbench/scrub.rb +154 -0
- data/lib/devbench/session.rb +99 -0
- data/lib/devbench/session_middleware.rb +73 -0
- data/lib/devbench/sidecar_transport.rb +95 -0
- data/lib/devbench/sidekiq_hooks.rb +208 -0
- data/lib/devbench/trace.rb +79 -0
- data/lib/devbench/transport.rb +181 -0
- data/lib/devbench/version.rb +10 -0
- data/lib/devbench.rb +37 -0
- metadata +75 -0
|
@@ -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
|