devbench 0.6.0 → 0.8.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/LICENSE +21 -0
- data/README.md +83 -4
- data/lib/devbench/active_job_hooks.rb +121 -0
- data/lib/devbench/config.rb +49 -7
- data/lib/devbench/current.rb +49 -0
- data/lib/devbench/direct.rb +30 -4
- data/lib/devbench/identity.rb +17 -3
- data/lib/devbench/logs.rb +84 -13
- data/lib/devbench/middleware.rb +58 -2
- data/lib/devbench/rails_hooks.rb +1 -1
- data/lib/devbench/railtie.rb +7 -1
- data/lib/devbench/reporter.rb +17 -5
- data/lib/devbench/request_signals.rb +359 -0
- data/lib/devbench/scrub.rb +69 -3
- data/lib/devbench/session.rb +26 -5
- data/lib/devbench/sidecar_transport.rb +1 -1
- data/lib/devbench/sidekiq_hooks.rb +1 -1
- data/lib/devbench/trace.rb +1 -1
- data/lib/devbench/transport.rb +9 -1
- data/lib/devbench/version.rb +18 -6
- data/lib/devbench/view_helper.rb +24 -1
- data/lib/devbench/widget_identity.rb +125 -0
- data/lib/devbench.rb +5 -0
- metadata +7 -3
data/lib/devbench/logs.rb
CHANGED
|
@@ -4,7 +4,7 @@ require 'logger'
|
|
|
4
4
|
require_relative 'current'
|
|
5
5
|
|
|
6
6
|
module Devbench
|
|
7
|
-
# In-process log capture (docs/
|
|
7
|
+
# In-process log capture (docs/server-sdk/configuration.md, "In-process log capture
|
|
8
8
|
# (direct mode)"; DECISIONS #161): recent log lines kept in memory, keyed
|
|
9
9
|
# by the trace that was current when each was written, so triage can be
|
|
10
10
|
# handed the server lines of one user action without a sidecar.
|
|
@@ -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/middleware.rb
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
require_relative 'trace'
|
|
4
4
|
require_relative 'current'
|
|
5
5
|
require_relative 'reporter'
|
|
6
|
+
require_relative 'request_signals'
|
|
6
7
|
|
|
7
8
|
module Devbench
|
|
8
9
|
# Rack middleware: accepts the correlation id and makes it available for the
|
|
9
|
-
# duration of the request,
|
|
10
|
+
# duration of the request, reports exceptions that escape the app, and
|
|
11
|
+
# reports what a request does without raising (Capability 2b: a 5xx with
|
|
12
|
+
# no exception, a request over 3 s, a route's 429 spike; RequestSignals).
|
|
10
13
|
#
|
|
11
14
|
# Never rejects a request, never alters the response body, and never raises
|
|
12
15
|
# anything of its own. An exception from the application is reported and
|
|
@@ -19,6 +22,10 @@ module Devbench
|
|
|
19
22
|
class Middleware
|
|
20
23
|
HANDLED_HEADER = 'x-adt-handled'
|
|
21
24
|
EXPOSE_HEADER = 'Access-Control-Expose-Headers'
|
|
25
|
+
# Set while the outermost instance observes a request, so a second
|
|
26
|
+
# instance in the stack (an app that inserted it twice, an engine's own)
|
|
27
|
+
# does not count the request twice.
|
|
28
|
+
OBSERVING_KEY = :__adt_observing_request
|
|
22
29
|
|
|
23
30
|
def initialize(app)
|
|
24
31
|
@app = app
|
|
@@ -34,23 +41,31 @@ module Devbench
|
|
|
34
41
|
nil
|
|
35
42
|
end
|
|
36
43
|
|
|
44
|
+
observing = Thread.current[OBSERVING_KEY] != true
|
|
45
|
+
started = observing ? RequestSignals.clock.monotonic_ms : nil
|
|
46
|
+
|
|
37
47
|
# Scoped even without a trace: identity set during this request must
|
|
38
48
|
# end with it, and a thread's next request must not inherit it.
|
|
39
49
|
status, headers, body = Current.with(trace) do
|
|
50
|
+
Thread.current[OBSERVING_KEY] = true if observing
|
|
40
51
|
result = begin
|
|
41
52
|
@app.call(env)
|
|
42
53
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
43
54
|
# Reported inside the scope so the report carries this request's
|
|
44
55
|
# trace and user. capture never raises; if it somehow did, the
|
|
45
56
|
# application's exception would be lost, which is the one outcome
|
|
46
|
-
# this must not have.
|
|
57
|
+
# this must not have. A request that raised is not evaluated as a
|
|
58
|
+
# request signal: the exception is its report.
|
|
47
59
|
report(e, env)
|
|
48
60
|
raise e
|
|
61
|
+
ensure
|
|
62
|
+
Thread.current[OBSERVING_KEY] = nil if observing
|
|
49
63
|
end
|
|
50
64
|
|
|
51
65
|
# Rails' ShowExceptions turns an exception into a 500 page itself, so
|
|
52
66
|
# nothing escapes to here. It leaves the exception in env.
|
|
53
67
|
report(env['action_dispatch.exception'], env)
|
|
68
|
+
observe(env, result, started) if observing
|
|
54
69
|
|
|
55
70
|
trace.nil? ? result : mark_handled(result)
|
|
56
71
|
end
|
|
@@ -60,6 +75,47 @@ module Devbench
|
|
|
60
75
|
|
|
61
76
|
private
|
|
62
77
|
|
|
78
|
+
# Classifies the finished request (failed / slow / nothing) and feeds the
|
|
79
|
+
# 429 spike detector. Inside the request's scope, so a signal carries its
|
|
80
|
+
# trace and user. Duration runs from entry to the application handing
|
|
81
|
+
# back its response; streaming a body after that is not counted.
|
|
82
|
+
def observe(env, result, started)
|
|
83
|
+
finished = RequestSignals.clock
|
|
84
|
+
duration_ms = (finished.monotonic_ms - started).floor
|
|
85
|
+
status, headers, = result
|
|
86
|
+
RequestSignals.observe(
|
|
87
|
+
status: status.is_a?(Integer) ? status : Integer(status.to_s, 10),
|
|
88
|
+
method: env['REQUEST_METHOD'],
|
|
89
|
+
route: symbol(env),
|
|
90
|
+
duration_ms: duration_ms,
|
|
91
|
+
streaming: streaming?(env, status.to_i, headers),
|
|
92
|
+
# Rails rendering a 500 itself is an exception: reported, or one the
|
|
93
|
+
# app chose to ignore (Devbench.ignore_exceptions), which must not
|
|
94
|
+
# come back as an http_500 either.
|
|
95
|
+
exception_reported: Current.exception_reported? || !env['action_dispatch.exception'].nil?,
|
|
96
|
+
finished_ms: finished.wall_ms
|
|
97
|
+
)
|
|
98
|
+
rescue StandardError, SystemStackError
|
|
99
|
+
# Never fail a response over instrumentation.
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A 101 upgrade, a hijacked connection (a websocket takes the socket) or
|
|
104
|
+
# a text/event-stream response: its duration is a connection's lifetime,
|
|
105
|
+
# never a slow request.
|
|
106
|
+
def streaming?(env, status, headers)
|
|
107
|
+
return true if status == 101 || !env['rack.hijack_io'].nil?
|
|
108
|
+
return false unless headers.respond_to?(:each)
|
|
109
|
+
|
|
110
|
+
streaming = false
|
|
111
|
+
headers.each do |name, value|
|
|
112
|
+
key = name.to_s.downcase
|
|
113
|
+
streaming ||= key == 'rack.hijack'
|
|
114
|
+
streaming ||= key == 'content-type' && value.to_s.strip.downcase.start_with?('text/event-stream')
|
|
115
|
+
end
|
|
116
|
+
streaming
|
|
117
|
+
end
|
|
118
|
+
|
|
63
119
|
def report(error, env)
|
|
64
120
|
return if error.nil?
|
|
65
121
|
|
data/lib/devbench/rails_hooks.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative 'reporter'
|
|
4
4
|
|
|
5
5
|
module Devbench
|
|
6
|
-
# The Rails-side exception hooks (docs/
|
|
6
|
+
# The Rails-side exception hooks (docs/server-sdk/unhandled-failures.md, Capability 2).
|
|
7
7
|
#
|
|
8
8
|
# Plain Ruby: nothing here names a Rails constant, so this file loads
|
|
9
9
|
# without Rails. Devbench::Railtie installs the hooks against the real
|
data/lib/devbench/railtie.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative 'middleware'
|
|
4
4
|
require_relative 'rails_hooks'
|
|
5
5
|
require_relative 'sidekiq_hooks'
|
|
6
|
+
require_relative 'active_job_hooks'
|
|
6
7
|
require_relative 'view_helper'
|
|
7
8
|
|
|
8
9
|
module Devbench
|
|
@@ -29,7 +30,7 @@ module Devbench
|
|
|
29
30
|
Railtie.auto_insert(app.config.middleware)
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
# Puts the request's trace on every Rails log line (
|
|
33
|
+
# Puts the request's trace on every Rails log line (docs/server-sdk/trace-propagation.md,
|
|
33
34
|
# Capability 1, requirement 2), so the sidecar can hand triage the server
|
|
34
35
|
# lines of one user action. Appended to whatever the app already tags
|
|
35
36
|
# with — e.g. [:request_id] — never replacing it. Before the middleware
|
|
@@ -65,6 +66,11 @@ module Devbench
|
|
|
65
66
|
if defined?(::ActiveSupport::Notifications)
|
|
66
67
|
Devbench::RailsHooks.install_active_job(::ActiveSupport::Notifications)
|
|
67
68
|
end
|
|
69
|
+
# The request's trace into ActiveJob jobs, on any adapter. on_load
|
|
70
|
+
# runs now if ActiveJob::Base is already loaded, else when it loads.
|
|
71
|
+
if defined?(::ActiveSupport) && ::ActiveSupport.respond_to?(:on_load)
|
|
72
|
+
::ActiveSupport.on_load(:active_job) { Devbench::ActiveJobHooks.install(self) }
|
|
73
|
+
end
|
|
68
74
|
# Native Sidekiq jobs never reach the ActiveJob hook. after_initialize
|
|
69
75
|
# runs after every gem is required, so Gemfile order does not matter.
|
|
70
76
|
Devbench::SidekiqHooks.install if defined?(::Sidekiq)
|
data/lib/devbench/reporter.rb
CHANGED
|
@@ -6,7 +6,7 @@ require_relative 'transport'
|
|
|
6
6
|
|
|
7
7
|
module Devbench
|
|
8
8
|
# Turns a failure into a report — the control message of
|
|
9
|
-
# docs/
|
|
9
|
+
# docs/server-sdk/control-socket.md, "Control socket — v1" — and hands it to the
|
|
10
10
|
# active transport (Devbench.transport): direct to Dev Bench when a DSN is
|
|
11
11
|
# set, else the local sidecar's socket.
|
|
12
12
|
#
|
|
@@ -19,7 +19,7 @@ module Devbench
|
|
|
19
19
|
module Reporter
|
|
20
20
|
DEFAULT_SOCKET = '/tmp/adt-sidecar.sock'
|
|
21
21
|
|
|
22
|
-
# docs/
|
|
22
|
+
# docs/server-sdk/control-socket.md, "Control socket — v1".
|
|
23
23
|
MAX_MESSAGE = 2000
|
|
24
24
|
MAX_HANDLED_MESSAGE = 500
|
|
25
25
|
|
|
@@ -111,7 +111,13 @@ module Devbench
|
|
|
111
111
|
# would replace the application's own exception, so nothing is.
|
|
112
112
|
def capture(error, context:, handled:, symbol: nil)
|
|
113
113
|
return nil unless error.is_a?(Exception)
|
|
114
|
-
return nil if
|
|
114
|
+
return nil if ignored?(error)
|
|
115
|
+
|
|
116
|
+
# The request (or job) this happened in has reported an exception,
|
|
117
|
+
# whichever hook sent it first: a 5xx after it is not also a
|
|
118
|
+
# `failed` request (Capability 2b).
|
|
119
|
+
note_exception
|
|
120
|
+
return nil if reported?(error)
|
|
115
121
|
|
|
116
122
|
# Marked before sending, so a second hook reached while this one is
|
|
117
123
|
# still writing does not send it again.
|
|
@@ -151,6 +157,12 @@ module Devbench
|
|
|
151
157
|
|
|
152
158
|
private
|
|
153
159
|
|
|
160
|
+
def note_exception
|
|
161
|
+
Current.record_exception if Current.scoped?
|
|
162
|
+
rescue StandardError, SystemStackError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
|
|
154
166
|
def mark_reported(error)
|
|
155
167
|
error.instance_variable_set(REPORTED_IVAR, true)
|
|
156
168
|
rescue FrozenError
|
|
@@ -177,7 +189,7 @@ module Devbench
|
|
|
177
189
|
# message is detail.
|
|
178
190
|
def handled_payload(error, symbol, reason)
|
|
179
191
|
{
|
|
180
|
-
v:
|
|
192
|
+
v: PROTOCOL_VERSION,
|
|
181
193
|
kind: 'handled_failure',
|
|
182
194
|
symbol: symbol,
|
|
183
195
|
error: safely { error.class.name },
|
|
@@ -190,7 +202,7 @@ module Devbench
|
|
|
190
202
|
|
|
191
203
|
def exception_payload(error, context, handled, symbol)
|
|
192
204
|
{
|
|
193
|
-
v:
|
|
205
|
+
v: PROTOCOL_VERSION,
|
|
194
206
|
kind: 'exception',
|
|
195
207
|
context: CONTEXTS.include?(context) ? context : 'explicit',
|
|
196
208
|
handled: handled ? true : false,
|