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.
@@ -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. The same
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
- 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)
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
- 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')
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
- return true if counts.empty?
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
- bodies(counts, overflowed).each do |body|
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
- wanted.concat(asks(response))
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 [] if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
432
+ return none if body.empty? || body.bytesize > MAX_RESPONSE_BYTES
374
433
 
375
434
  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) } : []
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 ask.
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