devbench 0.5.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 +7 -0
- data/README.md +225 -0
- data/lib/adt.rb +5 -0
- data/lib/devbench/backtrace.rb +61 -0
- data/lib/devbench/config.rb +146 -0
- data/lib/devbench/current.rb +74 -0
- data/lib/devbench/direct.rb +470 -0
- data/lib/devbench/fingerprint.rb +180 -0
- data/lib/devbench/http.rb +34 -0
- data/lib/devbench/identity.rb +53 -0
- data/lib/devbench/middleware.rb +135 -0
- data/lib/devbench/rails_hooks.rb +126 -0
- data/lib/devbench/railtie.rb +107 -0
- data/lib/devbench/reporter.rb +247 -0
- data/lib/devbench/scrub.rb +154 -0
- data/lib/devbench/session.rb +99 -0
- data/lib/devbench/session_middleware.rb +73 -0
- data/lib/devbench/sidecar_transport.rb +95 -0
- data/lib/devbench/sidekiq_hooks.rb +208 -0
- data/lib/devbench/trace.rb +79 -0
- data/lib/devbench/transport.rb +181 -0
- data/lib/devbench/version.rb +10 -0
- data/lib/devbench.rb +37 -0
- metadata +75 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require_relative 'fingerprint'
|
|
8
|
+
require_relative 'scrub'
|
|
9
|
+
|
|
10
|
+
module Devbench
|
|
11
|
+
# Direct mode (docs/SERVER_SDK_SPEC.md, "Direct mode"): fingerprint and
|
|
12
|
+
# count in-process, flush counts to ingest once a minute, upload one
|
|
13
|
+
# redacted evidence bundle per fingerprint only when ingest asks. The same
|
|
14
|
+
# phase-1/phase-2 protocol as the browser sensor, and the same signals and
|
|
15
|
+
# bundle the sidecar builds from the equivalent control message, so a
|
|
16
|
+
# customer can move between modes without forking an issue.
|
|
17
|
+
#
|
|
18
|
+
# Never harms the host:
|
|
19
|
+
# * The calling thread fingerprints the report (tens of microseconds, no
|
|
20
|
+
# I/O) and folds it into a count under a lock that is never held across
|
|
21
|
+
# I/O. Nothing is queued per occurrence: the table of counts *is* the
|
|
22
|
+
# buffer, bounded at MAX_FINGERPRINTS per window; further distinct
|
|
23
|
+
# fingerprints are counted in `overflowed`, as the browser does.
|
|
24
|
+
# * All network I/O is on a background thread (or an explicit flush!),
|
|
25
|
+
# with timeouts of at most 5 s, one retry with jitter, then discard.
|
|
26
|
+
# * Nothing raises into the application.
|
|
27
|
+
# * Fork-safe: Puma, Unicorn and Sidekiq fork after boot. A child notices
|
|
28
|
+
# the PID change on its first report, drops the parent's counts (the
|
|
29
|
+
# parent sends those), takes a new sensor id, and starts its own thread.
|
|
30
|
+
class DirectTransport
|
|
31
|
+
MAX_FINGERPRINTS = 512
|
|
32
|
+
MAX_USERS = 20
|
|
33
|
+
MAX_DETAILS = 512
|
|
34
|
+
MAX_COUNTS_PER_POST = 512
|
|
35
|
+
MAX_BODY_BYTES = 192 * 1024
|
|
36
|
+
MAX_MESSAGE = 2000
|
|
37
|
+
MAX_FRAMES = 50
|
|
38
|
+
MAX_IDENTITY = 255
|
|
39
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
40
|
+
HTTP_TIMEOUT = 5.0
|
|
41
|
+
EXIT_TIMEOUT = 2.0
|
|
42
|
+
|
|
43
|
+
Count = Struct.new(:kind, :n, :first, :last, :users, :users_overflow)
|
|
44
|
+
Detail = Struct.new(:kind, :text, :exemplar, :frames)
|
|
45
|
+
Signal = Struct.new(:fp, :kind, :text, :user, :frames, :exemplar)
|
|
46
|
+
|
|
47
|
+
attr_reader :dsn, :service, :release
|
|
48
|
+
|
|
49
|
+
def initialize(dsn:, service:, release:, interval: 60)
|
|
50
|
+
@dsn = dsn
|
|
51
|
+
@service = service.to_s
|
|
52
|
+
@release = release.to_s
|
|
53
|
+
@interval = interval.to_f.positive? ? interval.to_f : 60.0
|
|
54
|
+
@flush_uri = URI(dsn.flush_url)
|
|
55
|
+
@fork_lock = Mutex.new
|
|
56
|
+
@stopped = false
|
|
57
|
+
reset_state
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Called by Reporter on the application's thread with a control message
|
|
61
|
+
# (the same hash the sidecar transport would write to its socket).
|
|
62
|
+
def deliver(payload)
|
|
63
|
+
signal = signal_for(payload)
|
|
64
|
+
return nil if signal.nil?
|
|
65
|
+
|
|
66
|
+
ensure_running
|
|
67
|
+
@lock.synchronize { record(signal) }
|
|
68
|
+
nil
|
|
69
|
+
rescue StandardError, SystemStackError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Sends everything counted so far, now, waiting at most `timeout`
|
|
74
|
+
# seconds. Returns true when nothing is left unsent.
|
|
75
|
+
def flush!(timeout: HTTP_TIMEOUT)
|
|
76
|
+
check_fork
|
|
77
|
+
deadline = monotonic + timeout.to_f
|
|
78
|
+
return false unless acquire(@cycle, deadline)
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
flush_cycle(deadline)
|
|
82
|
+
ensure
|
|
83
|
+
@cycle.unlock
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError, SystemStackError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A last flush (bounded to EXIT_TIMEOUT), then the thread stops. Called
|
|
90
|
+
# when the transport is replaced (Devbench.configure, Devbench.reset!).
|
|
91
|
+
def stop
|
|
92
|
+
flush!(timeout: EXIT_TIMEOUT)
|
|
93
|
+
@stopped = true
|
|
94
|
+
lock = @lock
|
|
95
|
+
lock.synchronize { @wake.broadcast }
|
|
96
|
+
thread = @thread
|
|
97
|
+
thread.join(0.5) if thread && thread != Thread.current
|
|
98
|
+
nil
|
|
99
|
+
rescue StandardError, SystemStackError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Counts not yet flushed, by fingerprint. For tests and diagnostics.
|
|
104
|
+
def pending
|
|
105
|
+
@lock.synchronize { @window.transform_values { |c| c.n } }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# rake devbench:test. Sends one synthetic exception straight to ingest
|
|
109
|
+
# (not via the background thread), uploads its evidence if asked, and
|
|
110
|
+
# prints what happened. Returns true when ingest accepted it.
|
|
111
|
+
def self_test(payload, io)
|
|
112
|
+
signal = signal_for(payload)
|
|
113
|
+
now = Time.now.to_i
|
|
114
|
+
body = bodies({ signal.fp => Count.new(signal.kind, 1, now, now, [], 0) }, 0).first
|
|
115
|
+
remember(signal)
|
|
116
|
+
|
|
117
|
+
io.puts "Dev Bench: sending a test exception to #{@dsn} (service #{@service.inspect})"
|
|
118
|
+
response, error = attempt(:post, @flush_uri, body, flush_headers, HTTP_TIMEOUT)
|
|
119
|
+
if error
|
|
120
|
+
io.puts " could not reach #{@dsn}: #{error.class}: #{error.message}"
|
|
121
|
+
return false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
code = response.code.to_i
|
|
125
|
+
unless (200..299).cover?(code)
|
|
126
|
+
io.puts " HTTP #{code}: #{explain(code, response)}"
|
|
127
|
+
return false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
io.puts " HTTP #{code}: accepted (fingerprint #{signal.fp[0, 12]})"
|
|
131
|
+
asks(response).each do |ask|
|
|
132
|
+
res = upload(ask, monotonic + HTTP_TIMEOUT)
|
|
133
|
+
io.puts(res ? " evidence uploaded (HTTP #{res.code})" : ' evidence upload failed')
|
|
134
|
+
end
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
# ---- Signals: exactly what the sidecar builds (internal/sidecar/control.go)
|
|
141
|
+
|
|
142
|
+
def signal_for(payload)
|
|
143
|
+
return nil unless payload.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
case payload[:kind]
|
|
146
|
+
when 'exception' then exception_signal(payload)
|
|
147
|
+
when 'handled_failure' then handled_signal(payload)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# recordException: {error, server, service, type, message[0,2000], frames}.
|
|
152
|
+
def exception_signal(payload)
|
|
153
|
+
type = payload[:error].to_s
|
|
154
|
+
message = truncate(payload[:message].to_s, MAX_MESSAGE)
|
|
155
|
+
frames = Array(payload[:frames]).first(MAX_FRAMES)
|
|
156
|
+
fp = Fingerprint.compute(kind: 'error', source: 'server', service: @service,
|
|
157
|
+
type: type, message: message, frames: frames)
|
|
158
|
+
return nil if fp.nil?
|
|
159
|
+
|
|
160
|
+
symbol = payload[:symbol].to_s
|
|
161
|
+
text = symbol.empty? ? type : "#{type} at #{symbol}"
|
|
162
|
+
Signal.new(fp, 'error', text, user(payload[:user]), frames, exemplar(payload))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# recordHandled: {handled_failure, server, service, type, message: symbol}.
|
|
166
|
+
# Frames and message are not part of it, so existing fingerprints never
|
|
167
|
+
# move.
|
|
168
|
+
def handled_signal(payload)
|
|
169
|
+
symbol = payload[:symbol].to_s
|
|
170
|
+
fp = Fingerprint.compute(kind: 'handled_failure', source: 'server', service: @service,
|
|
171
|
+
type: payload[:error].to_s, message: symbol)
|
|
172
|
+
return nil if fp.nil?
|
|
173
|
+
|
|
174
|
+
frames = Array(payload[:frames]).first(MAX_FRAMES)
|
|
175
|
+
Signal.new(fp, 'handled_failure', symbol, user(payload[:user]), frames, exemplar(payload))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The sidecar's exemplar line: `Error: message [context, handled]
|
|
179
|
+
# reason=...`. Raw here; templated and scrubbed when it leaves.
|
|
180
|
+
def exemplar(payload)
|
|
181
|
+
line = payload[:error].to_s.dup
|
|
182
|
+
message = truncate(payload[:message].to_s, MAX_MESSAGE)
|
|
183
|
+
line << ': ' << message.tr("\n", ' ') unless message.empty?
|
|
184
|
+
context = payload[:context].to_s
|
|
185
|
+
unless context.empty?
|
|
186
|
+
line << ' [' << context
|
|
187
|
+
line << ', handled' if payload[:kind] == 'exception' && payload[:handled]
|
|
188
|
+
line << ']'
|
|
189
|
+
end
|
|
190
|
+
reason = payload[:reason].to_s
|
|
191
|
+
line << ' reason=' << reason unless reason.empty?
|
|
192
|
+
line
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Identity travels only in the count's users list, never in a
|
|
196
|
+
# fingerprint, template or bundle.
|
|
197
|
+
def user(raw)
|
|
198
|
+
return nil unless raw.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
email = truncate(Fingerprint.trim((raw[:email] || raw['email']).to_s).downcase, MAX_IDENTITY)
|
|
201
|
+
account = truncate(Fingerprint.trim((raw[:account] || raw['account']).to_s), MAX_IDENTITY)
|
|
202
|
+
out = {}
|
|
203
|
+
out[:email] = email unless email.empty?
|
|
204
|
+
out[:account] = account unless account.empty?
|
|
205
|
+
out.empty? ? nil : out
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def truncate(text, max)
|
|
209
|
+
text.length > max ? text[0, max] : text
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# ---- The table (caller holds @lock)
|
|
213
|
+
|
|
214
|
+
def record(signal)
|
|
215
|
+
now = Time.now.to_i
|
|
216
|
+
entry = @window[signal.fp]
|
|
217
|
+
if entry.nil?
|
|
218
|
+
if @window.size >= MAX_FINGERPRINTS
|
|
219
|
+
@overflowed += 1
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
entry = @window[signal.fp] = Count.new(signal.kind, 0, now, now, [], 0)
|
|
223
|
+
end
|
|
224
|
+
entry.n += 1
|
|
225
|
+
entry.last = now if now > entry.last
|
|
226
|
+
add_user(entry, signal.user)
|
|
227
|
+
remember(signal)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def add_user(entry, user)
|
|
231
|
+
return if user.nil? || entry.users.include?(user)
|
|
232
|
+
|
|
233
|
+
if entry.users.length >= MAX_USERS
|
|
234
|
+
entry.users_overflow += 1
|
|
235
|
+
else
|
|
236
|
+
entry.users << user
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The first occurrence's detail, kept across windows so evidence can be
|
|
241
|
+
# answered after the flush that reported it. Bounded, least recently
|
|
242
|
+
# seen first out.
|
|
243
|
+
def remember(signal)
|
|
244
|
+
detail = @details.delete(signal.fp)
|
|
245
|
+
detail ||= Detail.new(signal.kind, signal.text, signal.exemplar, signal.frames)
|
|
246
|
+
detail.frames = signal.frames if detail.frames.empty? && !signal.frames.empty?
|
|
247
|
+
@details[signal.fp] = detail
|
|
248
|
+
@details.shift while @details.size > MAX_DETAILS
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ---- Lifecycle
|
|
252
|
+
|
|
253
|
+
def reset_state
|
|
254
|
+
@pid = Process.pid
|
|
255
|
+
@lock = Mutex.new
|
|
256
|
+
@wake = ConditionVariable.new
|
|
257
|
+
@cycle = Mutex.new
|
|
258
|
+
@window = {}
|
|
259
|
+
@details = {}
|
|
260
|
+
@overflowed = 0
|
|
261
|
+
@sensor_id = "rb-#{SecureRandom.hex(10)}"
|
|
262
|
+
@thread = nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def check_fork
|
|
266
|
+
return if @pid == Process.pid
|
|
267
|
+
|
|
268
|
+
@fork_lock.synchronize { reset_state if @pid != Process.pid }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def ensure_running
|
|
272
|
+
check_fork
|
|
273
|
+
return if @stopped || @thread&.alive?
|
|
274
|
+
|
|
275
|
+
@fork_lock.synchronize do
|
|
276
|
+
return if @stopped || @thread&.alive?
|
|
277
|
+
|
|
278
|
+
@thread = Thread.new { run }
|
|
279
|
+
@thread.name = 'devbench-flush' if @thread.respond_to?(:name=)
|
|
280
|
+
@thread.report_on_exception = false
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def run
|
|
285
|
+
next_at = monotonic + @interval
|
|
286
|
+
until @stopped
|
|
287
|
+
lock = @lock
|
|
288
|
+
lock.synchronize do
|
|
289
|
+
while !@stopped && (remaining = next_at - monotonic).positive?
|
|
290
|
+
@wake.wait(lock, remaining)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
break if @stopped
|
|
294
|
+
|
|
295
|
+
next_at = monotonic + @interval
|
|
296
|
+
begin
|
|
297
|
+
@cycle.synchronize { flush_cycle(monotonic + (HTTP_TIMEOUT * 4)) }
|
|
298
|
+
rescue StandardError, SystemStackError
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# ---- Flushing
|
|
305
|
+
|
|
306
|
+
def flush_cycle(deadline)
|
|
307
|
+
counts, overflowed = @lock.synchronize do
|
|
308
|
+
taken = [@window, @overflowed]
|
|
309
|
+
@window = {}
|
|
310
|
+
@overflowed = 0
|
|
311
|
+
taken
|
|
312
|
+
end
|
|
313
|
+
return true if counts.empty?
|
|
314
|
+
|
|
315
|
+
ok = true
|
|
316
|
+
wanted = []
|
|
317
|
+
bodies(counts, overflowed).each do |body|
|
|
318
|
+
response = request(:post, @flush_uri, body, flush_headers, deadline)
|
|
319
|
+
if response.nil?
|
|
320
|
+
ok = false
|
|
321
|
+
next
|
|
322
|
+
end
|
|
323
|
+
code = response.code.to_i
|
|
324
|
+
if (200..299).cover?(code)
|
|
325
|
+
wanted.concat(asks(response))
|
|
326
|
+
else
|
|
327
|
+
ok = false
|
|
328
|
+
Devbench.warn_once(:"http_#{code}", "Dev Bench refused a flush: HTTP #{code}: #{explain(code, response)}")
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
wanted.each { |ask| upload(ask, deadline) }
|
|
332
|
+
ok
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Counts split so each request stays under MAX_BODY_BYTES (measured, not
|
|
336
|
+
# estimated) and MAX_COUNTS_PER_POST. `overflowed` rides on the first.
|
|
337
|
+
def bodies(counts, overflowed)
|
|
338
|
+
out = []
|
|
339
|
+
current = []
|
|
340
|
+
size = 0
|
|
341
|
+
budget = MAX_BODY_BYTES - envelope([], overflowed).bytesize
|
|
342
|
+
counts.each do |fp, c|
|
|
343
|
+
entry = { fp: fp, n: c.n, first: c.first, last: c.last, kind: c.kind }
|
|
344
|
+
entry[:users] = c.users unless c.users.empty?
|
|
345
|
+
entry[:users_overflow] = c.users_overflow if c.users_overflow.positive?
|
|
346
|
+
bytes = JSON.generate(entry).bytesize + 1
|
|
347
|
+
if !current.empty? && (current.length >= MAX_COUNTS_PER_POST || size + bytes > budget)
|
|
348
|
+
out << envelope(current, out.empty? ? overflowed : 0)
|
|
349
|
+
current = []
|
|
350
|
+
size = 0
|
|
351
|
+
end
|
|
352
|
+
current << entry
|
|
353
|
+
size += bytes
|
|
354
|
+
end
|
|
355
|
+
out << envelope(current, out.empty? ? overflowed : 0) unless current.empty?
|
|
356
|
+
out
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def envelope(counts, overflowed)
|
|
360
|
+
body = { v: 1, tenant: '', source: 'server', service: @service, release: @release,
|
|
361
|
+
sensor_id: @sensor_id, counts: counts }
|
|
362
|
+
body[:overflowed] = overflowed if overflowed.positive?
|
|
363
|
+
JSON.generate(body)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def flush_headers
|
|
367
|
+
{ 'content-type' => 'application/json', 'x-adt-key' => @dsn.key,
|
|
368
|
+
'user-agent' => "devbench-ruby/#{Devbench::VERSION}" }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def asks(response)
|
|
372
|
+
body = response.body.to_s
|
|
373
|
+
return [] if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
|
|
374
|
+
|
|
375
|
+
parsed = JSON.parse(body)
|
|
376
|
+
list = parsed.is_a?(Hash) ? parsed['need_evidence'] : nil
|
|
377
|
+
list.is_a?(Array) ? list.select { |a| a.is_a?(Hash) } : []
|
|
378
|
+
rescue JSON::ParserError
|
|
379
|
+
# Counts were accepted; an unreadable answer only loses the ask.
|
|
380
|
+
[]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Phase 2: the sidecar's bundle shape, PUT to the presigned URL with no
|
|
384
|
+
# key (it carries its own authorization).
|
|
385
|
+
def upload(ask, deadline)
|
|
386
|
+
fp = ask['fp']
|
|
387
|
+
url = ask['url']
|
|
388
|
+
return nil unless fp.is_a?(String) && url.is_a?(String) && !url.empty?
|
|
389
|
+
|
|
390
|
+
expires = ask['expires'].to_i
|
|
391
|
+
return nil if expires.positive? && expires <= Time.now.to_i
|
|
392
|
+
|
|
393
|
+
detail = @lock.synchronize { @details[fp] }
|
|
394
|
+
return nil if detail.nil?
|
|
395
|
+
|
|
396
|
+
bundle = {
|
|
397
|
+
v: 1, fp: fp, kind: detail.kind,
|
|
398
|
+
template: Scrub.template_text(detail.text), service: @service,
|
|
399
|
+
exemplars: detail.exemplar.empty? ? [] : [Scrub.egress(detail.exemplar)]
|
|
400
|
+
}
|
|
401
|
+
bundle[:frames] = detail.frames unless detail.frames.empty?
|
|
402
|
+
response = request(:put, URI(url), JSON.generate(bundle), { 'content-type' => 'application/json' }, deadline)
|
|
403
|
+
response && (200..299).cover?(response.code.to_i) ? response : nil
|
|
404
|
+
rescue StandardError, SystemStackError
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# One try, then one retry with jitter on a network error or 5xx, inside
|
|
409
|
+
# the deadline. A 4xx is final. Returns the response or nil.
|
|
410
|
+
def request(method, uri, body, headers, deadline)
|
|
411
|
+
2.times do |i|
|
|
412
|
+
remaining = deadline - monotonic
|
|
413
|
+
return nil if remaining < 0.05
|
|
414
|
+
|
|
415
|
+
response, error = attempt(method, uri, body, headers, [HTTP_TIMEOUT, remaining].min)
|
|
416
|
+
return response if error.nil? && response.code.to_i < 500
|
|
417
|
+
|
|
418
|
+
if error
|
|
419
|
+
Devbench.warn_once(:unreachable, "could not reach Dev Bench at #{@dsn} (#{error.class}); " \
|
|
420
|
+
'counts for that window were dropped')
|
|
421
|
+
end
|
|
422
|
+
jitter = 0.1 + rand * 0.4
|
|
423
|
+
sleep(jitter) if i.zero? && deadline - monotonic > jitter + 0.05
|
|
424
|
+
end
|
|
425
|
+
nil
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def attempt(method, uri, body, headers, timeout)
|
|
429
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https',
|
|
430
|
+
open_timeout: timeout, read_timeout: timeout,
|
|
431
|
+
write_timeout: timeout, ssl_timeout: timeout) do |http|
|
|
432
|
+
klass = method == :post ? Net::HTTP::Post : Net::HTTP::Put
|
|
433
|
+
req = klass.new(uri.request_uri, headers)
|
|
434
|
+
req.body = body
|
|
435
|
+
http.request(req)
|
|
436
|
+
end
|
|
437
|
+
[response, nil]
|
|
438
|
+
rescue StandardError => e
|
|
439
|
+
[nil, e]
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def explain(code, response)
|
|
443
|
+
detail = begin
|
|
444
|
+
JSON.parse(response.body.to_s)['error']
|
|
445
|
+
rescue StandardError
|
|
446
|
+
nil
|
|
447
|
+
end
|
|
448
|
+
case code
|
|
449
|
+
when 401 then 'the key in DEVBENCH_DSN was rejected (wrong, revoked, or for another environment)'
|
|
450
|
+
when 429 then 'rate limited; counts for that window were dropped'
|
|
451
|
+
else detail.is_a?(String) ? detail : (response.message.to_s.empty? ? 'unexpected response' : response.message)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Ruby's Mutex has no timed lock; spin briefly instead of waiting forever
|
|
456
|
+
# behind a background flush.
|
|
457
|
+
def acquire(mutex, deadline)
|
|
458
|
+
loop do
|
|
459
|
+
return true if mutex.try_lock
|
|
460
|
+
return false if monotonic >= deadline
|
|
461
|
+
|
|
462
|
+
sleep 0.01
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def monotonic
|
|
467
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Devbench
|
|
6
|
+
# A port of internal/fingerprint (Go): the same signal gives the same
|
|
7
|
+
# fingerprint, byte for byte, as the sidecar and the browser compute.
|
|
8
|
+
#
|
|
9
|
+
# If two implementations disagree, the same problem is counted as two:
|
|
10
|
+
# triage cost doubles and one issue is split in half, and a customer moving
|
|
11
|
+
# between direct and sidecar mode would fork every issue they have.
|
|
12
|
+
# testdata/fingerprint_vectors.json holds all of them to one answer
|
|
13
|
+
# (test/fingerprint_vectors_test.rb).
|
|
14
|
+
#
|
|
15
|
+
# Go's regexp and Ruby's differ in ways that matter here, so the patterns
|
|
16
|
+
# are spelled out rather than copied:
|
|
17
|
+
# * Go's \b is an ASCII word boundary; Ruby's treats "é" as a word
|
|
18
|
+
# character. GO_B rebuilds Go's.
|
|
19
|
+
# * Go's \s is [\t\n\f\r ]; Ruby's adds \v. GO_WS is Go's.
|
|
20
|
+
# * Go's strings.TrimSpace trims Unicode spaces; String#strip does not.
|
|
21
|
+
module Fingerprint
|
|
22
|
+
GO_W = '[0-9A-Za-z_]'
|
|
23
|
+
GO_B = "(?:(?<=#{GO_W})(?!#{GO_W})|(?<!#{GO_W})(?=#{GO_W}))".freeze
|
|
24
|
+
GO_WS = '[\t\n\f\r ]'
|
|
25
|
+
GO_NOT_WS = '[^\t\n\f\r ]'
|
|
26
|
+
# unicode.IsSpace, which strings.TrimSpace and strings.Fields use.
|
|
27
|
+
GO_SPACE = "[\t\n\v\f\r \u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]"
|
|
28
|
+
TRIM = /\A#{GO_SPACE}+|#{GO_SPACE}+\z/o
|
|
29
|
+
|
|
30
|
+
def self.go_re(source, options = 0)
|
|
31
|
+
Regexp.new(source.gsub('\b', GO_B), options)
|
|
32
|
+
end
|
|
33
|
+
private_class_method :go_re
|
|
34
|
+
|
|
35
|
+
# Ordered as in Go: more specific patterns first, or a UUID becomes a
|
|
36
|
+
# string of <num> fragments. (Go skips rules a cheap scan proves cannot
|
|
37
|
+
# match; that changes no output, so it is not ported.)
|
|
38
|
+
TEMPLATE_RULES = [
|
|
39
|
+
[go_re('\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', Regexp::IGNORECASE), '<uuid>'],
|
|
40
|
+
[go_re('\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?'), '<ts>'],
|
|
41
|
+
[go_re('\b\d{4}-\d{2}-\d{2}\b'), '<date>'],
|
|
42
|
+
[go_re('\b[^\t\n\f\r <>@]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b'), '<email>'],
|
|
43
|
+
[go_re("\\b[a-z][a-z0-9+.-]*://#{GO_NOT_WS}+"), '<url>'],
|
|
44
|
+
[go_re('\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'), '<ip>'],
|
|
45
|
+
[go_re('\b(?:0x)?[0-9a-fA-F]{8,}\b'), '<hex>'],
|
|
46
|
+
[/"[^"]*"/, '<str>'],
|
|
47
|
+
[/'[^']*'/, '<str>'],
|
|
48
|
+
[go_re("\\b\\d+(?:\\.\\d+)?#{GO_WS}?(?:ms|s|us|ns|µs)\\b"), '<dur>'],
|
|
49
|
+
[go_re('\b\d+(?:\.\d+)?\b'), '<num>']
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
WHITESPACE = /#{GO_WS}+/o
|
|
53
|
+
MAX_FRAMES = 5
|
|
54
|
+
KEEP_SEGMENTS = 3
|
|
55
|
+
|
|
56
|
+
DEPENDENCY_FRAGMENTS = [
|
|
57
|
+
'/node_modules/', '/vendor/', '/gems/', '/ruby/gems/', '/usr/local/go/src/',
|
|
58
|
+
'/go/pkg/mod/', '/.bundle/', '<anonymous>'
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
ASSET_DIGEST = /\A(.+)[-.]([A-Za-z0-9_]{8,})((?:\.chunk|\.bundle)?\.(?:js|mjs|cjs|css)(?:\.map)?)\z/
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
# The fingerprint (64 hex chars) of a signal, or nil when the signal
|
|
65
|
+
# has no type, message or frames (Go returns an error there).
|
|
66
|
+
#
|
|
67
|
+
# frames: [{function:, file:, line:}] (string or symbol keys).
|
|
68
|
+
def compute(kind:, source:, service: '', type: '', message: '', frames: [])
|
|
69
|
+
return nil if kind.to_s.empty? || source.to_s.empty?
|
|
70
|
+
return nil if type.to_s.empty? && message.to_s.empty? && (frames.nil? || frames.empty?)
|
|
71
|
+
|
|
72
|
+
Digest::SHA256.hexdigest(canonical(normalize(kind, source, service, type, message, frames)))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The canonical form, field by field, as Go's Normalized.
|
|
76
|
+
def normalize(kind, source, service, type, message, frames)
|
|
77
|
+
{
|
|
78
|
+
kind: kind.to_s, source: source.to_s, service: service.to_s,
|
|
79
|
+
type: trim(type.to_s), template: template(message.to_s),
|
|
80
|
+
frames: normalize_frames(frames || [])
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Reduces a message to its shape, replacing values that vary between
|
|
85
|
+
# occurrences with holes.
|
|
86
|
+
def template(message)
|
|
87
|
+
out = trim(utf8(message))
|
|
88
|
+
return '' if out.empty?
|
|
89
|
+
|
|
90
|
+
TEMPLATE_RULES.each { |re, with| out = out.gsub(re, with) }
|
|
91
|
+
trim(out.gsub(WHITESPACE, ' '))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalize_path(path)
|
|
95
|
+
p = trim(path.to_s)
|
|
96
|
+
return '' if p.empty?
|
|
97
|
+
|
|
98
|
+
if (i = p.index('://'))
|
|
99
|
+
rest = p[(i + 3)..]
|
|
100
|
+
j = rest.index('/')
|
|
101
|
+
p = j ? rest[j..] : rest
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if (i = p.index(/[?#]/))
|
|
105
|
+
p = p[0, i]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
p = p.tr('\\', '/')
|
|
109
|
+
segments = p.gsub(%r{\A/+|/+\z}, '').split('/', -1)
|
|
110
|
+
segments = [''] if segments.empty?
|
|
111
|
+
segments = segments.last(KEEP_SEGMENTS)
|
|
112
|
+
segments[-1] = strip_asset_digest(segments[-1])
|
|
113
|
+
segments.join('/')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def trim(text)
|
|
117
|
+
text.gsub(TRIM, '')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def normalize_frames(frames)
|
|
123
|
+
out = []
|
|
124
|
+
frames.each do |f|
|
|
125
|
+
function = field(f, :function)
|
|
126
|
+
file = field(f, :file)
|
|
127
|
+
next if dependency?(file, function)
|
|
128
|
+
|
|
129
|
+
fn = trim(function)
|
|
130
|
+
path = normalize_path(file)
|
|
131
|
+
next if fn.empty? && path.empty?
|
|
132
|
+
|
|
133
|
+
# Deliberately no line number.
|
|
134
|
+
out << "#{fn}@#{path}"
|
|
135
|
+
break if out.length == MAX_FRAMES
|
|
136
|
+
end
|
|
137
|
+
out
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def field(frame, name)
|
|
141
|
+
return '' unless frame.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
utf8((frame[name] || frame[name.to_s]).to_s)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def dependency?(file, function)
|
|
147
|
+
hay = "#{file} #{function}"
|
|
148
|
+
DEPENDENCY_FRAGMENTS.any? { |frag| hay.include?(frag) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def strip_asset_digest(name)
|
|
152
|
+
m = ASSET_DIGEST.match(name)
|
|
153
|
+
return name if m.nil? || !digest?(m[2])
|
|
154
|
+
|
|
155
|
+
m[1] + m[3]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def digest?(token)
|
|
159
|
+
digit = token.match?(/[0-9]/)
|
|
160
|
+
upper = token.match?(/[A-Z]/)
|
|
161
|
+
hex_only = token.match?(/\A[0-9a-f]*\z/)
|
|
162
|
+
return true if hex_only && digit && token.length >= 8
|
|
163
|
+
|
|
164
|
+
token.length == 8 && digit && upper
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def canonical(norm)
|
|
168
|
+
["v1", norm[:kind], norm[:source], norm[:service], norm[:type], norm[:template],
|
|
169
|
+
norm[:frames].join("\x1f")].join("\x1e")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Go treats invalid UTF-8 as U+FFFD; so does this, rather than raising.
|
|
173
|
+
def utf8(text)
|
|
174
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
175
|
+
|
|
176
|
+
text.encode('UTF-8', invalid: :replace, undef: :replace).scrub
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'current'
|
|
4
|
+
require_relative 'trace'
|
|
5
|
+
|
|
6
|
+
module Devbench
|
|
7
|
+
# Forwarding helpers.
|
|
8
|
+
#
|
|
9
|
+
# This is the capability people forget. Accepting a header is easy and
|
|
10
|
+
# obviously correct; forwarding it on the way out is the part that gets
|
|
11
|
+
# missed, and when it is missing the failure is silent — the call succeeds,
|
|
12
|
+
# and downstream logs simply cannot be joined to the browser that caused
|
|
13
|
+
# them. See ARCHITECTURE_PROPOSAL.md risk #9.
|
|
14
|
+
module HTTP
|
|
15
|
+
# Returns headers to merge into an outbound request, or {}.
|
|
16
|
+
#
|
|
17
|
+
# Net::HTTP.post(uri, body, **Devbench::HTTP.headers)
|
|
18
|
+
# Faraday.get(url, nil, Devbench::HTTP.headers)
|
|
19
|
+
def self.headers
|
|
20
|
+
trace = Current.trace
|
|
21
|
+
return {} if trace.nil?
|
|
22
|
+
|
|
23
|
+
forwarded = trace.next_hop
|
|
24
|
+
return {} if forwarded.nil?
|
|
25
|
+
|
|
26
|
+
{ Trace::HEADER => forwarded.to_s }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Merges the trace header into an existing hash, without mutating it.
|
|
30
|
+
def self.merge(existing)
|
|
31
|
+
(existing || {}).merge(headers)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|