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 +4 -4
- data/README.md +2 -2
- data/lib/devbench/config.rb +43 -6
- data/lib/devbench/logs.rb +83 -12
- data/lib/devbench/version.rb +1 -1
- data/lib/devbench/view_helper.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd716a7741353f88f381775e58d7d613d7a5d50ff69287feb65f86f25c38e13d
|
|
4
|
+
data.tar.gz: '03358a6c9d097c8b4adc3daacc8c032699aba2a81814284caa0287254134f95c'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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`:
|
data/lib/devbench/config.rb
CHANGED
|
@@ -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:
|
|
113
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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] ==
|
|
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,
|
|
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
|
-
# "[
|
|
314
|
-
|
|
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
|
-
|
|
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('')
|
data/lib/devbench/version.rb
CHANGED
data/lib/devbench/view_helper.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Devbench
|
|
|
10
10
|
#
|
|
11
11
|
# renders
|
|
12
12
|
#
|
|
13
|
-
# <script src="https://unpkg.com/devbench@0.
|
|
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.
|
|
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-
|
|
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
|