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
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,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
|