devbench 0.6.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: 61008f8defbff11fd3e079f568739cc25114bc05fb0f8981c5084f83d4770de4
4
- data.tar.gz: d0304643c20cdb7fc413771188d60ee1881ae9e760129c22fd32d63aebea0bd5
3
+ metadata.gz: dd716a7741353f88f381775e58d7d613d7a5d50ff69287feb65f86f25c38e13d
4
+ data.tar.gz: '03358a6c9d097c8b4adc3daacc8c032699aba2a81814284caa0287254134f95c'
5
5
  SHA512:
6
- metadata.gz: 8af3f77395bf0f10fddf36e0cdfcfdfd85aa2e1528d08c4abc170eeffd318021d657824d24b8f1dfb8fdba1da71b904760ffffdee84a4d50944ab63f41a3f5db
7
- data.tar.gz: c59ffb19a9739467182f6e4bf15b4ad68ebeb18d2eaabb79a17b15a24de40bde6230247bc58d8085368ef7be2b562986360dd0d5e90a45334d63adf739976938
6
+ metadata.gz: bdc43a37a987bcc745a17bc957d704bbf93835a6ef475c66e7228acb9c64edcad1ee2ab4acd9b68020f21435d16f6cf263e378db615cd49242e20407115e6d9a
7
+ data.tar.gz: b0c9a3d9884106853d587c2944cb8436bd333ed197fd4630ae17a713e4f959068e96faaf40ea54bfe1cc87f21532f6b482686dd6451ee39d022d9d683e6229d5
data/README.md CHANGED
@@ -50,7 +50,7 @@ gem (upgrading the gem upgrades the sensor), with **only the public part**
50
50
  of `DEVBENCH_DSN`:
51
51
 
52
52
  ```html
53
- <script src="https://unpkg.com/devbench@0.6.0/dist/devbench.min.js"
53
+ <script src="https://unpkg.com/devbench@0.7.0/dist/devbench.min.js"
54
54
  data-dsn="https://<public>@adt-ingest.onrender.com" data-release="<release>" defer></script>
55
55
  ```
56
56
 
@@ -76,7 +76,7 @@ All optional except the DSN.
76
76
  |---|---|---|
77
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
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` | `GIT_SHA`, `SOURCE_VERSION`, `RENDER_GIT_COMMIT`, else empty | Which deploy an occurrence came from. |
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
80
  | `DEVBENCH_ENABLED` | on | `false` turns everything off: no middleware, no hooks, no tag, nothing sent. |
81
81
 
82
82
  Or from code, e.g. `config/initializers/devbench.rb`:
@@ -109,11 +109,20 @@ module Devbench
109
109
  # module, underscored (AcmeShop -> acme_shop);
110
110
  # else "app"; with "-sidekiq" appended in a
111
111
  # Sidekiq process.
112
- # DEVBENCH_RELEASE the deployed version. Default: GIT_SHA,
113
- # SOURCE_VERSION, RENDER_GIT_COMMIT, else "".
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 "".
114
117
  # DEVBENCH_ENABLED "false" turns everything off.
115
118
  class Configuration
116
- 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
117
126
  OFF = %w[false 0 no off].freeze
118
127
 
119
128
  # Strings, or nil for "not set".
@@ -125,8 +134,8 @@ module Devbench
125
134
  def initialize(env = ENV)
126
135
  @dsn = presence(env['DEVBENCH_DSN']) || presence(env['ADT_DSN'])
127
136
  @service = presence(env['DEVBENCH_SERVICE'])
128
- @release = presence(env['DEVBENCH_RELEASE']) ||
129
- 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)
130
139
  @enabled = !OFF.include?(env['DEVBENCH_ENABLED'].to_s.strip.downcase)
131
140
  @flush_interval = 60
132
141
  end
@@ -149,12 +158,40 @@ module Devbench
149
158
  sidekiq_server? ? "#{base}-sidekiq" : base
150
159
  end
151
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 "".
152
165
  def resolved_release
153
- @release.to_s
166
+ explicit = presence(@release)
167
+ return explicit if explicit
168
+
169
+ @detected_release ||= (revision_file_release || @release_env).to_s
154
170
  end
155
171
 
156
172
  private
157
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
+
158
195
  def sidekiq_server?
159
196
  defined?(::Sidekiq) && ::Sidekiq.respond_to?(:server?) && ::Sidekiq.server? ? true : false
160
197
  rescue StandardError, SystemStackError
data/lib/devbench/logs.rb CHANGED
@@ -28,6 +28,10 @@ module Devbench
28
28
  MAX_BYTES = 4 * 1024 * 1024
29
29
  MAX_AGE = 15 * 60
30
30
  MAX_LINE_BYTES = 4096
31
+ # Lines held from one multi-line message (an exception and its
32
+ # backtrace): the top of it, so one framework backtrace cannot push the
33
+ # rest of the request out of a 200-line slice.
34
+ MAX_MESSAGE_LINES = 50
31
35
 
32
36
  SEVERITY = %w[DEBUG INFO WARN ERROR FATAL].freeze
33
37
  # The last line captured on this thread, and by which hook: see #capture.
@@ -149,7 +153,7 @@ module Devbench
149
153
  if message.nil?
150
154
  message = block_given? ? yield : progname
151
155
  end
152
- Logs.capture(severity, message, :broadcast)
156
+ Logs.capture(severity, message, :broadcast, Logs.broadcast_tags(devbench_broadcast, self))
153
157
  true
154
158
  rescue StandardError, SystemStackError
155
159
  true
@@ -157,7 +161,7 @@ module Devbench
157
161
  alias log add
158
162
 
159
163
  def <<(message)
160
- Logs.capture(nil, message, :broadcast) unless Current.trace.nil?
164
+ Logs.capture(nil, message, :broadcast, Logs.broadcast_tags(devbench_broadcast, self)) unless Current.trace.nil?
161
165
  self
162
166
  rescue StandardError, SystemStackError
163
167
  self
@@ -257,6 +261,17 @@ module Devbench
257
261
  []
258
262
  end
259
263
 
264
+ # The tag prefixes of the app's loggers in a broadcast (not ours): the
265
+ # one Rails wrote after each newline of a multi-line message is the
266
+ # first broadcast's, but which logger is first is the app's choice.
267
+ # A Proc, so a single-line message never computes it.
268
+ def broadcast_tags(broadcast, own)
269
+ lambda do
270
+ loggers = broadcast&.broadcasts || []
271
+ loggers.reject { |l| l.equal?(own) }.filter_map { |l| tags_of(l) }
272
+ end
273
+ end
274
+
260
275
  # Called by Tee after the logger's own #add returned.
261
276
  def tee(logger, severity, message, progname, had_block, yielded, value)
262
277
  severity ||= ::Logger::UNKNOWN
@@ -267,32 +282,38 @@ module Devbench
267
282
 
268
283
  message = had_block ? value : progname
269
284
  end
270
- capture(severity, message, :tee)
285
+ capture(severity, message, :tee, -> { tags_of(logger) })
271
286
  rescue StandardError, SystemStackError
272
287
  nil
273
288
  end
274
289
 
275
- # Keeps one line under the current trace. Never raises.
290
+ # Keeps one message under the current trace — one entry per line of
291
+ # it (see #lines_for). Never raises. `tags` is the tag prefix of the
292
+ # logger written to (an Array of candidates, or a Proc giving one),
293
+ # needed only for a multi-line message.
276
294
  #
277
295
  # One line can reach both hooks: in a Sidekiq process Rails.logger may
278
296
  # broadcast to Sidekiq.logger. Both run on this thread, one right
279
297
  # after the other, so a line equal to the one just captured *by the
280
298
  # other hook* is that same write and is skipped. The same hook
281
299
  # repeating a line is a real repeat and is kept.
282
- def capture(severity, message, source)
300
+ def capture(severity, message, source, tags = nil)
283
301
  return nil unless active?
284
302
 
285
303
  trace = Current.trace
286
304
  return nil if trace.nil?
287
305
 
288
- line = line_for(trace, severity, message)
306
+ lines = lines_for(trace, severity, message, tags)
307
+ return nil if lines.empty?
308
+
309
+ written = lines.join("\n")
289
310
  last = Thread.current[LAST]
290
- if last && last[0] != source && last[1] == line
311
+ if last && last[0] != source && last[1] == written
291
312
  Thread.current[LAST] = nil
292
313
  return nil
293
314
  end
294
- Thread.current[LAST] = [source, line]
295
- buffer.push(trace.key, line)
315
+ Thread.current[LAST] = [source, written]
316
+ lines.each { |line| buffer.push(trace.key, line) }
296
317
  start_polling if @polling_pid != Process.pid
297
318
  nil
298
319
  rescue StandardError, SystemStackError, ThreadError
@@ -310,11 +331,61 @@ module Devbench
310
331
  transport.start if transport.respond_to?(:start)
311
332
  end
312
333
 
313
- # "[v1/s/i/h] INFO message", at most MAX_LINE_BYTES, valid UTF-8.
314
- def line_for(trace, severity, message)
334
+ # "[a] [b] " — what ActiveSupport::TaggedLogging puts before a line,
335
+ # and what ActionDispatch::DebugExceptions puts after every newline of
336
+ # the exception it logs as one message. nil when the logger has none.
337
+ def tags_of(logger)
338
+ formatter = logger.respond_to?(:formatter) ? logger.formatter : nil
339
+ return nil unless formatter.respond_to?(:tags_text)
340
+
341
+ text = formatter.tags_text.to_s
342
+ text.empty? ? nil : text
343
+ end
344
+
345
+ # "[v1/s/i/h] INFO message", at most MAX_LINE_BYTES, valid UTF-8 —
346
+ # one per line of the message. A multi-line message (Rails logs an
347
+ # exception and its backtrace as one) is split so each line reaches
348
+ # triage on its own, with the logger's tags removed from the start of
349
+ # continuation lines (the trace frames each line once) and blank
350
+ # lines dropped. At most MAX_MESSAGE_LINES; when more, the last says
351
+ # how many were left out.
352
+ def lines_for(trace, severity, message, tags)
315
353
  label = severity.is_a?(Integer) ? SEVERITY[severity] : nil
354
+ prefix = label ? "[#{trace}] #{label} " : "[#{trace}] "
316
355
  text = message_text(message)
317
- line = label ? "[#{trace}] #{label} #{text}" : "[#{trace}] #{text}"
356
+ text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
357
+ text = text.scrub('') unless text.valid_encoding?
358
+ return [bound_line(prefix + text)] unless text.include?("\n")
359
+
360
+ prefixes = tag_prefixes(tags)
361
+ parts = text.split("\n").each_with_index.filter_map do |part, i|
362
+ part = untag(part, prefixes) if i.positive?
363
+ part = part.chomp("\r")
364
+ part unless part.strip.empty?
365
+ end
366
+ return [] if parts.empty?
367
+
368
+ if parts.length > MAX_MESSAGE_LINES
369
+ omitted = parts.length - (MAX_MESSAGE_LINES - 1)
370
+ parts = parts.first(MAX_MESSAGE_LINES - 1) << "... (#{omitted} more lines)"
371
+ end
372
+ parts.map { |part| bound_line(prefix + part) }
373
+ end
374
+
375
+ # A logger whose formatter fails to give its tags costs the untagging,
376
+ # never the line.
377
+ def tag_prefixes(tags)
378
+ Array(tags.respond_to?(:call) ? tags.call : tags).select { |t| t.is_a?(::String) && !t.empty? }
379
+ rescue StandardError, SystemStackError
380
+ []
381
+ end
382
+
383
+ def untag(part, prefixes)
384
+ found = prefixes.find { |p| part.start_with?(p) }
385
+ found ? part[found.length..] : part
386
+ end
387
+
388
+ def bound_line(line)
318
389
  line = line.byteslice(0, MAX_LINE_BYTES) if line.bytesize > MAX_LINE_BYTES
319
390
  line = line.dup.force_encoding(Encoding::UTF_8) unless line.encoding == Encoding::UTF_8
320
391
  line.valid_encoding? ? line : line.scrub('')
@@ -6,5 +6,5 @@ module Devbench
6
6
  # The wire format this speaks — the trace header, the handled header, the
7
7
  # control-socket message shapes — is what a customer's deployment depends
8
8
  # on. See docs/SERVER_SDK_SPEC.md.
9
- VERSION = '0.6.0'
9
+ VERSION = '0.7.0'
10
10
  end
@@ -10,7 +10,7 @@ module Devbench
10
10
  #
11
11
  # renders
12
12
  #
13
- # <script src="https://unpkg.com/devbench@0.6.0/dist/devbench.min.js"
13
+ # <script src="https://unpkg.com/devbench@0.7.0/dist/devbench.min.js"
14
14
  # data-dsn="https://<public>@<host>" data-release="<release>" defer></script>
15
15
  #
16
16
  # The DSN is the public part of DEVBENCH_DSN only (Devbench.browser_dsn),
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: devbench
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dev Bench
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Reports unhandled exceptions, Rails.error reports, failed ActiveJob and