devbench 0.5.0 → 0.6.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 +106 -19
- data/lib/devbench/config.rb +66 -21
- data/lib/devbench/direct.rb +172 -23
- data/lib/devbench/egress_policy.rb +101 -0
- data/lib/devbench/logs.rb +342 -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 +8 -2
|
@@ -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
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
require_relative 'current'
|
|
5
|
+
|
|
6
|
+
module Devbench
|
|
7
|
+
# In-process log capture (docs/SERVER_SDK_SPEC.md, "In-process log capture
|
|
8
|
+
# (direct mode)"; DECISIONS #161): recent log lines kept in memory, keyed
|
|
9
|
+
# by the trace that was current when each was written, so triage can be
|
|
10
|
+
# handed the server lines of one user action without a sidecar.
|
|
11
|
+
#
|
|
12
|
+
# Two ways in, both installed by the Railtie in direct mode only:
|
|
13
|
+
#
|
|
14
|
+
# * Rails 7.1+ (Rails.logger is an ActiveSupport::BroadcastLogger):
|
|
15
|
+
# broadcast_to a CaptureLogger — Rails' own public API for "also send
|
|
16
|
+
# these lines there". It writes nothing anywhere; it only remembers.
|
|
17
|
+
# * Any other ::Logger (Rails < 7.1, Sidekiq.logger, a plain Logger):
|
|
18
|
+
# a Tee prepended to that one logger object. It calls the logger's own
|
|
19
|
+
# #add first, unchanged, and remembers the line afterwards; a block
|
|
20
|
+
# message is evaluated once, by the logger, and its value reused.
|
|
21
|
+
#
|
|
22
|
+
# Never harms the host: what the app's logger writes is untouched; a line
|
|
23
|
+
# with no current trace costs one thread-local read; the buffer lock is
|
|
24
|
+
# held for an Array push, never across I/O or redaction; nothing raises
|
|
25
|
+
# out of a logging call. Fork-safe: a child starts with an empty buffer.
|
|
26
|
+
module Logs
|
|
27
|
+
MAX_LINES = 10_000
|
|
28
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
29
|
+
MAX_AGE = 15 * 60
|
|
30
|
+
MAX_LINE_BYTES = 4096
|
|
31
|
+
|
|
32
|
+
SEVERITY = %w[DEBUG INFO WARN ERROR FATAL].freeze
|
|
33
|
+
# The last line captured on this thread, and by which hook: see #capture.
|
|
34
|
+
LAST = :__devbench_last_log
|
|
35
|
+
|
|
36
|
+
# Bounded, thread-safe, per-trace store of recent lines. Oldest evicted
|
|
37
|
+
# first, by count, bytes and age.
|
|
38
|
+
class Buffer
|
|
39
|
+
Entry = Struct.new(:key, :at, :text)
|
|
40
|
+
|
|
41
|
+
def initialize(max_lines: MAX_LINES, max_bytes: MAX_BYTES, max_age: MAX_AGE,
|
|
42
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
43
|
+
@max_lines = max_lines
|
|
44
|
+
@max_bytes = max_bytes
|
|
45
|
+
@max_age = max_age
|
|
46
|
+
@clock = clock
|
|
47
|
+
@lock = Mutex.new
|
|
48
|
+
reset
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Keeps one line under a trace key.
|
|
52
|
+
def push(key, text)
|
|
53
|
+
now = @clock.call
|
|
54
|
+
entry = Entry.new(key, now, text)
|
|
55
|
+
@lock.synchronize do
|
|
56
|
+
reset if @pid != Process.pid
|
|
57
|
+
@entries << entry
|
|
58
|
+
(@by_key[key] ||= []) << entry
|
|
59
|
+
@bytes += text.bytesize
|
|
60
|
+
evict(now)
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The lines held for a key, oldest first, at most `limit` (the most
|
|
66
|
+
# recent ones when there are more). A copy: the caller redacts it
|
|
67
|
+
# outside the lock.
|
|
68
|
+
def lookup(key, limit)
|
|
69
|
+
@lock.synchronize do
|
|
70
|
+
reset if @pid != Process.pid
|
|
71
|
+
evict(@clock.call)
|
|
72
|
+
list = @by_key[key]
|
|
73
|
+
return [] if list.nil?
|
|
74
|
+
|
|
75
|
+
(list.length > limit ? list.last(limit) : list).map(&:text)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def size
|
|
80
|
+
@lock.synchronize { @entries.length }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Whether any line younger than the age bound is held (by this
|
|
84
|
+
# process).
|
|
85
|
+
def any?
|
|
86
|
+
@lock.synchronize do
|
|
87
|
+
reset if @pid != Process.pid
|
|
88
|
+
evict(@clock.call)
|
|
89
|
+
!@entries.empty?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bytes
|
|
94
|
+
@lock.synchronize { @bytes }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def clear
|
|
98
|
+
@lock.synchronize { reset }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def reset
|
|
104
|
+
@pid = Process.pid
|
|
105
|
+
@entries = []
|
|
106
|
+
@by_key = {}
|
|
107
|
+
@bytes = 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Every entry is the oldest of its own key when it is the oldest
|
|
111
|
+
# overall, so removing it from its key's list is a shift.
|
|
112
|
+
def evict(now)
|
|
113
|
+
cutoff = now - @max_age
|
|
114
|
+
while (oldest = @entries.first) &&
|
|
115
|
+
(@entries.length > @max_lines || @bytes > @max_bytes || oldest.at < cutoff)
|
|
116
|
+
@entries.shift
|
|
117
|
+
list = @by_key[oldest.key]
|
|
118
|
+
list.shift
|
|
119
|
+
@by_key.delete(oldest.key) if list.empty?
|
|
120
|
+
@bytes -= oldest.text.bytesize
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Rails 7.1+: the logger handed to BroadcastLogger#broadcast_to. Its
|
|
126
|
+
# level mirrors the app's other loggers, so adding it never turns on a
|
|
127
|
+
# level the app has off (BroadcastLogger#level is the minimum of its
|
|
128
|
+
# loggers, and #debug? asks whether any is at debug). It has no #tagged:
|
|
129
|
+
# BroadcastLogger would run a tagged block once per logger that has one.
|
|
130
|
+
module CaptureLogger
|
|
131
|
+
attr_accessor :devbench_broadcast
|
|
132
|
+
|
|
133
|
+
def level
|
|
134
|
+
own = super
|
|
135
|
+
others = devbench_broadcast&.broadcasts&.reject { |l| l.equal?(self) }
|
|
136
|
+
return own if others.nil? || others.empty?
|
|
137
|
+
|
|
138
|
+
mirrored = others.map(&:level).min
|
|
139
|
+
local = respond_to?(:local_level) ? local_level : nil
|
|
140
|
+
local.nil? ? mirrored : [local, mirrored].max
|
|
141
|
+
rescue StandardError, SystemStackError
|
|
142
|
+
::Logger::FATAL
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def add(severity, message = nil, progname = nil)
|
|
146
|
+
severity ||= ::Logger::UNKNOWN
|
|
147
|
+
return true if severity < level || Current.trace.nil?
|
|
148
|
+
|
|
149
|
+
if message.nil?
|
|
150
|
+
message = block_given? ? yield : progname
|
|
151
|
+
end
|
|
152
|
+
Logs.capture(severity, message, :broadcast)
|
|
153
|
+
true
|
|
154
|
+
rescue StandardError, SystemStackError
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
alias log add
|
|
158
|
+
|
|
159
|
+
def <<(message)
|
|
160
|
+
Logs.capture(nil, message, :broadcast) unless Current.trace.nil?
|
|
161
|
+
self
|
|
162
|
+
rescue StandardError, SystemStackError
|
|
163
|
+
self
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Every other ::Logger: prepended to the one logger object's singleton
|
|
168
|
+
# class. The logger's own #add runs first, exactly as before; only then
|
|
169
|
+
# is the line remembered.
|
|
170
|
+
module Tee
|
|
171
|
+
def add(severity, message = nil, progname = nil, &block)
|
|
172
|
+
return super if Current.trace.nil? || !Logs.active?
|
|
173
|
+
|
|
174
|
+
yielded = false
|
|
175
|
+
value = nil
|
|
176
|
+
if block
|
|
177
|
+
original = block
|
|
178
|
+
block = proc do
|
|
179
|
+
value = original.call
|
|
180
|
+
yielded = true
|
|
181
|
+
value
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
result = super(severity, message, progname, &block)
|
|
185
|
+
Logs.tee(self, severity, message, progname, !block.nil?, yielded, value)
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
alias log add
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class << self
|
|
192
|
+
def buffer
|
|
193
|
+
@buffer ||= Buffer.new
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# For tests: a buffer with other bounds or a controllable clock.
|
|
197
|
+
attr_writer :buffer
|
|
198
|
+
|
|
199
|
+
# Whether capture is on: direct mode, after install. Hooks stay in
|
|
200
|
+
# place when it is turned off, and do nothing.
|
|
201
|
+
def active?
|
|
202
|
+
@active ? true : false
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A new transport (Devbench.configure) must be started again by the
|
|
206
|
+
# next line held, so both reset the per-process start.
|
|
207
|
+
def activate!
|
|
208
|
+
@polling_pid = nil
|
|
209
|
+
@active = true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Whether any logger was hooked in this process.
|
|
213
|
+
def hooked?
|
|
214
|
+
@hooked ? true : false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def deactivate!
|
|
218
|
+
@polling_pid = nil
|
|
219
|
+
@active = false
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Hooks one logger. Returns :broadcast, :tee, or nil (not a logger we
|
|
223
|
+
# can hook — left alone). Idempotent.
|
|
224
|
+
def install(logger)
|
|
225
|
+
return nil if logger.nil?
|
|
226
|
+
|
|
227
|
+
if logger.respond_to?(:broadcast_to) && logger.respond_to?(:broadcasts)
|
|
228
|
+
return :broadcast if logger.broadcasts.any? { |l| l.is_a?(CaptureLogger) }
|
|
229
|
+
|
|
230
|
+
capture = capture_logger_class.new(nil)
|
|
231
|
+
capture.level = logger.level
|
|
232
|
+
capture.devbench_broadcast = logger
|
|
233
|
+
logger.broadcast_to(capture)
|
|
234
|
+
@hooked = true
|
|
235
|
+
:broadcast
|
|
236
|
+
elsif logger.is_a?(::Logger)
|
|
237
|
+
logger.singleton_class.prepend(Tee) unless logger.singleton_class.include?(Tee)
|
|
238
|
+
@hooked = true
|
|
239
|
+
:tee
|
|
240
|
+
end
|
|
241
|
+
rescue StandardError, SystemStackError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Whether this process holds any traced line: the transport polls
|
|
246
|
+
# ingest for log requests only then.
|
|
247
|
+
def holding?
|
|
248
|
+
buffer.any?
|
|
249
|
+
rescue StandardError, SystemStackError
|
|
250
|
+
false
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The lines held for one trace key (session/intent), oldest first.
|
|
254
|
+
def lookup(key, limit)
|
|
255
|
+
buffer.lookup(key, limit)
|
|
256
|
+
rescue StandardError, SystemStackError
|
|
257
|
+
[]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Called by Tee after the logger's own #add returned.
|
|
261
|
+
def tee(logger, severity, message, progname, had_block, yielded, value)
|
|
262
|
+
severity ||= ::Logger::UNKNOWN
|
|
263
|
+
return if severity < logger.level
|
|
264
|
+
|
|
265
|
+
if message.nil?
|
|
266
|
+
return if had_block && !yielded
|
|
267
|
+
|
|
268
|
+
message = had_block ? value : progname
|
|
269
|
+
end
|
|
270
|
+
capture(severity, message, :tee)
|
|
271
|
+
rescue StandardError, SystemStackError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Keeps one line under the current trace. Never raises.
|
|
276
|
+
#
|
|
277
|
+
# One line can reach both hooks: in a Sidekiq process Rails.logger may
|
|
278
|
+
# broadcast to Sidekiq.logger. Both run on this thread, one right
|
|
279
|
+
# after the other, so a line equal to the one just captured *by the
|
|
280
|
+
# other hook* is that same write and is skipped. The same hook
|
|
281
|
+
# repeating a line is a real repeat and is kept.
|
|
282
|
+
def capture(severity, message, source)
|
|
283
|
+
return nil unless active?
|
|
284
|
+
|
|
285
|
+
trace = Current.trace
|
|
286
|
+
return nil if trace.nil?
|
|
287
|
+
|
|
288
|
+
line = line_for(trace, severity, message)
|
|
289
|
+
last = Thread.current[LAST]
|
|
290
|
+
if last && last[0] != source && last[1] == line
|
|
291
|
+
Thread.current[LAST] = nil
|
|
292
|
+
return nil
|
|
293
|
+
end
|
|
294
|
+
Thread.current[LAST] = [source, line]
|
|
295
|
+
buffer.push(trace.key, line)
|
|
296
|
+
start_polling if @polling_pid != Process.pid
|
|
297
|
+
nil
|
|
298
|
+
rescue StandardError, SystemStackError, ThreadError
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
# The first line this process holds starts the transport's flush
|
|
305
|
+
# thread, so a process with nothing to report still polls for log
|
|
306
|
+
# requests (a forked worker included: the PID is per process).
|
|
307
|
+
def start_polling
|
|
308
|
+
@polling_pid = Process.pid
|
|
309
|
+
transport = Devbench.transport
|
|
310
|
+
transport.start if transport.respond_to?(:start)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# "[v1/s/i/h] INFO message", at most MAX_LINE_BYTES, valid UTF-8.
|
|
314
|
+
def line_for(trace, severity, message)
|
|
315
|
+
label = severity.is_a?(Integer) ? SEVERITY[severity] : nil
|
|
316
|
+
text = message_text(message)
|
|
317
|
+
line = label ? "[#{trace}] #{label} #{text}" : "[#{trace}] #{text}"
|
|
318
|
+
line = line.byteslice(0, MAX_LINE_BYTES) if line.bytesize > MAX_LINE_BYTES
|
|
319
|
+
line = line.dup.force_encoding(Encoding::UTF_8) unless line.encoding == Encoding::UTF_8
|
|
320
|
+
line.valid_encoding? ? line : line.scrub('')
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# As ::Logger::Formatter#msg2str.
|
|
324
|
+
def message_text(message)
|
|
325
|
+
text = case message
|
|
326
|
+
when ::String then message
|
|
327
|
+
when ::Exception
|
|
328
|
+
"#{message.message} (#{message.class})\n#{(message.backtrace || []).join("\n")}"
|
|
329
|
+
else message.inspect
|
|
330
|
+
end
|
|
331
|
+
text.end_with?("\n") ? text.chomp : text
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def capture_logger_class
|
|
335
|
+
@capture_logger_class ||= begin
|
|
336
|
+
base = defined?(::ActiveSupport::Logger) ? ::ActiveSupport::Logger : ::Logger
|
|
337
|
+
Class.new(base) { include CaptureLogger }
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
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 'view_helper'
|
|
6
7
|
|
|
7
8
|
module Devbench
|
|
8
9
|
# Hooks Dev Bench into Rails at boot. Loaded by lib/devbench.rb only when
|
|
@@ -40,6 +41,11 @@ module Devbench
|
|
|
40
41
|
nil
|
|
41
42
|
end
|
|
42
43
|
|
|
44
|
+
# <%= devbench_script_tag %> in any view or layout.
|
|
45
|
+
initializer 'devbench.view_helper' do
|
|
46
|
+
ActiveSupport.on_load(:action_view) { include Devbench::ViewHelper }
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
# rake devbench:test — check the DSN without waiting for a flush.
|
|
44
50
|
rake_tasks do
|
|
45
51
|
namespace :devbench do
|
|
@@ -62,12 +68,27 @@ module Devbench
|
|
|
62
68
|
# Native Sidekiq jobs never reach the ActiveJob hook. after_initialize
|
|
63
69
|
# runs after every gem is required, so Gemfile order does not matter.
|
|
64
70
|
Devbench::SidekiqHooks.install if defined?(::Sidekiq)
|
|
71
|
+
|
|
72
|
+
# Server log lines for triage, kept in-process (direct mode only).
|
|
73
|
+
# After the app's initializers, so the logger they configured is the
|
|
74
|
+
# one hooked.
|
|
75
|
+
Devbench::Railtie.capture_logs
|
|
65
76
|
rescue StandardError, SystemStackError
|
|
66
77
|
nil
|
|
67
78
|
end
|
|
68
79
|
end
|
|
69
80
|
|
|
70
81
|
class << self
|
|
82
|
+
# Rails.logger, plus Sidekiq's logger when Sidekiq is loaded. A no-op
|
|
83
|
+
# unless reporting is direct.
|
|
84
|
+
def capture_logs
|
|
85
|
+
loggers = [::Rails.logger]
|
|
86
|
+
loggers << ::Sidekiq.logger if defined?(::Sidekiq) && ::Sidekiq.respond_to?(:logger)
|
|
87
|
+
Devbench.capture_logs(loggers.compact)
|
|
88
|
+
rescue StandardError, SystemStackError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
# Records the conditional insert on a MiddlewareStackProxy. Returns
|
|
72
93
|
# true when recorded.
|
|
73
94
|
def auto_insert(proxy)
|
data/lib/devbench/scrub.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'fingerprint'
|
|
4
|
+
require_relative 'egress_policy'
|
|
4
5
|
|
|
5
6
|
module Devbench
|
|
6
7
|
# A port of the sidecar's strict egress (internal/scrub and
|
|
@@ -109,25 +110,62 @@ module Devbench
|
|
|
109
110
|
end
|
|
110
111
|
|
|
111
112
|
# The sidecar's forEgress in strict mode, for one line: any trace kept
|
|
112
|
-
# as is, the rest templated, scrubbed and prose-masked
|
|
113
|
-
|
|
113
|
+
# as is, the rest templated, scrubbed and prose-masked — then the
|
|
114
|
+
# learned rules (an EgressPolicy), exactly as Sidecar.redactBody
|
|
115
|
+
# applies them:
|
|
116
|
+
#
|
|
117
|
+
# shape 'none' the content never leaves: <withheld:shape:<fp12>>
|
|
118
|
+
# shape 'full' lifts only the prose heuristic, never base redaction
|
|
119
|
+
# shape 'masked' prose heuristic (already on in strict mode)
|
|
120
|
+
# field / term masked afterwards, whatever the shape rule says
|
|
121
|
+
#
|
|
122
|
+
# Shape rules are keyed by the line's shape fingerprint, as the sidecar
|
|
123
|
+
# computes it (log_template / sidecar / service / the trace-stripped
|
|
124
|
+
# line).
|
|
125
|
+
def egress(line, policy = nil, service = '')
|
|
114
126
|
line = utf8(line)
|
|
115
127
|
match = TRACE.match(line)
|
|
116
128
|
body = match ? line.gsub(TRACE, '<trace>') : line
|
|
117
|
-
|
|
129
|
+
fp, rule = shape_rule(policy, service, body)
|
|
130
|
+
redacted = if rule == EgressPolicy::NONE
|
|
131
|
+
withheld(fp)
|
|
132
|
+
else
|
|
133
|
+
redact_body(body, rule, policy)
|
|
134
|
+
end
|
|
118
135
|
match ? "[#{match[0]}] #{redacted}" : redacted
|
|
119
136
|
end
|
|
120
137
|
|
|
121
138
|
# A bundle's template text: as the sidecar's answerEvidenceRequests
|
|
122
|
-
# does,
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
# does, the shape's own rule (by the signal's fingerprint), strict
|
|
140
|
+
# redaction, and then a second shape pass. Empty when the shape's
|
|
141
|
+
# content never leaves.
|
|
142
|
+
def template_text(text, policy = nil, fp = nil)
|
|
143
|
+
rule = policy && fp ? policy.rule_for(fp) : nil
|
|
144
|
+
return '' if rule == EgressPolicy::NONE
|
|
145
|
+
|
|
146
|
+
self.text(redact_body(utf8(text), rule, policy))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# What stands in for a line whose shape is marked 'none'.
|
|
150
|
+
def withheld(fp)
|
|
151
|
+
"<withheld:shape:#{fp.to_s[0, 12]}>"
|
|
125
152
|
end
|
|
126
153
|
|
|
127
154
|
private
|
|
128
155
|
|
|
129
|
-
|
|
130
|
-
|
|
156
|
+
# Base redaction (template + scrub), the prose heuristic unless the
|
|
157
|
+
# shape is marked 'full', then field and term rules.
|
|
158
|
+
def redact_body(body, rule, policy)
|
|
159
|
+
redacted = text(Fingerprint.template(body))
|
|
160
|
+
redacted = prose(redacted) unless rule == EgressPolicy::FULL
|
|
161
|
+
policy ? policy.mask(redacted) : redacted
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def shape_rule(policy, service, body)
|
|
165
|
+
return [nil, nil] if policy.nil? || !policy.shapes?
|
|
166
|
+
|
|
167
|
+
fp = Fingerprint.compute(kind: 'log_template', source: 'sidecar', service: service.to_s, message: body)
|
|
168
|
+
fp ? [fp, policy.rule_for(fp)] : [nil, nil]
|
|
131
169
|
end
|
|
132
170
|
|
|
133
171
|
def proper_noun?(token)
|
|
@@ -101,6 +101,10 @@ module Devbench
|
|
|
101
101
|
|
|
102
102
|
handlers = config.error_handlers
|
|
103
103
|
handlers << ERROR_HANDLER unless handlers.any? { |h| h.equal?(ERROR_HANDLER) }
|
|
104
|
+
|
|
105
|
+
# The job's own log lines (Sidekiq::Job#logger), kept under the
|
|
106
|
+
# job's trace — direct mode only, a no-op otherwise.
|
|
107
|
+
Devbench.capture_logs(config.logger) if config.respond_to?(:logger)
|
|
104
108
|
true
|
|
105
109
|
rescue StandardError, SystemStackError
|
|
106
110
|
false
|