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.
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/SERVER_SDK_SPEC.md, "In-process log capture
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 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('')
@@ -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, and reports exceptions that escape the app.
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
 
@@ -3,7 +3,7 @@
3
3
  require_relative 'reporter'
4
4
 
5
5
  module Devbench
6
- # The Rails-side exception hooks (docs/SERVER_SDK_SPEC.md, Capability 2).
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
@@ -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 (SERVER_SDK_SPEC,
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)
@@ -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/SERVER_SDK_SPEC.md, "Control socket — v1" — and hands it to the
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/SERVER_SDK_SPEC.md, "Control socket — v1".
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 reported?(error) || ignored?(error)
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: 1,
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: 1,
205
+ v: PROTOCOL_VERSION,
194
206
  kind: 'exception',
195
207
  context: CONTEXTS.include?(context) ? context : 'explicit',
196
208
  handled: handled ? true : false,