devbench 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be38d75135b0a120747e5abf3889afadb0997377f8d58510abb5a3baadf41d64
4
- data.tar.gz: d2791c38e21e79d204e71a22aa3337793b9641976827aeb0f5e6dd54a7f811dc
3
+ metadata.gz: dd716a7741353f88f381775e58d7d613d7a5d50ff69287feb65f86f25c38e13d
4
+ data.tar.gz: '03358a6c9d097c8b4adc3daacc8c032699aba2a81814284caa0287254134f95c'
5
5
  SHA512:
6
- metadata.gz: 3f323a0cd9aa89ed80cee8fa78c8598c992f8ac15200bc7bf98ee5cc211079dc1458237c782479c150643a944683b27fe3b295222d1f7b2e8ec5a3bf0bacab5c
7
- data.tar.gz: ec896fbc0a0d4d65d6259a83186f81df942cc09d0f437995cc34f80a4ab66c0864b90c948b944054c8404fa1f16047b002f7f5d2c56f84a9500dbaf304bc4d25
6
+ metadata.gz: bdc43a37a987bcc745a17bc957d704bbf93835a6ef475c66e7228acb9c64edcad1ee2ab4acd9b68020f21435d16f6cf263e378db615cd49242e20407115e6d9a
7
+ data.tar.gz: b0c9a3d9884106853d587c2944cb8436bd333ed197fd4630ae17a713e4f959068e96faaf40ea54bfe1cc87f21532f6b482686dd6451ee39d022d9d683e6229d5
data/README.md CHANGED
@@ -1,24 +1,27 @@
1
1
  # Dev Bench for Ruby and Rails
2
2
 
3
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
4
+ the server log lines of a failing request, the browser sensor, trace
5
+ propagation and the handled-failure header — so an app can turn Sentry
6
+ off. Zero runtime dependencies; loads in plain Ruby, hooks itself
6
7
  into Rails when Rails is there.
7
8
 
8
9
  ## Install (Rails)
9
10
 
10
- ```ruby
11
- # Gemfile
12
- gem 'devbench'
11
+ ```sh
12
+ bundle add devbench
13
+ bin/rails generate devbench
13
14
  ```
14
15
 
16
+ Then set **one** value in the app's environment — the DSN `adt dsn create`
17
+ printed for this environment:
18
+
15
19
  ```sh
16
- # The DSN Dev Bench gave you for this environment
17
- DEVBENCH_DSN=https://<key>@adt-ingest.onrender.com
20
+ DEVBENCH_DSN=https://<public>:<secret>@adt-ingest.onrender.com
18
21
  ```
19
22
 
20
- That is the whole install. No middleware line, no initializer, no process
21
- to run next to the app. Check it:
23
+ That is the whole install: no browser key, no sidecar, no service names,
24
+ no initializer, no middleware line. Check it:
22
25
 
23
26
  ```sh
24
27
  bin/rails devbench:test
@@ -30,16 +33,51 @@ It prints the HTTP result, or says plainly what is wrong (`DEVBENCH_DSN is
30
33
  not set`, `HTTP 401: the key in DEVBENCH_DSN was rejected`, `could not reach
31
34
  …`) and exits non-zero. Outside Rake: `Devbench.test!`.
32
35
 
36
+ What the generator does, and nothing else (run it again and nothing
37
+ changes):
38
+
39
+ - puts `<%= devbench_script_tag %>` in `app/views/layouts/application.html.erb`
40
+ just before `</head>` (`.haml` / `.slim`: `= devbench_script_tag` as the
41
+ last line of `head`). Without that layout it prints what to add where.
42
+ - if `config/initializers/content_security_policy.rb` defines a policy,
43
+ adds `https://unpkg.com` to `script_src`, and the ingest host and
44
+ `https://*.storage.supabase.co` (evidence uploads) to `connect_src`.
45
+
46
+ ### The browser tag
47
+
48
+ `devbench_script_tag` renders the browser sensor, version-pinned to this
49
+ gem (upgrading the gem upgrades the sensor), with **only the public part**
50
+ of `DEVBENCH_DSN`:
51
+
52
+ ```html
53
+ <script src="https://unpkg.com/devbench@0.7.0/dist/devbench.min.js"
54
+ data-dsn="https://<public>@adt-ingest.onrender.com" data-release="<release>" defer></script>
55
+ ```
56
+
57
+ The secret never reaches a page. Without a DSN (or with a 0.5 single-key
58
+ DSN, or `DEVBENCH_ENABLED=false`) it renders nothing at all, so a
59
+ development or test environment without a DSN serves no tag. If the app
60
+ uses CSP nonces (`content_security_policy_nonce_generator`), the tag
61
+ carries the request's nonce.
62
+
63
+ **CSP:** if your policy is defined somewhere other than the standard
64
+ initializer, add these yourself:
65
+
66
+ ```ruby
67
+ policy.script_src ..., "https://unpkg.com"
68
+ policy.connect_src ..., "https://adt-ingest.onrender.com", "https://*.storage.supabase.co"
69
+ ```
70
+
33
71
  ## Configuration
34
72
 
35
73
  All optional except the DSN.
36
74
 
37
75
  | Variable | Default | |
38
76
  |---|---|---|
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. |
77
+ | `DEVBENCH_DSN` | — (falls back to `ADT_DSN`) | `https://<public>:<secret>@<host>[:port]` from `adt dsn create`. The server authenticates with the secret; the page gets the public part. A 0.5 `https://<key>@<host>` still works (no browser tag, see below). Plain `http://` is accepted only for localhost. |
78
+ | `DEVBENCH_SERVICE` | your app's module, underscored (`AcmeShop` → `acme_shop`); `app` outside Rails; **`<that>-sidekiq` in a Sidekiq process** | Groups this app's issues. Set it and it wins everywhere. |
79
+ | `DEVBENCH_RELEASE` | the first found of: a `REVISION` file in `Rails.root` (Capistrano writes one), `HEROKU_SLUG_COMMIT`, `KAMAL_VERSION`, `GITHUB_SHA`, `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT`; else empty | Which deploy an occurrence came from. `c.release` in code wins over all of these. |
80
+ | `DEVBENCH_ENABLED` | on | `false` turns everything off: no middleware, no hooks, no tag, nothing sent. |
43
81
 
44
82
  Or from code, e.g. `config/initializers/devbench.rb`:
45
83
 
@@ -51,7 +89,7 @@ end
51
89
  ```
52
90
 
53
91
  A DSN that does not parse logs one `[devbench]` warning and reports
54
- nothing; it never raises into the app.
92
+ nothing; it never raises into the app. Neither key is ever printed.
55
93
 
56
94
  ## What is captured automatically
57
95
 
@@ -94,8 +132,9 @@ Like the browser sensor, the gem does not send one request per error:
94
132
  Dev Bench sensor). Repeats cost a counter increment.
95
133
  - **Once a minute** a background thread sends the counts — fingerprint,
96
134
  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).
135
+ small request. Nothing is sent for a quiet minute (except a small poll
136
+ while the process holds log lines, below), and what is left is sent
137
+ when the process exits (waiting at most 2 seconds).
99
138
  - **Detail only on request.** When Dev Bench sees a fingerprint for the
100
139
  first time it asks for evidence, and the gem uploads the stack and one
101
140
  example message — **templated and scrubbed first** (emails, numbers,
@@ -107,6 +146,33 @@ Like the browser sensor, the gem does not send one request per error:
107
146
  counted). Safe under forking servers (Puma, Unicorn, Sidekiq): each
108
147
  worker reports on its own.
109
148
 
149
+ ## Server log lines
150
+
151
+ Triage reads the server's log lines for the user action that failed. With
152
+ `DEVBENCH_DSN` set, the gem keeps them itself — no sidecar:
153
+
154
+ - **What:** every line written to `Rails.logger` and `Sidekiq.logger`
155
+ while a request or job carries a trace (`x-adt-trace` from the browser
156
+ sensor; a job inherits the trace of the request that enqueued it).
157
+ Untraced lines are not kept. Rails 7.1+: a capturing logger joins
158
+ `Rails.logger`'s broadcast (it follows the app's level and `silence`,
159
+ and writes nothing); older Rails and `Sidekiq.logger`: a tee after the
160
+ logger's own write. **What your logger writes is unchanged.**
161
+ - **Bounded:** per process, the newest 10,000 lines or 4 MiB, nothing older
162
+ than 15 minutes, 4 KiB per line. A logging call is never blocked on I/O
163
+ and never raises because of this.
164
+ - **Sent only when asked:** when Dev Bench asks for a trace, the flush
165
+ thread sends that trace's lines from this process — the newest ≤ 200
166
+ lines / 64 KiB — **redacted first** with the same strict rules as
167
+ evidence (quoted values, numbers, emails, cards, tokens, credentials,
168
+ IPs and two-word names removed), plus any redaction rules Dev Bench has
169
+ learned for your app. A process holding lines it was not asked about
170
+ sends nothing but a small poll once a minute while it holds them.
171
+ - **Forking servers:** each Puma worker and Sidekiq process keeps and
172
+ answers for its own lines; Dev Bench assembles them.
173
+
174
+ A Rack app without Rails: `Devbench.capture_logs(logger)`.
175
+
110
176
  ## Who was affected
111
177
 
112
178
  Set it once per request, typically in `ApplicationController`:
@@ -198,11 +264,12 @@ browser cannot read `x-adt-handled` unless the server lists it in
198
264
  than overwrites. **A CORS layer that replaces the response header set will
199
265
  strip it** and detection will silently never fire; check the ordering.
200
266
 
201
- ## Optional: the sidecar (server logs)
267
+ ## Optional: the sidecar
202
268
 
203
269
  The Dev Bench sidecar is a separate process that reads your app's log
204
270
  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.
271
+ user action. It is optional: with a DSN the gem keeps those lines itself
272
+ (above). It remains for setups that cannot load the gem.
206
273
 
207
274
  Without a DSN, the gem reports to the sidecar's unix socket instead
208
275
  (`$ADT_SIDECAR_SOCKET`, default `/tmp/adt-sidecar.sock`), exactly as 0.4
@@ -212,6 +279,26 @@ gem does not also write to its socket, so nothing is counted twice.
212
279
  Fingerprints are identical in both modes, so moving between them keeps
213
280
  every issue.
214
281
 
282
+ ## Upgrading from 0.5
283
+
284
+ Nothing is required: a 0.5 single-key `DEVBENCH_DSN` keeps reporting
285
+ exactly as before. To get server log lines and the browser tag from the
286
+ same value, mint a pair and replace the DSN:
287
+
288
+ ```sh
289
+ adt dsn create --tenant <tenant> --environment production
290
+ # https://<public>:<secret>@adt-ingest.onrender.com
291
+ ```
292
+
293
+ then run `bin/rails generate devbench` for the tag and CSP. A page that
294
+ loaded the browser sensor with its own DSN can drop that and use the tag.
295
+
296
+ One default changed: a Sidekiq process without `DEVBENCH_SERVICE` now
297
+ reports as `<app>-sidekiq` (it was `<app>`), so job failures are grouped
298
+ apart from web failures — and an existing job issue reappears once under
299
+ the new name. Set `DEVBENCH_SERVICE` in the Sidekiq process to keep the
300
+ old name.
301
+
215
302
  ## Upgrading from `adt` (0.4)
216
303
 
217
304
  - Change the Gemfile line to `gem 'devbench'`. `require 'adt'` and every
@@ -221,5 +308,5 @@ every issue.
221
308
  - `config.middleware.insert_before 0, ADT::Middleware` can stay or go: the
222
309
  Railtie sees it and does not insert a second one.
223
310
  - 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.
311
+ To report directly instead, set `DEVBENCH_DSN` (from `adt dsn create`);
312
+ see "Upgrading from 0.5".
@@ -4,23 +4,31 @@ require 'uri'
4
4
 
5
5
  module Devbench
6
6
  # Where reports go, parsed from DEVBENCH_DSN (docs/SERVER_SDK_SPEC.md, "Dev
7
- # Bench naming and configuration"):
7
+ # Bench naming and configuration"; DECISIONS #161):
8
8
  #
9
- # https://<ingest key>@<host>[:port]
9
+ # https://<public>:<secret>@<host>[:port] from `adt dsn create`
10
+ # https://<key>@<host>[:port] 0.5 and earlier: one key
10
11
  #
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.
12
+ # scheme://host[:port] is the ingest base. The secret (a server key) is
13
+ # what this process authenticates with; the public part (a browser key) is
14
+ # only ever handed to the page, via #browser_dsn. A single-key DSN keeps
15
+ # working with its one key and has no browser part — that key is a server
16
+ # secret and must never reach a page. A path or query, if present, is
17
+ # ignored: the keys alone identify tenant, environment and source.
14
18
  class DSN
15
19
  class Invalid < StandardError; end
16
20
 
17
21
  LOOPBACK = /\A(?:localhost|127(?:\.\d{1,3}){3}|::1|\[::1\])\z|\.localhost\z/i
22
+ SHAPE = 'https://<public>:<secret>@<host>'
18
23
 
19
- attr_reader :base, :key
24
+ attr_reader :base, :key, :public_key
20
25
 
21
- def initialize(base, key)
26
+ # key: what the server sends as X-ADT-Key (the secret, or a 0.5 DSN's
27
+ # single key). public_key: the browser key, or nil.
28
+ def initialize(base, key, public_key = nil)
22
29
  @base = base.freeze
23
30
  @key = key.freeze
31
+ @public_key = public_key&.freeze
24
32
  freeze
25
33
  end
26
34
 
@@ -28,8 +36,26 @@ module Devbench
28
36
  "#{@base}/v1/flush"
29
37
  end
30
38
 
31
- # The DSN without its key, for messages and logs. The key is a secret
32
- # on a server: it must never be printed.
39
+ # The self-test endpoint: validates the key, stores nothing.
40
+ def check_url
41
+ "#{@base}/v1/check"
42
+ end
43
+
44
+ def logs_url
45
+ "#{@base}/v1/logs"
46
+ end
47
+
48
+ # The DSN a browser may hold: https://<public>@host. Nil when there is
49
+ # no public part.
50
+ def browser_dsn
51
+ return nil if @public_key.nil?
52
+
53
+ scheme, rest = @base.split('://', 2)
54
+ "#{scheme}://#{URI.encode_www_form_component(@public_key)}@#{rest}"
55
+ end
56
+
57
+ # The DSN without its keys, for messages and logs. The secret must never
58
+ # be printed.
33
59
  def to_s
34
60
  @base
35
61
  end
@@ -37,7 +63,7 @@ module Devbench
37
63
  alias inspect to_s
38
64
 
39
65
  # Raises DSN::Invalid with a message that says what to fix. Never echoes
40
- # the key back.
66
+ # either key back.
41
67
  def self.parse(raw)
42
68
  text = raw.to_s.strip
43
69
  raise Invalid, 'is empty' if text.empty?
@@ -45,28 +71,32 @@ module Devbench
45
71
  uri = begin
46
72
  URI.parse(text)
47
73
  rescue URI::Error
48
- raise Invalid, 'is not a URL (expected https://<key>@<host>)'
74
+ raise Invalid, "is not a URL (expected #{SHAPE})"
49
75
  end
50
76
 
51
77
  scheme = uri.scheme.to_s.downcase
52
- raise Invalid, 'must start with https:// (expected https://<key>@<host>)' unless %w[http https].include?(scheme)
78
+ raise Invalid, "must start with https:// (expected #{SHAPE})" unless %w[http https].include?(scheme)
53
79
 
54
80
  host = uri.host.to_s
55
- raise Invalid, 'has no host (expected https://<key>@<host>)' if host.empty?
81
+ raise Invalid, "has no host (expected #{SHAPE})" if host.empty?
56
82
 
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?
83
+ user = URI.decode_www_form_component(uri.user.to_s).strip
84
+ secret = URI.decode_www_form_component(uri.password.to_s).strip
85
+ raise Invalid, "has no key (expected #{SHAPE})" if user.empty? && secret.empty?
59
86
 
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).
87
+ # The secret is a real secret on a server. Over plain http it would
88
+ # cross the network readable by anything on the path, so http is
89
+ # accepted only for a loopback ingest (local development and tests).
63
90
  if scheme == 'http' && !LOOPBACK.match?(host)
64
91
  raise Invalid, "uses http:// for #{host}: the key would cross the network in plaintext; use https://"
65
92
  end
66
93
 
67
94
  port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ''
68
95
  host = "[#{host}]" if host.include?(':') && !host.start_with?('[')
69
- new("#{scheme}://#{host}#{port}", key.strip)
96
+ base = "#{scheme}://#{host}#{port}"
97
+ return new(base, user) if secret.empty?
98
+
99
+ new(base, secret, user.empty? ? nil : user)
70
100
  end
71
101
  end
72
102
 
@@ -77,12 +107,22 @@ module Devbench
77
107
  # sidecar's socket, as before 0.5.
78
108
  # DEVBENCH_SERVICE this app's name. Default: the Rails application's
79
109
  # module, underscored (AcmeShop -> acme_shop);
80
- # else "app".
81
- # DEVBENCH_RELEASE the deployed version. Default: GIT_SHA,
82
- # SOURCE_VERSION, RENDER_GIT_COMMIT, else "".
110
+ # else "app"; with "-sidekiq" appended in a
111
+ # Sidekiq process.
112
+ # DEVBENCH_RELEASE the deployed version. Default, first found: the
113
+ # REVISION file in the app root (Capistrano),
114
+ # HEROKU_SLUG_COMMIT, KAMAL_VERSION, GITHUB_SHA,
115
+ # GIT_SHA, SOURCE_VERSION, RENDER_GIT_COMMIT,
116
+ # else "".
83
117
  # DEVBENCH_ENABLED "false" turns everything off.
84
118
  class Configuration
85
- RELEASE_FALLBACKS = %w[GIT_SHA SOURCE_VERSION RENDER_GIT_COMMIT].freeze
119
+ # Tried in order after the REVISION file when no release is configured.
120
+ # Common deploy tools and platforms only; never an app's own variable.
121
+ RELEASE_FALLBACKS = %w[HEROKU_SLUG_COMMIT KAMAL_VERSION GITHUB_SHA GIT_SHA SOURCE_VERSION RENDER_GIT_COMMIT].freeze
122
+ # Capistrano writes the deployed commit here, in the release's root.
123
+ REVISION_FILE = 'REVISION'
124
+ # A commit or tag, not a document: read no more than this.
125
+ REVISION_MAX_BYTES = 255
86
126
  OFF = %w[false 0 no off].freeze
87
127
 
88
128
  # Strings, or nil for "not set".
@@ -94,8 +134,8 @@ module Devbench
94
134
  def initialize(env = ENV)
95
135
  @dsn = presence(env['DEVBENCH_DSN']) || presence(env['ADT_DSN'])
96
136
  @service = presence(env['DEVBENCH_SERVICE'])
97
- @release = presence(env['DEVBENCH_RELEASE']) ||
98
- RELEASE_FALLBACKS.lazy.map { |name| presence(env[name]) }.find(&:itself)
137
+ @release = presence(env['DEVBENCH_RELEASE'])
138
+ @release_env = RELEASE_FALLBACKS.lazy.map { |name| presence(env[name]) }.find(&:itself)
99
139
  @enabled = !OFF.include?(env['DEVBENCH_ENABLED'].to_s.strip.downcase)
100
140
  @flush_interval = 60
101
141
  end
@@ -106,16 +146,58 @@ module Devbench
106
146
 
107
147
  # The service name sent with every flush and folded into every
108
148
  # fingerprint. Resolved when reporting starts, after Rails has booted.
149
+ #
150
+ # A Sidekiq process (Sidekiq.server?) defaults to "<app>-sidekiq", so a
151
+ # typical app's web and job processes are told apart with no
152
+ # DEVBENCH_SERVICE at all. An explicit service always wins.
109
153
  def resolved_service
110
- presence(@service) || rails_service || 'app'
154
+ explicit = presence(@service)
155
+ return explicit if explicit
156
+
157
+ base = rails_service || 'app'
158
+ sidekiq_server? ? "#{base}-sidekiq" : base
111
159
  end
112
160
 
161
+ # The release sent with every flush and on the browser tag. Explicit
162
+ # (configure or DEVBENCH_RELEASE) wins; else the REVISION file, read
163
+ # once reporting starts (after Rails has booted, so Rails.root is
164
+ # known); else the deploy variables in RELEASE_FALLBACKS order; else "".
113
165
  def resolved_release
114
- @release.to_s
166
+ explicit = presence(@release)
167
+ return explicit if explicit
168
+
169
+ @detected_release ||= (revision_file_release || @release_env).to_s
115
170
  end
116
171
 
117
172
  private
118
173
 
174
+ # The first line of <app root>/REVISION, or nil when there is none or it
175
+ # cannot be read. The app root is Rails.root, else the working directory.
176
+ def revision_file_release
177
+ path = File.join(app_root, REVISION_FILE)
178
+ return nil unless File.file?(path)
179
+
180
+ presence(File.open(path, 'rb') { |f| f.read(REVISION_MAX_BYTES) }.to_s.lines.first)
181
+ rescue StandardError
182
+ nil
183
+ end
184
+
185
+ def app_root
186
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
187
+ ::Rails.root.to_s
188
+ else
189
+ Dir.pwd
190
+ end
191
+ rescue StandardError
192
+ Dir.pwd
193
+ end
194
+
195
+ def sidekiq_server?
196
+ defined?(::Sidekiq) && ::Sidekiq.respond_to?(:server?) && ::Sidekiq.server? ? true : false
197
+ rescue StandardError, SystemStackError
198
+ false
199
+ end
200
+
119
201
  def presence(value)
120
202
  text = value.to_s.strip
121
203
  text.empty? ? nil : text