devbench 0.7.0 → 0.8.1

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.
@@ -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.
@@ -142,15 +148,21 @@ module Devbench
142
148
  false
143
149
  end
144
150
 
145
- # The `exception` report for an error, without sending it. Used by
146
- # the self-test (Devbench.test!), which sends one synthetic report
147
- # straight to ingest, built exactly as a real one would be.
151
+ # The `exception` report for an error, built exactly as a real one
152
+ # would be, without sending it. (The self-test no longer uses it: it
153
+ # POSTs /v1/check and sends no report.)
148
154
  def report_for(error, context:, handled:, symbol: nil)
149
155
  exception_payload(error, context, handled, symbol)
150
156
  end
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,
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fingerprint'
4
+
5
+ module Devbench
6
+ # Request signals (docs/server-sdk/failed-and-slow-requests.md, "Capability
7
+ # 2b — Failed and slow requests"; DECISIONS #171): what a request does
8
+ # without raising.
9
+ #
10
+ # * failed: the application answered 500–599 and no exception was
11
+ # reported during the request;
12
+ # * slow: it answered 100–499, not streaming, after more than 3,000 ms;
13
+ # * rate_limit_spike: one route's 429s in one unix minute met the spike
14
+ # rule (at least ten, and at least 10% of that route's requests).
15
+ #
16
+ # A port of the backend's internal/reqsignal (and the Go SDK's copy,
17
+ # sdk/server/go/reqsignal.go). A port can drift, and a drifted port reports
18
+ # a different set of problems by language, so it is held to the shared
19
+ # vectors (testdata/request_signal_vectors.json) every implementation must
20
+ # reproduce. Change nothing here without changing those in lockstep.
21
+ #
22
+ # Devbench::Middleware observes; this module classifies, builds and keeps
23
+ # the per-process spike detector. Nothing here raises into the
24
+ # application.
25
+ module RequestSignals
26
+ # The thresholds: constants, not configuration. Tenant-configurable
27
+ # thresholds are a later issue (DECISIONS #171), and every SDK must apply
28
+ # the same numbers or the same app reports differently by language.
29
+ SLOW_THRESHOLD_MS = 3000
30
+ SPIKE_WINDOW_SECONDS = 60
31
+ SPIKE_MIN_COUNT = 10
32
+ SPIKE_MIN_PERCENT = 10
33
+ MAX_ROUTES_PER_WINDOW = 512
34
+ MAX_ROUTE_CHARS = 255
35
+
36
+ # A request matched to no route pattern: one literal, never the raw path
37
+ # (a path carries ids and slugs).
38
+ UNKNOWN_ROUTE = '*'
39
+ # Any method outside the standard set, so a client sending invented
40
+ # methods cannot mint fingerprints.
41
+ OTHER_METHOD = 'OTHER'
42
+ METHODS = %w[GET HEAD POST PUT PATCH DELETE OPTIONS TRACE CONNECT].freeze
43
+
44
+ # The `signal` field of the `request` control message.
45
+ FAILED = 'failed'
46
+ SLOW = 'slow'
47
+ RATE_LIMIT_SPIKE = 'rate_limit_spike'
48
+
49
+ # Everything the direct transport needs to count a request signal: the
50
+ # count's kind, the fingerprint's type and message, the template text
51
+ # kept for evidence, and the raw exemplar (scrubbed at egress).
52
+ Built = Struct.new(:kind, :type, :message, :text, :exemplar)
53
+
54
+ # One (method, route) whose window met the spike rule. `window` is the
55
+ # window's start, unix seconds.
56
+ Spike = Struct.new(:method, :route, :window, :count, :total)
57
+
58
+ # The clock the middleware reads: monotonic milliseconds for a request's
59
+ # duration, wall milliseconds for the spike window. Replaceable only so
60
+ # tests can pin a duration to the millisecond.
61
+ module SystemClock
62
+ def self.monotonic_ms
63
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
64
+ end
65
+
66
+ def self.wall_ms
67
+ Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
68
+ end
69
+ end
70
+
71
+ # The 429 spike rule for one process's requests. Thread-safe: every
72
+ # request's thread observes, the flush thread ticks.
73
+ #
74
+ # One window is open at a time. An observation in a later window closes
75
+ # the open one first; one in an earlier window (a clock step back) counts
76
+ # in the open window. Memory is bounded by MAX_ROUTES_PER_WINDOW, and
77
+ # nothing is kept past the open window. A forked child starts empty: the
78
+ # parent reports what it saw.
79
+ class SpikeDetector
80
+ Tally = Struct.new(:count, :total)
81
+
82
+ def initialize
83
+ @lock = Mutex.new
84
+ reset
85
+ end
86
+
87
+ # Records one finished request at t_ms (unix milliseconds) and returns
88
+ # the spikes of any window it closed.
89
+ def observe(t_ms, method, route, status)
90
+ w = RequestSignals.window_of(t_ms)
91
+ key = [RequestSignals.normalize_method(method), RequestSignals.normalize_route(route)].freeze
92
+
93
+ @lock.synchronize do
94
+ check_fork
95
+ closed = []
96
+ if @window.nil?
97
+ start(w)
98
+ elsif w > @window
99
+ closed = evaluate
100
+ start(w)
101
+ end
102
+
103
+ tally = @routes[key]
104
+ if tally.nil?
105
+ return closed if @routes.size >= MAX_ROUTES_PER_WINDOW
106
+
107
+ tally = @routes[key] = Tally.new(0, 0)
108
+ end
109
+ tally.total += 1
110
+ tally.count += 1 if status == 429
111
+ closed
112
+ end
113
+ end
114
+
115
+ # Closes the open window if now_ms is past it (at each flush) and
116
+ # returns its spikes.
117
+ def tick(now_ms)
118
+ @lock.synchronize do
119
+ check_fork
120
+ return [] if @window.nil? || RequestSignals.window_of(now_ms) <= @window
121
+
122
+ out = evaluate
123
+ reset
124
+ out
125
+ end
126
+ end
127
+
128
+ # Evaluates the open window as it stands (process exit, a new
129
+ # configuration) and closes it.
130
+ def finish
131
+ @lock.synchronize do
132
+ check_fork
133
+ return [] if @window.nil?
134
+
135
+ out = evaluate
136
+ reset
137
+ out
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def reset
144
+ @pid = Process.pid
145
+ @window = nil
146
+ @routes = {}
147
+ end
148
+
149
+ def check_fork
150
+ reset unless @pid == Process.pid
151
+ end
152
+
153
+ def start(window)
154
+ @window = window
155
+ @routes = {}
156
+ end
157
+
158
+ # The open window's spikes, sorted by method then route (byte order,
159
+ # as Go's string comparison). Caller holds @lock.
160
+ def evaluate
161
+ @routes.filter_map do |(method, route), tally|
162
+ next unless RequestSignals.spike?(tally.count, tally.total)
163
+
164
+ Spike.new(method, route, @window * SPIKE_WINDOW_SECONDS, tally.count, tally.total)
165
+ end.sort_by { |s| [s.method.b, s.route.b] }
166
+ end
167
+ end
168
+
169
+ class << self
170
+ attr_writer :clock
171
+
172
+ def clock
173
+ @clock || SystemClock
174
+ end
175
+
176
+ # The process's spike detector, in both transports.
177
+ def detector
178
+ @detector ||= SpikeDetector.new
179
+ end
180
+
181
+ # The signal one finished request produces: FAILED, SLOW or nil. At
182
+ # most one per request. 429s are not classified here; they feed the
183
+ # spike detector.
184
+ def classify(status:, duration_ms:, exception_reported:, streaming:)
185
+ return nil if status < 100 || status > 599
186
+ # A 5xx after a reported exception: the exception is the report.
187
+ return (exception_reported ? nil : FAILED) if status >= 500
188
+ return nil if streaming || status == 101
189
+
190
+ duration_ms > SLOW_THRESHOLD_MS ? SLOW : nil
191
+ end
192
+
193
+ # Uppercased and trimmed; anything outside the standard set is OTHER.
194
+ def normalize_method(method)
195
+ m = Fingerprint.trim(utf8(method)).upcase
196
+ METHODS.include?(m) ? m : OTHER_METHOD
197
+ end
198
+
199
+ # Trimmed and bounded to MAX_ROUTE_CHARS code points; empty is
200
+ # UNKNOWN_ROUTE.
201
+ def normalize_route(route)
202
+ r = Fingerprint.trim(utf8(route))
203
+ return UNKNOWN_ROUTE if r.empty?
204
+
205
+ r.length > MAX_ROUTE_CHARS ? r[0, MAX_ROUTE_CHARS] : r
206
+ end
207
+
208
+ # Fingerprint inputs, template text and exemplar for a request signal,
209
+ # as the sidecar builds them from the `request` control message. Nil
210
+ # for a signal/status pair a sensor must refuse.
211
+ def build(signal:, method:, route:, status:, duration_ms: 0, count: 0, total: 0)
212
+ status = status.to_i
213
+ message = "#{normalize_method(method)} #{normalize_route(route)}"
214
+ case signal
215
+ when FAILED
216
+ return nil unless status.between?(500, 599)
217
+
218
+ type = "http_#{status}"
219
+ new_built('error', type, message, "#{type}: #{message} in #{non_negative(duration_ms)} ms")
220
+ when SLOW
221
+ return nil unless status.between?(100, 499)
222
+
223
+ type = 'slow_request'
224
+ new_built('degradation', type, message,
225
+ "#{type}: #{message} in #{non_negative(duration_ms)} ms (status #{status})")
226
+ when RATE_LIMIT_SPIKE
227
+ type = 'http_429_spike'
228
+ new_built('error', type, message,
229
+ "#{type}: #{message} #{non_negative(count)} of #{non_negative(total)} " \
230
+ 'requests answered 429 in one minute')
231
+ end
232
+ end
233
+
234
+ # The rule, in integer arithmetic: at least SPIKE_MIN_COUNT 429s, and
235
+ # at least SPIKE_MIN_PERCENT of the route's requests in the window.
236
+ def spike?(count, total)
237
+ count >= SPIKE_MIN_COUNT && count * 100 >= total * SPIKE_MIN_PERCENT
238
+ end
239
+
240
+ # Floors toward negative infinity (Ruby's integer division does), so a
241
+ # pre-1970 clock still maps to one window per minute.
242
+ def window_of(t_ms)
243
+ t_ms.to_i / (SPIKE_WINDOW_SECONDS * 1000)
244
+ end
245
+
246
+ # ---- Reporting (called by Devbench::Middleware and the flushes)
247
+
248
+ # Classifies a finished request, reports its signal, and feeds the spike
249
+ # detector. Called inside the request's scope, so a signal carries its
250
+ # trace and user. Never raises.
251
+ def observe(status:, method:, route:, duration_ms:, streaming:, exception_reported:, finished_ms:)
252
+ return nil unless status.is_a?(Integer)
253
+
254
+ method = normalize_method(method)
255
+ route = normalize_route(route)
256
+ signal = classify(status: status, duration_ms: duration_ms,
257
+ exception_reported: exception_reported, streaming: streaming)
258
+ emit(request_payload(signal, method, route, status, duration_ms)) unless signal.nil?
259
+ return nil unless status.between?(100, 599)
260
+
261
+ install_exit_hook
262
+ report_spikes(detector.observe(finished_ms, method, route, status))
263
+ start_flusher if status == 429
264
+ nil
265
+ rescue StandardError, SystemStackError
266
+ nil
267
+ end
268
+
269
+ # Closes the spike window if the clock has left it (at a flush) and
270
+ # reports its spikes to `transport`.
271
+ def tick!(transport = nil)
272
+ report_spikes(detector.tick(clock.wall_ms), transport)
273
+ rescue StandardError, SystemStackError
274
+ nil
275
+ end
276
+
277
+ # Evaluates the open window as it stands and reports its spikes (process
278
+ # exit, a replaced configuration). Returns how many were reported.
279
+ def finish!(transport = nil)
280
+ report_spikes(detector.finish, transport)
281
+ rescue StandardError, SystemStackError
282
+ 0
283
+ end
284
+
285
+ # The `request` control message for a spike: an aggregate of many
286
+ # requests, so no trace and no user.
287
+ def spike_payload(spike)
288
+ { v: PROTOCOL_VERSION, kind: 'request', signal: RATE_LIMIT_SPIKE, method: spike.method,
289
+ route: spike.route, status: 429, count: spike.count, total: spike.total }
290
+ end
291
+
292
+ private
293
+
294
+ def new_built(kind, type, message, exemplar)
295
+ Built.new(kind, type, message, "#{type} at #{message}", exemplar)
296
+ end
297
+
298
+ def non_negative(n)
299
+ n = n.to_i
300
+ n.negative? ? 0 : n
301
+ end
302
+
303
+ def utf8(text)
304
+ text.to_s.encode('UTF-8', invalid: :replace, undef: :replace).scrub
305
+ end
306
+
307
+ def request_payload(signal, method, route, status, duration_ms)
308
+ {
309
+ v: PROTOCOL_VERSION, kind: 'request', signal: signal, method: method, route: route,
310
+ status: status, duration_ms: non_negative(duration_ms),
311
+ trace: safely { Current.trace&.to_s }, user: safely { Current.user }
312
+ }.compact
313
+ end
314
+
315
+ def report_spikes(spikes, transport = nil)
316
+ spikes.each { |s| emit(spike_payload(s), transport) }
317
+ spikes.length
318
+ end
319
+
320
+ def emit(payload, transport = nil)
321
+ (transport || Devbench.transport).deliver(payload)
322
+ rescue StandardError, SystemStackError
323
+ nil
324
+ end
325
+
326
+ # Direct mode: a process whose only traffic is a burst of 429s has
327
+ # counted nothing, so its flush thread may not be running; without it
328
+ # the spike would wait for the next request or the process's exit.
329
+ def start_flusher
330
+ transport = Devbench.transport
331
+ transport.start if transport.respond_to?(:start)
332
+ rescue StandardError, SystemStackError
333
+ nil
334
+ end
335
+
336
+ # The open window is evaluated at process exit, in either transport:
337
+ # the spikes are delivered, then direct mode flushes them (bounded, as
338
+ # the transport's own exit flush is). Inherited by forked children,
339
+ # which report their own window.
340
+ def install_exit_hook
341
+ return if @exit_hook_installed
342
+
343
+ @exit_hook_installed = true
344
+ at_exit do
345
+ reported = finish!
346
+ Devbench.transport.flush!(timeout: DirectTransport::EXIT_TIMEOUT) if reported.positive?
347
+ rescue StandardError, SystemStackError
348
+ nil
349
+ end
350
+ end
351
+
352
+ def safely
353
+ yield
354
+ rescue StandardError, SystemStackError
355
+ nil
356
+ end
357
+ end
358
+ end
359
+ end
@@ -25,6 +25,25 @@ module Devbench
25
25
  end
26
26
  private_class_method :go_re
27
27
 
28
+ # The card and phone rules (#186; internal/scrub/numbers.go, the
29
+ # `numbers` vectors): a candidate regex plus checks on the whole
30
+ # candidate, run in the gsub block. A rejected candidate is left as
31
+ # written and matching carries on after it; it is never retried shorter.
32
+ # No lookaround in the candidates (the Go SDK is RE2), so every SDK finds
33
+ # the same ones.
34
+ CARD = '<redacted:card>'
35
+ PHONE = '<redacted:phone>'
36
+ CARD_CANDIDATE = go_re('\b(?:\d[ \-]*?){13,19}\b')
37
+ # Group 1 is the character before the number (or the start of the text):
38
+ # a phone number is not glued to a letter, digit, `_` or `-`, which keeps
39
+ # a UUID's tail and `ord_123456789` out. Group 2 is the candidate.
40
+ PHONE_CANDIDATE = /(\A|[^A-Za-z0-9_\-])(\+?\d[\d\t\n\f\r ().\-]{7,}\d)/
41
+ ISO_DATE_PREFIX = /\A\d{4}-\d{2}-\d{2}(?:\z|\D)/
42
+ DECIMAL = /\A\d+\.\d+\z/
43
+ WORD_BYTE = /\A[A-Za-z0-9_]/
44
+ # E.164 caps a phone number at 15 digits; anything longer is an id.
45
+ PHONE_DIGITS = (7..15)
46
+
28
47
  # Ordered most-specific first, and the order is load-bearing: see the
29
48
  # comments on internal/scrub's rules (a credential containing an email,
30
49
  # a phone pattern eating an SSN).
@@ -39,11 +58,14 @@ module Devbench
39
58
  [go_re('\b(password|passwd|pwd|secret|api[_\-]?key|access[_\-]?token|refresh[_\-]?token|' \
40
59
  'client[_\-]?secret|private[_\-]?key|session[_\-]?id|csrf[_\-]?token)' \
41
60
  "(#{WS}*[:=]#{WS}*)(\"[^\"]*\"|'[^']*'|#{NOT_WS}+)", Regexp::IGNORECASE), '\1\2<redacted:secret>'],
42
- [go_re('\b[^\t\n\f\r <>@]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b'), '<redacted:email>'],
61
+ # The local part stops at `=` and `:` (#148): `customer=sam@x.com`
62
+ # keeps its key. Same class as the template's email rule.
63
+ [go_re('\b[^\t\n\f\r <>@=:]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b'), '<redacted:email>'],
43
64
  [go_re('\b\d{3}-\d{2}-\d{4}\b'), '<redacted:ssn>'],
44
- [go_re('\b(?:\d[ \-]*?){13,19}\b'), '<redacted:card>'],
65
+ # The card and phone rules (#186) judge each candidate in the block.
66
+ [CARD_CANDIDATE, ->(m) { card?(m[0]) ? CARD : m[0] }],
45
67
  [go_re('\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'), '<redacted:ip>'],
46
- [/\+?\d[\d\t\n\f\r ().\-]{7,}\d/, '<redacted:phone>']
68
+ [PHONE_CANDIDATE, ->(m) { phone?(m[2], m.string[m.end(0)].to_s) ? "#{m[1]}#{PHONE}" : m[0] }]
47
69
  ].freeze
48
70
 
49
71
  # internal/sidecar/trace.go's traceRe.
@@ -75,7 +97,33 @@ module Devbench
75
97
  class << self
76
98
  # internal/scrub.Text: every shape rule, in order.
77
99
  def text(line)
78
- RULES.reduce(utf8(line)) { |out, (re, with)| out.gsub(re, with) }
100
+ RULES.reduce(utf8(line)) do |out, (re, with)|
101
+ with.is_a?(Proc) ? out.gsub(re) { with.call(Regexp.last_match) } : out.gsub(re, with)
102
+ end
103
+ end
104
+
105
+ # A card: its digits pass the Luhn check, and it is not a bare 13-digit
106
+ # run starting with 1 (a millisecond timestamp).
107
+ def card?(candidate)
108
+ digits = candidate.delete('^0-9')
109
+ return false if digits == candidate && timestamp?(digits)
110
+
111
+ luhn?(digits)
112
+ end
113
+
114
+ # A phone, judged with the character after it ('' at the end of the
115
+ # text; one character is all the check reads). Not one when a letter, digit
116
+ # or `_` follows; it starts with an ISO date; it is a decimal; it has
117
+ # fewer than 7 or more than 15 digits; or it is a bare 10- or 13-digit
118
+ # run starting with 1 (a timestamp).
119
+ def phone?(candidate, after)
120
+ return false if WORD_BYTE.match?(after)
121
+ return false if ISO_DATE_PREFIX.match?(candidate) || DECIMAL.match?(candidate)
122
+
123
+ digits = candidate.delete('^0-9')
124
+ return false unless PHONE_DIGITS.cover?(digits.length)
125
+
126
+ !(digits == candidate && timestamp?(digits))
79
127
  end
80
128
 
81
129
  # internal/scrub.Prose: runs of two or more capitalised words that are
@@ -153,6 +201,26 @@ module Devbench
153
201
 
154
202
  private
155
203
 
204
+ # A Unix timestamp in seconds or milliseconds (2001 to 2033).
205
+ def timestamp?(digits)
206
+ (digits.length == 10 || digits.length == 13) && digits.start_with?('1')
207
+ end
208
+
209
+ def luhn?(digits)
210
+ return false if digits.empty?
211
+
212
+ sum = 0
213
+ digits.reverse.each_char.with_index do |c, i|
214
+ d = c.ord - 48
215
+ if i.odd?
216
+ d *= 2
217
+ d -= 9 if d > 9
218
+ end
219
+ sum += d
220
+ end
221
+ (sum % 10).zero?
222
+ end
223
+
156
224
  # Base redaction (template + scrub), the prose heuristic unless the
157
225
  # shape is marked 'full', then field and term rules.
158
226
  def redact_body(body, rule, policy)