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.
@@ -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