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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +82 -3
- data/lib/devbench/active_job_hooks.rb +121 -0
- data/lib/devbench/config.rb +6 -1
- data/lib/devbench/current.rb +49 -0
- data/lib/devbench/direct.rb +36 -11
- data/lib/devbench/fingerprint.rb +17 -3
- data/lib/devbench/identity.rb +17 -3
- data/lib/devbench/logs.rb +1 -1
- 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 +20 -8
- data/lib/devbench/request_signals.rb +359 -0
- data/lib/devbench/scrub.rb +72 -4
- data/lib/devbench/session.rb +38 -9
- 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 +13 -5
- 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/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.
|
|
@@ -142,15 +148,21 @@ module Devbench
|
|
|
142
148
|
false
|
|
143
149
|
end
|
|
144
150
|
|
|
145
|
-
# The `exception` report for an error,
|
|
146
|
-
#
|
|
147
|
-
#
|
|
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:
|
|
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,
|
|
@@ -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
|
data/lib/devbench/scrub.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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))
|
|
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)
|