devbench 0.5.0 → 0.7.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 +4 -4
- data/README.md +107 -20
- data/lib/devbench/config.rb +109 -27
- data/lib/devbench/direct.rb +172 -23
- data/lib/devbench/egress_policy.rb +101 -0
- data/lib/devbench/logs.rb +413 -0
- data/lib/devbench/railtie.rb +21 -0
- data/lib/devbench/scrub.rb +46 -8
- data/lib/devbench/sidekiq_hooks.rb +4 -0
- data/lib/devbench/transport.rb +65 -10
- data/lib/devbench/version.rb +1 -1
- data/lib/devbench/view_helper.rb +62 -0
- data/lib/devbench.rb +7 -2
- data/lib/generators/devbench/devbench_generator.rb +210 -0
- metadata +9 -3
data/lib/devbench/direct.rb
CHANGED
|
@@ -6,11 +6,15 @@ require 'securerandom'
|
|
|
6
6
|
require 'uri'
|
|
7
7
|
require_relative 'fingerprint'
|
|
8
8
|
require_relative 'scrub'
|
|
9
|
+
require_relative 'egress_policy'
|
|
10
|
+
require_relative 'logs'
|
|
9
11
|
|
|
10
12
|
module Devbench
|
|
11
13
|
# Direct mode (docs/SERVER_SDK_SPEC.md, "Direct mode"): fingerprint and
|
|
12
14
|
# count in-process, flush counts to ingest once a minute, upload one
|
|
13
|
-
# redacted evidence bundle per fingerprint only when ingest asks
|
|
15
|
+
# redacted evidence bundle per fingerprint only when ingest asks, and
|
|
16
|
+
# answer ingest's requests for the log lines of a trace (need_logs) from
|
|
17
|
+
# the in-process buffer (Devbench::Logs), redacted. The same
|
|
14
18
|
# phase-1/phase-2 protocol as the browser sensor, and the same signals and
|
|
15
19
|
# bundle the sidecar builds from the equivalent control message, so a
|
|
16
20
|
# customer can move between modes without forking an issue.
|
|
@@ -37,6 +41,13 @@ module Devbench
|
|
|
37
41
|
MAX_FRAMES = 50
|
|
38
42
|
MAX_IDENTITY = 255
|
|
39
43
|
MAX_RESPONSE_BYTES = 64 * 1024
|
|
44
|
+
# Log slices (spec: <= 500 lines per slice). 200 and 64 KiB are what the
|
|
45
|
+
# store keeps of a slice; sending more would spend the customer's
|
|
46
|
+
# bandwidth on lines truncated on arrival. 25 slices is what one
|
|
47
|
+
# delivery may carry.
|
|
48
|
+
MAX_SLICE_LINES = 200
|
|
49
|
+
MAX_SLICE_BYTES = 64 * 1024
|
|
50
|
+
MAX_SLICES_PER_POST = 25
|
|
40
51
|
HTTP_TIMEOUT = 5.0
|
|
41
52
|
EXIT_TIMEOUT = 2.0
|
|
42
53
|
|
|
@@ -52,6 +63,11 @@ module Devbench
|
|
|
52
63
|
@release = release.to_s
|
|
53
64
|
@interval = interval.to_f.positive? ? interval.to_f : 60.0
|
|
54
65
|
@flush_uri = URI(dsn.flush_url)
|
|
66
|
+
@logs_uri = URI(dsn.logs_url)
|
|
67
|
+
@check_uri = URI(dsn.check_url)
|
|
68
|
+
# Learned redaction rules from the last response that carried a set.
|
|
69
|
+
# Not per process: a forked child keeps them until ingest re-sends.
|
|
70
|
+
@policy = nil
|
|
55
71
|
@fork_lock = Mutex.new
|
|
56
72
|
@stopped = false
|
|
57
73
|
reset_state
|
|
@@ -100,6 +116,10 @@ module Devbench
|
|
|
100
116
|
nil
|
|
101
117
|
end
|
|
102
118
|
|
|
119
|
+
# The learned rules in force (an EgressPolicy), or nil before any
|
|
120
|
+
# arrived. For tests and diagnostics.
|
|
121
|
+
attr_reader :policy
|
|
122
|
+
|
|
103
123
|
# Counts not yet flushed, by fingerprint. For tests and diagnostics.
|
|
104
124
|
def pending
|
|
105
125
|
@lock.synchronize { @window.transform_values { |c| c.n } }
|
|
@@ -108,14 +128,13 @@ module Devbench
|
|
|
108
128
|
# rake devbench:test. Sends one synthetic exception straight to ingest
|
|
109
129
|
# (not via the background thread), uploads its evidence if asked, and
|
|
110
130
|
# prints what happened. Returns true when ingest accepted it.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
response, error = attempt(:post, @flush_uri, body, flush_headers, HTTP_TIMEOUT)
|
|
131
|
+
# The self-test (Devbench.test!): asks ingest whether this DSN's key is
|
|
132
|
+
# valid, and for which tenant and environment. Creates nothing — it used
|
|
133
|
+
# to send a synthetic exception, which became a real issue on a new
|
|
134
|
+
# customer's punch list.
|
|
135
|
+
def self_test(io)
|
|
136
|
+
io.puts "Dev Bench: checking the DSN against #{@dsn} (service #{@service.inspect})"
|
|
137
|
+
response, error = attempt(:post, @check_uri, '{}', flush_headers, HTTP_TIMEOUT)
|
|
119
138
|
if error
|
|
120
139
|
io.puts " could not reach #{@dsn}: #{error.class}: #{error.message}"
|
|
121
140
|
return false
|
|
@@ -127,14 +146,26 @@ module Devbench
|
|
|
127
146
|
return false
|
|
128
147
|
end
|
|
129
148
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
info = begin
|
|
150
|
+
JSON.parse(response.body.to_s)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
{}
|
|
134
153
|
end
|
|
154
|
+
io.puts " HTTP #{code}: accepted — tenant #{info['tenant'].inspect}, environment " \
|
|
155
|
+
"#{info['environment'].inspect}. Nothing was recorded; real errors will appear as issues."
|
|
135
156
|
true
|
|
136
157
|
end
|
|
137
158
|
|
|
159
|
+
# Starts the background flush thread if it is not running (in this
|
|
160
|
+
# process). Called when this process first holds a log line, so a
|
|
161
|
+
# worker that reports nothing still polls for log requests.
|
|
162
|
+
def start
|
|
163
|
+
ensure_running
|
|
164
|
+
nil
|
|
165
|
+
rescue StandardError, SystemStackError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
138
169
|
private
|
|
139
170
|
|
|
140
171
|
# ---- Signals: exactly what the sidecar builds (internal/sidecar/control.go)
|
|
@@ -303,6 +334,9 @@ module Devbench
|
|
|
303
334
|
|
|
304
335
|
# ---- Flushing
|
|
305
336
|
|
|
337
|
+
# Phase 1, then whatever the responses asked for: learned rules first
|
|
338
|
+
# (a rule arriving with a request for the shape it governs applies to
|
|
339
|
+
# that request), then log slices, then evidence.
|
|
306
340
|
def flush_cycle(deadline)
|
|
307
341
|
counts, overflowed = @lock.synchronize do
|
|
308
342
|
taken = [@window, @overflowed]
|
|
@@ -310,11 +344,22 @@ module Devbench
|
|
|
310
344
|
@overflowed = 0
|
|
311
345
|
taken
|
|
312
346
|
end
|
|
313
|
-
|
|
347
|
+
# Nothing counted: poll only while this process holds traced lines,
|
|
348
|
+
# since need_logs only rides a flush response (the sidecar's poll).
|
|
349
|
+
# Holding none, send nothing.
|
|
350
|
+
if counts.empty?
|
|
351
|
+
return true unless Logs.holding?
|
|
352
|
+
|
|
353
|
+
posts = [envelope([], 0)]
|
|
354
|
+
else
|
|
355
|
+
posts = bodies(counts, overflowed)
|
|
356
|
+
end
|
|
314
357
|
|
|
315
358
|
ok = true
|
|
316
359
|
wanted = []
|
|
317
|
-
|
|
360
|
+
log_keys = []
|
|
361
|
+
rules = nil
|
|
362
|
+
posts.each do |body|
|
|
318
363
|
response = request(:post, @flush_uri, body, flush_headers, deadline)
|
|
319
364
|
if response.nil?
|
|
320
365
|
ok = false
|
|
@@ -322,12 +367,17 @@ module Devbench
|
|
|
322
367
|
end
|
|
323
368
|
code = response.code.to_i
|
|
324
369
|
if (200..299).cover?(code)
|
|
325
|
-
|
|
370
|
+
answer = answers(response)
|
|
371
|
+
wanted.concat(answer[:evidence])
|
|
372
|
+
log_keys.concat(answer[:logs])
|
|
373
|
+
rules = answer[:redaction] unless answer[:redaction].nil?
|
|
326
374
|
else
|
|
327
375
|
ok = false
|
|
328
376
|
Devbench.warn_once(:"http_#{code}", "Dev Bench refused a flush: HTTP #{code}: #{explain(code, response)}")
|
|
329
377
|
end
|
|
330
378
|
end
|
|
379
|
+
@policy = EgressPolicy.compile(rules) unless rules.nil?
|
|
380
|
+
deliver_logs(log_keys.uniq, deadline) unless log_keys.empty?
|
|
331
381
|
wanted.each { |ask| upload(ask, deadline) }
|
|
332
382
|
ok
|
|
333
383
|
end
|
|
@@ -369,15 +419,114 @@ module Devbench
|
|
|
369
419
|
end
|
|
370
420
|
|
|
371
421
|
def asks(response)
|
|
422
|
+
answers(response)[:evidence]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# What a flush response asked for: evidence (need_evidence), log slices
|
|
426
|
+
# (need_logs trace keys) and the learned rule set (redaction: an array,
|
|
427
|
+
# or nil when the response carried none — which leaves the rules in
|
|
428
|
+
# force, unlike an empty array, which withdraws them).
|
|
429
|
+
def answers(response)
|
|
430
|
+
none = { evidence: [], logs: [], redaction: nil }
|
|
372
431
|
body = response.body.to_s
|
|
373
|
-
return
|
|
432
|
+
return none if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
|
|
374
433
|
|
|
375
434
|
parsed = JSON.parse(body)
|
|
376
|
-
|
|
377
|
-
|
|
435
|
+
return none unless parsed.is_a?(Hash)
|
|
436
|
+
|
|
437
|
+
evidence = parsed['need_evidence']
|
|
438
|
+
logs = parsed['need_logs']
|
|
439
|
+
redaction = parsed['redaction']
|
|
440
|
+
{
|
|
441
|
+
evidence: evidence.is_a?(Array) ? evidence.select { |a| a.is_a?(Hash) } : [],
|
|
442
|
+
logs: logs.is_a?(Array) ? logs.filter_map { |l| log_key(l) } : [],
|
|
443
|
+
redaction: redaction.is_a?(Array) ? redaction : nil
|
|
444
|
+
}
|
|
378
445
|
rescue JSON::ParserError
|
|
379
|
-
# Counts were accepted; an unreadable answer only loses the
|
|
380
|
-
|
|
446
|
+
# Counts were accepted; an unreadable answer only loses the asks.
|
|
447
|
+
none
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def log_key(ask)
|
|
451
|
+
key = ask.is_a?(Hash) ? ask['trace'] : nil
|
|
452
|
+
key.is_a?(String) && !key.empty? && key.bytesize <= 256 ? key : nil
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Phase 3: the lines this process holds for each requested trace,
|
|
456
|
+
# redacted with the exemplars' strict egress plus the learned rules,
|
|
457
|
+
# POSTed with the secret key. A key with no lines here sends nothing:
|
|
458
|
+
# another process (a Puma worker, a Sidekiq process) may hold them, and
|
|
459
|
+
# ingest does not let a server key's empty answer settle a request.
|
|
460
|
+
def deliver_logs(keys, deadline)
|
|
461
|
+
policy = @policy
|
|
462
|
+
slices = keys.filter_map do |key|
|
|
463
|
+
lines = Logs.lookup(key, MAX_SLICE_LINES)
|
|
464
|
+
next if lines.empty?
|
|
465
|
+
|
|
466
|
+
{ trace: key, lines: bound_slice(lines.map { |line| egress_line(line, policy) }) }
|
|
467
|
+
end
|
|
468
|
+
slice_bodies(slices).each do |body|
|
|
469
|
+
response = request(:post, @logs_uri, body, flush_headers, deadline)
|
|
470
|
+
next if response.nil? || (200..299).cover?(response.code.to_i)
|
|
471
|
+
|
|
472
|
+
code = response.code.to_i
|
|
473
|
+
Devbench.warn_once(:"logs_http_#{code}", "Dev Bench refused log lines: HTTP #{code}: #{explain(code, response)}")
|
|
474
|
+
end
|
|
475
|
+
nil
|
|
476
|
+
rescue StandardError, SystemStackError
|
|
477
|
+
nil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# A held line is "[<trace>] <SEVERITY> <message>" (Logs). The bracketed
|
|
481
|
+
# trace is our own framing, kept as is; the rest is redacted exactly as
|
|
482
|
+
# the sidecar redacts a line's body.
|
|
483
|
+
OWN_TRACE = %r{\A\[(v1/[A-Za-z0-9_-]{1,64}/[A-Za-z0-9_-]{1,64}/\d{1,3})\] }
|
|
484
|
+
|
|
485
|
+
def egress_line(line, policy)
|
|
486
|
+
own = OWN_TRACE.match(line)
|
|
487
|
+
out = if own.nil?
|
|
488
|
+
Scrub.egress(line, policy, @service)
|
|
489
|
+
else
|
|
490
|
+
rest = line[own.end(0)..]
|
|
491
|
+
redacted = Scrub.egress(rest, policy, @service)
|
|
492
|
+
# A message that already carries the trace (Rails embeds its
|
|
493
|
+
# log tags in a multi-line error) comes back prefixed with it.
|
|
494
|
+
redacted.start_with?("[#{own[1]}] ") ? redacted : "[#{own[1]}] #{redacted}"
|
|
495
|
+
end
|
|
496
|
+
out.bytesize > Logs::MAX_LINE_BYTES ? out.byteslice(0, Logs::MAX_LINE_BYTES).scrub('') : out
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The most recent lines that fit the store's per-slice byte bound.
|
|
500
|
+
def bound_slice(lines)
|
|
501
|
+
total = 0
|
|
502
|
+
kept = []
|
|
503
|
+
lines.reverse_each do |line|
|
|
504
|
+
total += line.bytesize
|
|
505
|
+
break if total > MAX_SLICE_BYTES
|
|
506
|
+
|
|
507
|
+
kept << line
|
|
508
|
+
end
|
|
509
|
+
kept.reverse
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# At most MAX_SLICES_PER_POST slices and MAX_BODY_BYTES per request.
|
|
513
|
+
def slice_bodies(slices)
|
|
514
|
+
out = []
|
|
515
|
+
current = []
|
|
516
|
+
size = 0
|
|
517
|
+
budget = MAX_BODY_BYTES - JSON.generate({ slices: [] }).bytesize
|
|
518
|
+
slices.each do |slice|
|
|
519
|
+
bytes = JSON.generate(slice).bytesize + 1
|
|
520
|
+
if !current.empty? && (current.length >= MAX_SLICES_PER_POST || size + bytes > budget)
|
|
521
|
+
out << JSON.generate({ slices: current })
|
|
522
|
+
current = []
|
|
523
|
+
size = 0
|
|
524
|
+
end
|
|
525
|
+
current << slice
|
|
526
|
+
size += bytes
|
|
527
|
+
end
|
|
528
|
+
out << JSON.generate({ slices: current }) unless current.empty?
|
|
529
|
+
out
|
|
381
530
|
end
|
|
382
531
|
|
|
383
532
|
# Phase 2: the sidecar's bundle shape, PUT to the presigned URL with no
|
|
@@ -395,8 +544,8 @@ module Devbench
|
|
|
395
544
|
|
|
396
545
|
bundle = {
|
|
397
546
|
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)]
|
|
547
|
+
template: Scrub.template_text(detail.text, @policy, fp), service: @service,
|
|
548
|
+
exemplars: detail.exemplar.empty? ? [] : [Scrub.egress(detail.exemplar, @policy, @service)]
|
|
400
549
|
}
|
|
401
550
|
bundle[:frames] = detail.frames unless detail.frames.empty?
|
|
402
551
|
response = request(:put, URI(url), JSON.generate(bundle), { 'content-type' => 'application/json' }, deadline)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'fingerprint'
|
|
4
|
+
|
|
5
|
+
module Devbench
|
|
6
|
+
# Learned redaction rules, as ingest sends them on a flush response
|
|
7
|
+
# (`redaction`), compiled and applied exactly as the sidecar does
|
|
8
|
+
# (internal/sidecar/rules.go and Sidecar.redactBody):
|
|
9
|
+
#
|
|
10
|
+
# {"fp": <shape fp>, "egress": "masked"|"none"|"full"} shape rule
|
|
11
|
+
# {"kind": "field", "target": "license"} mask that field's value
|
|
12
|
+
# {"kind": "term", "target": "Whitfield"} mask that literal
|
|
13
|
+
#
|
|
14
|
+
# Immutable once built; the transport swaps a whole new one in when a
|
|
15
|
+
# response carries a rule set, and keeps the current one when a response
|
|
16
|
+
# carries none (absent is not empty: ingest omits the field when it could
|
|
17
|
+
# not look the rules up).
|
|
18
|
+
class EgressPolicy
|
|
19
|
+
FIELD = 'field'
|
|
20
|
+
TERM = 'term'
|
|
21
|
+
MASKED = 'masked'
|
|
22
|
+
NONE = 'none'
|
|
23
|
+
FULL = 'full'
|
|
24
|
+
|
|
25
|
+
# store.NormalizeRule's field-name shape; anything else is ignored, so a
|
|
26
|
+
# malformed rule never becomes an odd pattern here.
|
|
27
|
+
FIELD_NAME = /\A[a-z0-9_.\-]{1,64}\z/
|
|
28
|
+
# A shorter term would mask fragments of ordinary words everywhere.
|
|
29
|
+
MIN_TERM_BYTES = 3
|
|
30
|
+
|
|
31
|
+
WS = Fingerprint::GO_WS
|
|
32
|
+
|
|
33
|
+
attr_reader :shapes, :terms
|
|
34
|
+
|
|
35
|
+
# rules: the parsed JSON array. Entries that are not objects, or not
|
|
36
|
+
# understood, are skipped (a newer ingest may send kinds this gem cannot
|
|
37
|
+
# enforce; the local passes still run).
|
|
38
|
+
def self.compile(rules)
|
|
39
|
+
shapes = {}
|
|
40
|
+
fields = []
|
|
41
|
+
terms = []
|
|
42
|
+
Array(rules).each do |rule|
|
|
43
|
+
next unless rule.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
kind = rule['kind'].to_s
|
|
46
|
+
target = rule['target'].is_a?(String) ? rule['target'] : ''
|
|
47
|
+
case kind
|
|
48
|
+
when ''
|
|
49
|
+
fp = rule['fp']
|
|
50
|
+
shapes[fp] = rule['egress'].to_s if fp.is_a?(String) && !fp.empty?
|
|
51
|
+
when FIELD
|
|
52
|
+
name = target.strip.downcase
|
|
53
|
+
fields << name if FIELD_NAME.match?(name) && !fields.include?(name)
|
|
54
|
+
when TERM
|
|
55
|
+
terms << target if target.bytesize >= MIN_TERM_BYTES && !terms.include?(target)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
new(shapes, fields, terms)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(shapes, fields, terms)
|
|
62
|
+
@shapes = shapes.freeze
|
|
63
|
+
@terms = terms.freeze
|
|
64
|
+
@fields = fields.empty? ? nil : field_pattern(fields)
|
|
65
|
+
freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The shape rule for a fingerprint, or nil.
|
|
69
|
+
def rule_for(fp)
|
|
70
|
+
@shapes[fp]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shapes?
|
|
74
|
+
!@shapes.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Field and term rules. Additive only: it removes text, never restores
|
|
78
|
+
# any.
|
|
79
|
+
def mask(text)
|
|
80
|
+
text = text.gsub(@fields, '\1\2\3\4<redacted:field>') if @fields
|
|
81
|
+
@terms.each { |term| text = text.gsub(term) { '<redacted:term>' } }
|
|
82
|
+
text
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# The field name, exactly — "license" must not catch "licensed" or
|
|
88
|
+
# "driver_license" — optionally quoted or a Ruby symbol, then a
|
|
89
|
+
# separator, then one value: a quoted string or a bare token. Go's
|
|
90
|
+
# pattern, with Go's \s and with \A for Go's unanchored-mode ^.
|
|
91
|
+
def field_pattern(fields)
|
|
92
|
+
names = fields.map { |f| Regexp.escape(f) }.join('|')
|
|
93
|
+
Regexp.new(
|
|
94
|
+
"(\\A|[^a-z0-9_.\\-])([\"':]?)(#{names})" \
|
|
95
|
+
"([\"']?#{WS}*(?:=>|=|:)#{WS}*)" \
|
|
96
|
+
"(\"(?:[^\"\\\\]|\\\\.)*\"|'(?:[^'\\\\]|\\\\.)*'|[^\\t\\n\\f\\r ,;&)}\\]]+)",
|
|
97
|
+
Regexp::IGNORECASE
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|