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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: be38d75135b0a120747e5abf3889afadb0997377f8d58510abb5a3baadf41d64
4
+ data.tar.gz: d2791c38e21e79d204e71a22aa3337793b9641976827aeb0f5e6dd54a7f811dc
5
+ SHA512:
6
+ metadata.gz: 3f323a0cd9aa89ed80cee8fa78c8598c992f8ac15200bc7bf98ee5cc211079dc1458237c782479c150643a944683b27fe3b295222d1f7b2e8ec5a3bf0bacab5c
7
+ data.tar.gz: ec896fbc0a0d4d65d6259a83186f81df942cc09d0f437995cc34f80a4ab66c0864b90c948b944054c8404fa1f16047b002f7f5d2c56f84a9500dbaf304bc4d25
data/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # Dev Bench for Ruby and Rails
2
+
3
+ Everything Sentry's Rails SDK captures by default, plus who was affected,
4
+ trace propagation and the handled-failure header — so an app can turn
5
+ Sentry off. Zero runtime dependencies; loads in plain Ruby, hooks itself
6
+ into Rails when Rails is there.
7
+
8
+ ## Install (Rails)
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ gem 'devbench'
13
+ ```
14
+
15
+ ```sh
16
+ # The DSN Dev Bench gave you for this environment
17
+ DEVBENCH_DSN=https://<key>@adt-ingest.onrender.com
18
+ ```
19
+
20
+ That is the whole install. No middleware line, no initializer, no process
21
+ to run next to the app. Check it:
22
+
23
+ ```sh
24
+ bin/rails devbench:test
25
+ # Dev Bench: sending a test exception to https://adt-ingest.onrender.com (service "acme_shop")
26
+ # HTTP 200: accepted (fingerprint 3f9a2b0c4d5e)
27
+ ```
28
+
29
+ It prints the HTTP result, or says plainly what is wrong (`DEVBENCH_DSN is
30
+ not set`, `HTTP 401: the key in DEVBENCH_DSN was rejected`, `could not reach
31
+ …`) and exits non-zero. Outside Rake: `Devbench.test!`.
32
+
33
+ ## Configuration
34
+
35
+ All optional except the DSN.
36
+
37
+ | Variable | Default | |
38
+ |---|---|---|
39
+ | `DEVBENCH_DSN` | — (falls back to `ADT_DSN`) | `https://<key>@<host>[:port]`. Plain `http://` is accepted only for localhost. |
40
+ | `DEVBENCH_SERVICE` | your app's module, underscored (`AcmeShop` → `acme_shop`); `app` outside Rails | Groups this app's issues. |
41
+ | `DEVBENCH_RELEASE` | `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT`, else empty | Which deploy an occurrence came from. |
42
+ | `DEVBENCH_ENABLED` | on | `false` turns everything off: no middleware, no hooks, nothing sent. |
43
+
44
+ Or from code, e.g. `config/initializers/devbench.rb`:
45
+
46
+ ```ruby
47
+ Devbench.configure do |c|
48
+ c.dsn = Rails.application.credentials.devbench_dsn
49
+ c.service = 'billing-api'
50
+ end
51
+ ```
52
+
53
+ A DSN that does not parse logs one `[devbench]` warning and reports
54
+ nothing; it never raises into the app.
55
+
56
+ ## What is captured automatically
57
+
58
+ | What | How | `context` |
59
+ |---|---|---|
60
+ | An exception escaping the app | `Devbench::Middleware` (inserted first in the stack by the Railtie) reports it, then re-raises the same object, backtrace intact | `request` |
61
+ | An exception Rails rendered as a 500 page itself | `Devbench::Middleware` (`env['action_dispatch.exception']`), or `Rails.error` from Rails 7.1's executor | `request` |
62
+ | `Rails.error.report` / `Rails.error.handle` (Rails ≥ 7.0) | the Railtie subscribes; `handled` is passed through | `rails_error` |
63
+ | A failed ActiveJob | the Railtie subscribes to `perform.active_job` | `job` |
64
+ | A failed Sidekiq job (`Sidekiq::Job` / `Sidekiq::Worker`) | Sidekiq server middleware; reports, then re-raises the same object, so retries are unchanged | `job` |
65
+ | A Sidekiq error outside a job (Redis/fetch errors, a death handler that raised) | Sidekiq `error_handlers` | `job` |
66
+
67
+ Each report carries the class, the message (≤ 2,000 characters), where it
68
+ surfaced (`DealsController#update`, `InvoiceJob#perform`), up to 50 stack
69
+ frames with paths relative to `Rails.root`, the trace, and the user.
70
+
71
+ - **Once per exception.** An error that passes several hooks (a job failing
72
+ inside a request reaches three) is reported once.
73
+ - **Status codes are not failures.** Not reported, as in Sentry:
74
+ `ActionController::RoutingError`, `ActiveRecord::RecordNotFound`,
75
+ `ActionController::InvalidAuthenticityToken`,
76
+ `ActionController::UnknownFormat`,
77
+ `ActionDispatch::Http::MimeNegotiation::InvalidType`, and subclasses. Add
78
+ your own by name:
79
+
80
+ ```ruby
81
+ Devbench.ignore_exceptions << 'Pundit::NotAuthorizedError'
82
+ ```
83
+
84
+ - **Never in the way.** No hook raises into the app; a failure while
85
+ reporting is dropped, and the application's own exception always comes
86
+ out unchanged.
87
+
88
+ ## What is sent, and when
89
+
90
+ Like the browser sensor, the gem does not send one request per error:
91
+
92
+ - Each report is **fingerprinted and counted in the process** (class,
93
+ templated message and in-app frames; the same algorithm as every other
94
+ Dev Bench sensor). Repeats cost a counter increment.
95
+ - **Once a minute** a background thread sends the counts — fingerprint,
96
+ how many, first/last seen, and up to 20 affected users each — in one
97
+ small request. Nothing is sent for a quiet minute, and what is left is
98
+ sent when the process exits (waiting at most 2 seconds).
99
+ - **Detail only on request.** When Dev Bench sees a fingerprint for the
100
+ first time it asks for evidence, and the gem uploads the stack and one
101
+ example message — **templated and scrubbed first** (emails, numbers,
102
+ quoted values, tokens, card numbers, credentials and two-word names are
103
+ removed). Who was affected never goes into evidence.
104
+ - **Never harms the app.** No network I/O on the request thread; 5-second
105
+ timeouts on the background thread, one retry, then the data is dropped;
106
+ memory is bounded (512 distinct problems per minute, the rest only
107
+ counted). Safe under forking servers (Puma, Unicorn, Sidekiq): each
108
+ worker reports on its own.
109
+
110
+ ## Who was affected
111
+
112
+ Set it once per request, typically in `ApplicationController`:
113
+
114
+ ```ruby
115
+ before_action do
116
+ Devbench.set_user(email: current_user&.email, account: current_account&.id)
117
+ end
118
+ ```
119
+
120
+ - `email` is trimmed and lowercased; `account` is your own tenant/customer
121
+ id, sent as a string. Both optional, each truncated to 255 characters.
122
+ - It is sent in its own field with the counts, never inside a message or
123
+ evidence, and triage sees counts of affected users and accounts.
124
+ - The middleware clears it when the request ends (Sidekiq: when the job
125
+ ends), so the next request on the same thread never inherits it.
126
+
127
+ ## Reporting what you caught
128
+
129
+ An exception you rescued but want seen, like Sentry's `capture_exception`:
130
+
131
+ ```ruby
132
+ rescue Faraday::Error => e
133
+ Devbench.capture_exception(e, symbol: 'Billing::Sync#run')
134
+ retry_later
135
+ end
136
+ ```
137
+
138
+ A failure you handled and turned into a normal response — the case this
139
+ product exists for:
140
+
141
+ ```ruby
142
+ rescue ActiveRecord::RecordInvalid => e
143
+ Devbench.report_handled(e, symbol: 'CustomersController#update', reason: 'validation')
144
+ render json: { ok: true } # <- the user is about to be told this worked
145
+ end
146
+ ```
147
+
148
+ `report_handled` also marks the response with `x-adt-handled: <count>`. The
149
+ browser already knows whether the user was shown an error; this is the one
150
+ fact it cannot know on its own. The header carries a count and nothing else,
151
+ and it is added even when reporting is down.
152
+
153
+ ## Sidekiq
154
+
155
+ Under Rails there is nothing to add: the Railtie installs the hooks at boot
156
+ when Sidekiq is loaded. A Sidekiq process **without Rails** installs them
157
+ itself, wherever it configures Sidekiq:
158
+
159
+ ```ruby
160
+ require 'sidekiq'
161
+ require 'devbench'
162
+ Devbench::SidekiqHooks.install
163
+ ```
164
+
165
+ That adds a server middleware (first in the chain; reports, then re-raises,
166
+ so retries and the dead set are unchanged), identity scoped to each job, the
167
+ request's trace carried into jobs it enqueues (`"adt_trace"` in the job
168
+ payload), and an error handler for failures outside a job. Sidekiq's own
169
+ control flow (`Sidekiq::Shutdown`, `JobRetry::Handled`/`Skip`,
170
+ `Job::Interrupted`) is never reported. Tested against Sidekiq 7.3; not a
171
+ dependency of this gem.
172
+
173
+ ## Without Rails
174
+
175
+ ```ruby
176
+ require 'devbench'
177
+ use Devbench::Middleware # Rack
178
+ ```
179
+
180
+ Everything else (`set_user`, `capture_exception`, `report_handled`, the
181
+ DSN) works the same.
182
+
183
+ ## Trace propagation
184
+
185
+ The correlation id the browser sent (`x-adt-trace`) is available as
186
+ `Devbench::Current.trace` for the duration of the request. Forward it on
187
+ outbound calls to other instrumented services:
188
+
189
+ ```ruby
190
+ Net::HTTP.post(uri, body, Devbench::HTTP.headers)
191
+ ```
192
+
193
+ ## Cross-origin
194
+
195
+ If your JavaScript is served from a different origin than this API, the
196
+ browser cannot read `x-adt-handled` unless the server lists it in
197
+ `Access-Control-Expose-Headers`. The middleware adds it and appends rather
198
+ than overwrites. **A CORS layer that replaces the response header set will
199
+ strip it** and detection will silently never fire; check the ordering.
200
+
201
+ ## Optional: the sidecar (server logs)
202
+
203
+ The Dev Bench sidecar is a separate process that reads your app's log
204
+ stream on the host and answers triage's requests for the log lines of one
205
+ user action. It is optional; nothing above needs it.
206
+
207
+ Without a DSN, the gem reports to the sidecar's unix socket instead
208
+ (`$ADT_SIDECAR_SOCKET`, default `/tmp/adt-sidecar.sock`), exactly as 0.4
209
+ did, and the sidecar counts and uploads on its behalf. **It never uses
210
+ both**: with a DSN set, a sidecar on the same host still reads logs, but the
211
+ gem does not also write to its socket, so nothing is counted twice.
212
+ Fingerprints are identical in both modes, so moving between them keeps
213
+ every issue.
214
+
215
+ ## Upgrading from `adt` (0.4)
216
+
217
+ - Change the Gemfile line to `gem 'devbench'`. `require 'adt'` and every
218
+ `ADT.set_user`, `ADT.report_handled`, `ADT.capture_exception`,
219
+ `ADT::Middleware` and `ADT::SidekiqHooks` keep working unchanged: `ADT`
220
+ is `Devbench`.
221
+ - `config.middleware.insert_before 0, ADT::Middleware` can stay or go: the
222
+ Railtie sees it and does not insert a second one.
223
+ - Running the sidecar and want to keep it that way? Change nothing else.
224
+ To report directly instead, set `DEVBENCH_DSN` (a key minted with
225
+ `--source server`); the sidecar then only serves logs.
data/lib/adt.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The pre-0.5 entry point. Installs that `require 'adt'` keep working
4
+ # unchanged: ADT is Devbench.
5
+ require_relative 'devbench'
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devbench
4
+ # Turns Ruby backtrace lines into the control socket's frames:
5
+ # [{function:, file:, line:}], innermost first.
6
+ #
7
+ # Ruby has changed this format under us before. Up to 3.3:
8
+ #
9
+ # /app/models/deal.rb:42:in `block in save'
10
+ #
11
+ # From 3.4, a straight quote, and the label carries the owner:
12
+ #
13
+ # /app/models/deal.rb:42:in 'block in Deal#save'
14
+ #
15
+ # Both parse to the same file and line; the function is whatever Ruby said.
16
+ module Backtrace
17
+ MAX_FRAMES = 50
18
+ # Bounds one frame so 50 of them cannot push a message past the socket's
19
+ # 256 KiB line limit.
20
+ MAX_FIELD = 1024
21
+ LINE = /\A(.+?):(\d+)(?::in [`'](.*)')?\z/m
22
+
23
+ def self.frames(backtrace, root)
24
+ return [] unless backtrace.is_a?(Array)
25
+
26
+ prefix = root_prefix(root)
27
+ backtrace.first(MAX_FRAMES).map { |raw| frame(utf8(raw.to_s), prefix) }
28
+ end
29
+
30
+ def self.frame(raw, prefix)
31
+ match = LINE.match(raw)
32
+ # Unparseable lines are kept whole rather than dropped: a frame the
33
+ # parser did not understand is still evidence.
34
+ return { function: '', file: clip(raw), line: 0 } if match.nil?
35
+
36
+ file = match[1]
37
+ file = file[prefix.length..] if prefix && file.start_with?(prefix)
38
+
39
+ { function: clip(match[3].to_s), file: clip(file), line: match[2].to_i }
40
+ end
41
+
42
+ def self.root_prefix(root)
43
+ root = root.to_s
44
+ return nil if root.empty?
45
+
46
+ root.end_with?('/') ? root : "#{root}/"
47
+ end
48
+
49
+ def self.clip(text)
50
+ text.length > MAX_FIELD ? text[0, MAX_FIELD] : text
51
+ end
52
+
53
+ # JSON.generate raises on invalid UTF-8, which would lose the whole report
54
+ # over one byte in a path.
55
+ def self.utf8(text)
56
+ text.encode('UTF-8', invalid: :replace, undef: :replace).scrub
57
+ end
58
+
59
+ private_class_method :frame, :root_prefix, :clip, :utf8
60
+ end
61
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Devbench
6
+ # Where reports go, parsed from DEVBENCH_DSN (docs/SERVER_SDK_SPEC.md, "Dev
7
+ # Bench naming and configuration"):
8
+ #
9
+ # https://<ingest key>@<host>[:port]
10
+ #
11
+ # scheme://host[:port] is the ingest base and the userinfo is the key. A
12
+ # path or query, if present, is ignored: the key alone identifies tenant,
13
+ # environment and source.
14
+ class DSN
15
+ class Invalid < StandardError; end
16
+
17
+ LOOPBACK = /\A(?:localhost|127(?:\.\d{1,3}){3}|::1|\[::1\])\z|\.localhost\z/i
18
+
19
+ attr_reader :base, :key
20
+
21
+ def initialize(base, key)
22
+ @base = base.freeze
23
+ @key = key.freeze
24
+ freeze
25
+ end
26
+
27
+ def flush_url
28
+ "#{@base}/v1/flush"
29
+ end
30
+
31
+ # The DSN without its key, for messages and logs. The key is a secret
32
+ # on a server: it must never be printed.
33
+ def to_s
34
+ @base
35
+ end
36
+
37
+ alias inspect to_s
38
+
39
+ # Raises DSN::Invalid with a message that says what to fix. Never echoes
40
+ # the key back.
41
+ def self.parse(raw)
42
+ text = raw.to_s.strip
43
+ raise Invalid, 'is empty' if text.empty?
44
+
45
+ uri = begin
46
+ URI.parse(text)
47
+ rescue URI::Error
48
+ raise Invalid, 'is not a URL (expected https://<key>@<host>)'
49
+ end
50
+
51
+ scheme = uri.scheme.to_s.downcase
52
+ raise Invalid, 'must start with https:// (expected https://<key>@<host>)' unless %w[http https].include?(scheme)
53
+
54
+ host = uri.host.to_s
55
+ raise Invalid, 'has no host (expected https://<key>@<host>)' if host.empty?
56
+
57
+ key = URI.decode_www_form_component(uri.user.to_s)
58
+ raise Invalid, 'has no key (expected https://<key>@<host>)' if key.strip.empty?
59
+
60
+ # The key is a real secret on a server. Over plain http it would cross
61
+ # the network readable by anything on the path, so http is accepted
62
+ # only for a loopback ingest (local development and tests).
63
+ if scheme == 'http' && !LOOPBACK.match?(host)
64
+ raise Invalid, "uses http:// for #{host}: the key would cross the network in plaintext; use https://"
65
+ end
66
+
67
+ port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ''
68
+ host = "[#{host}]" if host.include?(':') && !host.start_with?('[')
69
+ new("#{scheme}://#{host}#{port}", key.strip)
70
+ end
71
+ end
72
+
73
+ # Everything Dev Bench reads at start. From the environment by default;
74
+ # Devbench.configure sets the same fields from code.
75
+ #
76
+ # DEVBENCH_DSN where to send (fallback ADT_DSN). Unset: the local
77
+ # sidecar's socket, as before 0.5.
78
+ # DEVBENCH_SERVICE this app's name. Default: the Rails application's
79
+ # module, underscored (AcmeShop -> acme_shop);
80
+ # else "app".
81
+ # DEVBENCH_RELEASE the deployed version. Default: GIT_SHA,
82
+ # SOURCE_VERSION, RENDER_GIT_COMMIT, else "".
83
+ # DEVBENCH_ENABLED "false" turns everything off.
84
+ class Configuration
85
+ RELEASE_FALLBACKS = %w[GIT_SHA SOURCE_VERSION RENDER_GIT_COMMIT].freeze
86
+ OFF = %w[false 0 no off].freeze
87
+
88
+ # Strings, or nil for "not set".
89
+ attr_accessor :dsn, :service, :release
90
+ # Seconds between background flushes in direct mode (spec: 60).
91
+ attr_accessor :flush_interval
92
+ attr_writer :enabled
93
+
94
+ def initialize(env = ENV)
95
+ @dsn = presence(env['DEVBENCH_DSN']) || presence(env['ADT_DSN'])
96
+ @service = presence(env['DEVBENCH_SERVICE'])
97
+ @release = presence(env['DEVBENCH_RELEASE']) ||
98
+ RELEASE_FALLBACKS.lazy.map { |name| presence(env[name]) }.find(&:itself)
99
+ @enabled = !OFF.include?(env['DEVBENCH_ENABLED'].to_s.strip.downcase)
100
+ @flush_interval = 60
101
+ end
102
+
103
+ def enabled?
104
+ @enabled ? true : false
105
+ end
106
+
107
+ # The service name sent with every flush and folded into every
108
+ # fingerprint. Resolved when reporting starts, after Rails has booted.
109
+ def resolved_service
110
+ presence(@service) || rails_service || 'app'
111
+ end
112
+
113
+ def resolved_release
114
+ @release.to_s
115
+ end
116
+
117
+ private
118
+
119
+ def presence(value)
120
+ text = value.to_s.strip
121
+ text.empty? ? nil : text
122
+ end
123
+
124
+ # Rails.application.class.module_parent_name.underscore, without
125
+ # assuming ActiveSupport's inflections are loaded.
126
+ def rails_service
127
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
128
+
129
+ klass = ::Rails.application.class
130
+ name = klass.respond_to?(:module_parent_name) ? klass.module_parent_name : klass.name.to_s.split('::').first
131
+ return nil if name.nil? || name.empty? || name == 'Object'
132
+
133
+ name.respond_to?(:underscore) ? name.underscore : underscore(name)
134
+ rescue StandardError, SystemStackError
135
+ nil
136
+ end
137
+
138
+ def underscore(name)
139
+ name.gsub('::', '/')
140
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
141
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
142
+ .tr('-', '_')
143
+ .downcase
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devbench
4
+ # Per-request storage for the active trace.
5
+ #
6
+ # Plain thread/fiber-local rather than ActiveSupport::CurrentAttributes, so
7
+ # the core of this gem loads and is testable without Rails. The Railtie wires
8
+ # CurrentAttributes semantics where Rails is present.
9
+ module Current
10
+ KEY = :__adt_trace
11
+ HANDLED_KEY = :__adt_handled
12
+ USER_KEY = :__adt_user
13
+
14
+ # Counts failures handled during this request.
15
+ #
16
+ # The middleware turns a non-zero count into a response header, which is
17
+ # what lets the browser decide — at the moment it has both facts — whether
18
+ # the user was told. See docs/DETECTORS.md.
19
+ def self.handled_count
20
+ Thread.current[HANDLED_KEY] || 0
21
+ end
22
+
23
+ def self.record_handled
24
+ Thread.current[HANDLED_KEY] = handled_count + 1
25
+ end
26
+
27
+ # Who this request is for, as set by Devbench.set_user: a frozen hash with
28
+ # :email and/or :account, or nil. Normalised before it gets here.
29
+ def self.user
30
+ Thread.current[USER_KEY]
31
+ end
32
+
33
+ def self.user=(value)
34
+ Thread.current[USER_KEY] = value
35
+ end
36
+
37
+ def self.trace
38
+ Thread.current[KEY]
39
+ end
40
+
41
+ def self.trace=(value)
42
+ Thread.current[KEY] = value
43
+ end
44
+
45
+ # Runs the block with trace active, restoring whatever was there before.
46
+ #
47
+ # Restores rather than clearing, because servers reuse threads: clearing
48
+ # would leak one request's absence into the next request's presence.
49
+ def self.with(trace)
50
+ previous = Thread.current[KEY]
51
+ previous_handled = Thread.current[HANDLED_KEY]
52
+ previous_user = Thread.current[USER_KEY]
53
+ Thread.current[KEY] = trace
54
+ # Reset rather than inherit: servers reuse threads, and carrying a count
55
+ # across requests would mark an innocent response as having swallowed
56
+ # the previous request's failure.
57
+ Thread.current[HANDLED_KEY] = 0
58
+ # Same for identity, and more so: inheriting it would attribute one
59
+ # user's failure to whoever this thread served before.
60
+ Thread.current[USER_KEY] = nil
61
+ yield
62
+ ensure
63
+ Thread.current[KEY] = previous
64
+ Thread.current[HANDLED_KEY] = previous_handled
65
+ Thread.current[USER_KEY] = previous_user
66
+ end
67
+
68
+ def self.clear
69
+ Thread.current[KEY] = nil
70
+ Thread.current[HANDLED_KEY] = nil
71
+ Thread.current[USER_KEY] = nil
72
+ end
73
+ end
74
+ end