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.
@@ -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
@@ -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)
@@ -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
- def egress(line)
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
- redacted = strict(body)
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, strict redaction and then a second shape pass.
123
- def template_text(text)
124
- self.text(strict(utf8(text)))
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
- def strict(body)
130
- prose(text(Fingerprint.template(body)))
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