fiber_audit 0.1.0 → 0.2.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +19 -0
  3. data/ARCHITECTURE.md +726 -0
  4. data/CHANGELOG.md +30 -0
  5. data/LICENSE +201 -0
  6. data/README.md +66 -8
  7. data/lib/fiber_audit/cli.rb +87 -2
  8. data/lib/fiber_audit/configuration.rb +106 -6
  9. data/lib/fiber_audit/errors.rb +2 -0
  10. data/lib/fiber_audit/operation_vocabulary.rb +42 -0
  11. data/lib/fiber_audit/reporters/text.rb +1 -1
  12. data/lib/fiber_audit/runtime/active_operations.rb +146 -0
  13. data/lib/fiber_audit/runtime/boot.rb +83 -0
  14. data/lib/fiber_audit/runtime/clock.rb +35 -0
  15. data/lib/fiber_audit/runtime/environment.rb +289 -0
  16. data/lib/fiber_audit/runtime/event.rb +86 -0
  17. data/lib/fiber_audit/runtime/execution_context.rb +89 -0
  18. data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
  19. data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
  20. data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
  21. data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
  22. data/lib/fiber_audit/runtime/limits.rb +102 -0
  23. data/lib/fiber_audit/runtime/location.rb +44 -0
  24. data/lib/fiber_audit/runtime/policy.rb +121 -0
  25. data/lib/fiber_audit/runtime/probes/base.rb +333 -0
  26. data/lib/fiber_audit/runtime/probes/http.rb +80 -0
  27. data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
  28. data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
  29. data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
  30. data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
  31. data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
  32. data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
  33. data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
  34. data/lib/fiber_audit/runtime/rails_integration.rb +279 -0
  35. data/lib/fiber_audit/runtime/recorder.rb +333 -0
  36. data/lib/fiber_audit/runtime/redactor.rb +102 -0
  37. data/lib/fiber_audit/runtime/sampler.rb +26 -0
  38. data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
  39. data/lib/fiber_audit/runtime/session.rb +112 -0
  40. data/lib/fiber_audit/runtime/supervisor.rb +114 -0
  41. data/lib/fiber_audit/runtime/validation.rb +68 -0
  42. data/lib/fiber_audit/runtime/watchdog.rb +479 -0
  43. data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
  44. data/lib/fiber_audit/runtime.rb +37 -0
  45. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
  46. data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
  47. data/lib/fiber_audit/static/rules/io_select.rb +2 -4
  48. data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
  49. data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
  50. data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
  51. data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
  52. data/lib/fiber_audit/version.rb +1 -1
  53. data/lib/fiber_audit.rb +1 -0
  54. metadata +38 -2
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validation'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ Policy = Data.define(
8
+ :redaction,
9
+ :sampling_rate,
10
+ :max_events_per_second,
11
+ :max_events_per_session,
12
+ :max_record_bytes,
13
+ :max_session_bytes,
14
+ :fail_open
15
+ ) do
16
+ def initialize(**values)
17
+ unknown = values.keys - Policy::DEFAULTS.keys
18
+ raise RuntimeContractError, "unknown runtime policy field: #{unknown.first}" unless unknown.empty?
19
+
20
+ fields = Policy::DEFAULTS.merge(values)
21
+ redaction = normalize_redaction(fields[:redaction])
22
+ sampling_rate = normalize_sampling_rate(fields[:sampling_rate])
23
+ limits = Policy::LIMITS.to_h do |name, range|
24
+ [name, normalize_limit(fields[name], name, range)]
25
+ end
26
+ unless limits[:max_session_bytes] >= limits[:max_record_bytes]
27
+ raise RuntimeContractError, 'max_session_bytes must be >= max_record_bytes'
28
+ end
29
+ raise RuntimeContractError, 'fail_open must be a Boolean' unless [true, false].include?(fields[:fail_open])
30
+
31
+ super(
32
+ redaction: redaction,
33
+ sampling_rate: sampling_rate,
34
+ **limits,
35
+ fail_open: fields[:fail_open]
36
+ )
37
+ end
38
+
39
+ def sample?(draw:)
40
+ value = Validation.finite_number(draw, 'draw').to_f
41
+ raise RuntimeContractError, 'draw must be in 0.0...1.0' unless value >= 0.0 && value < 1.0
42
+
43
+ value < sampling_rate
44
+ end
45
+
46
+ def rate_allowed?(emitted_in_window:)
47
+ count_below_limit?(emitted_in_window, max_events_per_second, 'emitted_in_window')
48
+ end
49
+
50
+ def session_event_allowed?(emitted_events:)
51
+ count_below_limit?(emitted_events, max_events_per_session, 'emitted_events')
52
+ end
53
+
54
+ def record_size_allowed?(bytes:)
55
+ size_allowed?(bytes, max_record_bytes, 'bytes')
56
+ end
57
+
58
+ def session_bytes_allowed?(written_bytes:, next_record_bytes:)
59
+ written = Validation.integer(written_bytes, 'written_bytes')
60
+ following = Validation.integer(next_record_bytes, 'next_record_bytes')
61
+ written + following <= max_session_bytes
62
+ end
63
+
64
+ def fail_open?
65
+ fail_open
66
+ end
67
+
68
+ def strict_redaction?
69
+ redaction == :strict
70
+ end
71
+
72
+ private
73
+
74
+ def normalize_redaction(value)
75
+ normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
76
+ return :strict if normalized == :strict
77
+
78
+ raise RuntimeContractError, 'redaction must be strict'
79
+ end
80
+
81
+ def normalize_sampling_rate(value)
82
+ rate = Validation.finite_number(value, 'sampling_rate').to_f
83
+ return rate if rate.between?(0.0, 1.0)
84
+
85
+ raise RuntimeContractError, 'sampling_rate must be between 0.0 and 1.0'
86
+ end
87
+
88
+ def normalize_limit(value, field, range)
89
+ unless value.is_a?(Integer) && range.cover?(value)
90
+ raise RuntimeContractError, "#{field} must be an Integer in #{range}"
91
+ end
92
+
93
+ value
94
+ end
95
+
96
+ def count_below_limit?(value, limit, field)
97
+ Validation.integer(value, field) < limit
98
+ end
99
+
100
+ def size_allowed?(value, limit, field)
101
+ Validation.integer(value, field) <= limit
102
+ end
103
+ end
104
+
105
+ Policy.const_set(:DEFAULTS, {
106
+ redaction: :strict,
107
+ sampling_rate: 0.1,
108
+ max_events_per_second: 100,
109
+ max_events_per_session: 10_000,
110
+ max_record_bytes: 16_384,
111
+ max_session_bytes: 10_485_760,
112
+ fail_open: true
113
+ }.freeze)
114
+ Policy.const_set(:LIMITS, {
115
+ max_events_per_second: 1..10_000,
116
+ max_events_per_session: 1..1_000_000,
117
+ max_record_bytes: 1_024..1_048_576,
118
+ max_session_bytes: 4_096..1_073_741_824
119
+ }.freeze)
120
+ end
121
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../active_operations'
4
+ require_relative '../clock'
5
+ require_relative '../event'
6
+ require_relative '../execution_context'
7
+ require_relative '../rails_integration'
8
+ require_relative '../recorder'
9
+ require_relative '../redactor'
10
+
11
+ module FiberAudit
12
+ module Runtime
13
+ module Probes
14
+ # Shared behavior-preserving observation boundary for targeted wrappers.
15
+ # rubocop:disable Metrics/ClassLength
16
+ class Base
17
+ SOURCE = :targeted_probe
18
+ MAX_CALLSITE_FRAMES = 32
19
+ INTERNAL_PATH = File.expand_path('../..', __dir__).freeze
20
+ ORIGINAL_MUTEX_SYNCHRONIZE = Mutex.instance_method(:synchronize)
21
+
22
+ Observation = Data.define(
23
+ :operation,
24
+ :started_monotonic_ns,
25
+ :location,
26
+ :handle,
27
+ :thread_id,
28
+ :fiber_id,
29
+ :measurements,
30
+ :execution_context
31
+ )
32
+
33
+ attr_reader :recorder, :clock, :redactor, :active_operations, :owner_pid, :execution_context_store
34
+
35
+ def initialize(recorder:, clock:, redactor:, active_operations:, execution_context_store: nil,
36
+ pid_source: Process.method(:pid))
37
+ validate_dependencies!(recorder, clock, redactor, active_operations, execution_context_store, pid_source)
38
+ @recorder = recorder
39
+ @clock = clock
40
+ @redactor = redactor
41
+ @active_operations = active_operations
42
+ @execution_context_store = execution_context_store
43
+ @pid_source = pid_source
44
+ @owner_pid = current_pid
45
+ @active = true
46
+ end
47
+
48
+ def observe(operation:, measurements: {}, emit_start: false, measurement_builder: nil)
49
+ return yield unless active_for_current_process?
50
+ return yield if guarded?
51
+
52
+ observation = begin
53
+ prepare_observation(operation, measurements)
54
+ rescue StandardError => e
55
+ instrumentation_failure(e)
56
+ end
57
+ return yield unless observation
58
+
59
+ with_guard { emit_start_observation(observation) } if emit_start
60
+ completed = false
61
+ result = nil
62
+ begin
63
+ result = yield
64
+ completed = true
65
+ result
66
+ ensure
67
+ finalize_observation(observation, completed: completed, result: result, measurement_builder: measurement_builder)
68
+ end
69
+ end
70
+
71
+ def with_guard
72
+ self.class.enter_guard
73
+ yield
74
+ ensure
75
+ self.class.exit_guard
76
+ end
77
+
78
+ def deactivate
79
+ @active = false
80
+ self
81
+ end
82
+
83
+ def active_for_current_process?
84
+ @active && owner_pid == current_pid
85
+ rescue StandardError
86
+ false
87
+ end
88
+
89
+ def fail_open?
90
+ recorder.session.policy.fail_open?
91
+ end
92
+
93
+ def instrumentation_failure(error)
94
+ account_internal_error
95
+ raise error unless fail_open?
96
+
97
+ nil
98
+ end
99
+
100
+ private
101
+
102
+ def validate_dependencies!(candidate_recorder, candidate_clock, candidate_redactor, operations, context_store, pids)
103
+ raise RuntimeContractError, 'recorder must be a Runtime::Recorder' unless candidate_recorder.is_a?(Recorder)
104
+ raise RuntimeContractError, 'clock must be a Runtime::Clock' unless candidate_clock.is_a?(Clock)
105
+ raise RuntimeContractError, 'redactor must be a Runtime::Redactor' unless candidate_redactor.is_a?(Redactor)
106
+ unless operations.is_a?(ActiveOperations)
107
+ raise RuntimeContractError, 'active_operations must be Runtime::ActiveOperations'
108
+ end
109
+ if context_store && !context_store.respond_to?(:current)
110
+ raise RuntimeContractError, 'execution_context_store must respond to current'
111
+ end
112
+ raise RuntimeContractError, 'pid_source must respond to call' unless pids.respond_to?(:call)
113
+ end
114
+
115
+ def prepare_observation(operation, measurements)
116
+ # The block keeps all preparation under one recursion guard.
117
+ # rubocop:disable Metrics/BlockLength
118
+ with_guard do
119
+ canonical_operation = Validation.operation(operation)
120
+ normalized_measurements = normalize_measurements(measurements)
121
+ started_ns = clock.monotonic_ns
122
+ location = project_callsite
123
+ next unless location
124
+
125
+ thread = Thread.current
126
+ fiber = Fiber.current
127
+ captured_context = capture_execution_context
128
+ handle = active_operations.register(
129
+ operation: canonical_operation,
130
+ monotonic_ns: started_ns,
131
+ location: location,
132
+ execution_context: captured_context,
133
+ thread: thread,
134
+ fiber: fiber
135
+ )
136
+ Observation.new(
137
+ operation: canonical_operation,
138
+ started_monotonic_ns: started_ns,
139
+ location: location,
140
+ handle: handle,
141
+ thread_id: thread.object_id,
142
+ fiber_id: fiber.object_id,
143
+ measurements: normalized_measurements,
144
+ execution_context: captured_context
145
+ )
146
+ end
147
+ # rubocop:enable Metrics/BlockLength
148
+ end
149
+
150
+ def capture_execution_context
151
+ return Context::UNKNOWN unless execution_context_store
152
+
153
+ execution_context_store.current
154
+ rescue StandardError
155
+ Context::UNKNOWN
156
+ end
157
+
158
+ def emit_start_observation(observation)
159
+ emit_observation(:operation_started, observation, monotonic_ns: observation.started_monotonic_ns)
160
+ rescue StandardError => e
161
+ with_guard { active_operations.finish(observation.handle) } if observation.handle && !fail_open?
162
+ instrumentation_failure(e)
163
+ end
164
+
165
+ def finalize_observation(observation, completed:, result:, measurement_builder:)
166
+ error = nil
167
+ begin
168
+ if active_for_current_process?
169
+ with_guard do
170
+ ended_ns = clock.monotonic_ns
171
+ if ended_ns < observation.started_monotonic_ns
172
+ raise RuntimeSafetyError, 'probe monotonic clock moved backwards'
173
+ end
174
+
175
+ measurements = completed_measurements(observation, completed, result, measurement_builder)
176
+ emit_observation(
177
+ completed ? :operation_completed : :operation_aborted,
178
+ observation,
179
+ monotonic_ns: ended_ns,
180
+ duration_ns: ended_ns - observation.started_monotonic_ns,
181
+ measurements: measurements
182
+ )
183
+ end
184
+ end
185
+ rescue StandardError => e
186
+ error = e
187
+ ensure
188
+ begin
189
+ with_guard { active_operations.finish(observation.handle) } if observation.handle
190
+ rescue StandardError => e
191
+ error ||= e
192
+ end
193
+ end
194
+
195
+ return unless error
196
+
197
+ account_internal_error
198
+ raise error if completed && !fail_open?
199
+ end
200
+
201
+ def completed_measurements(observation, completed, result, builder)
202
+ values = observation.measurements.dup
203
+ if completed && builder
204
+ generated = builder.call(result)
205
+ raise RuntimeContractError, 'measurement_builder must return a Hash' unless generated.is_a?(Hash)
206
+
207
+ values.merge!(generated)
208
+ end
209
+ values[:operation_sequence] = observation.handle&.sequence
210
+ values
211
+ end
212
+
213
+ def emit_observation(kind, observation, monotonic_ns:, duration_ns: nil, measurements: nil)
214
+ values = measurements || observation.measurements.merge(operation_sequence: observation.handle&.sequence)
215
+ recorder.record do
216
+ Event.new(
217
+ kind: kind,
218
+ source: SOURCE,
219
+ occurred_at: clock.wall_time,
220
+ monotonic_ns: monotonic_ns,
221
+ duration_ns: duration_ns,
222
+ operation: observation.operation,
223
+ location: observation.location,
224
+ execution_context: observation.execution_context,
225
+ thread_id: observation.thread_id,
226
+ fiber_id: observation.fiber_id,
227
+ measurements: values
228
+ )
229
+ end
230
+ end
231
+
232
+ def normalize_measurements(value)
233
+ raise RuntimeContractError, 'probe measurements must be a Hash' unless value.is_a?(Hash)
234
+
235
+ value.dup.freeze
236
+ end
237
+
238
+ def project_callsite
239
+ caller_locations(0, MAX_CALLSITE_FRAMES)&.each do |frame|
240
+ paths = frame_paths(frame)
241
+ next if paths.any? { |path| internal_path?(path) }
242
+
243
+ return paths.filter_map { |path| safe_location(frame, path) }.first
244
+ rescue StandardError
245
+ return nil
246
+ end
247
+ nil
248
+ end
249
+
250
+ def frame_paths(frame)
251
+ candidates = [frame.absolute_path, frame.path].compact.filter_map do |path|
252
+ next unless path.is_a?(String) && !path.start_with?('-', '<')
253
+
254
+ File.absolute_path?(path) ? File.expand_path(path) : File.expand_path(path, redactor.root)
255
+ end
256
+ candidates.uniq
257
+ end
258
+
259
+ def internal_path?(path)
260
+ path == INTERNAL_PATH || path.start_with?("#{INTERNAL_PATH}#{File::SEPARATOR}")
261
+ end
262
+
263
+ def safe_location(frame, path)
264
+ location = redactor.location(path: path, line: frame.lineno, column: nil)
265
+ return if Location::SENTINELS.include?(location.path)
266
+
267
+ location
268
+ end
269
+
270
+ class << self
271
+ def guarded?
272
+ state = guard_state
273
+ key = guard_key
274
+ ORIGINAL_MUTEX_SYNCHRONIZE.bind_call(state.fetch(:mutex)) do
275
+ state.fetch(:depths).fetch(key, 0).positive?
276
+ end
277
+ end
278
+
279
+ def enter_guard
280
+ state = guard_state
281
+ key = guard_key
282
+ ORIGINAL_MUTEX_SYNCHRONIZE.bind_call(state.fetch(:mutex)) do
283
+ depths = state.fetch(:depths)
284
+ depths[key] = depths.fetch(key, 0) + 1
285
+ end
286
+ end
287
+
288
+ def exit_guard
289
+ state = guard_state
290
+ key = guard_key
291
+ ORIGINAL_MUTEX_SYNCHRONIZE.bind_call(state.fetch(:mutex)) do
292
+ depths = state.fetch(:depths)
293
+ depth = depths.fetch(key, 1) - 1
294
+ depth.positive? ? depths[key] = depth : depths.delete(key)
295
+ end
296
+ end
297
+
298
+ private
299
+
300
+ def guard_state
301
+ pid = Process.pid
302
+ return @guard_state if @guard_pid == pid && @guard_state
303
+
304
+ @guard_pid = pid
305
+ @guard_state = { mutex: Mutex.new, depths: {} }
306
+ end
307
+
308
+ def guard_key
309
+ [Thread.current.object_id, Fiber.current.object_id]
310
+ end
311
+ end
312
+
313
+ def guarded?
314
+ self.class.guarded?
315
+ end
316
+
317
+ def account_internal_error
318
+ with_guard { recorder.internal_error! unless recorder.disabled? }
319
+ rescue StandardError
320
+ nil
321
+ end
322
+
323
+ def current_pid
324
+ value = @pid_source.call
325
+ return value if value.is_a?(Integer) && value.positive?
326
+
327
+ raise RuntimeContractError, 'pid_source must return a positive Integer'
328
+ end
329
+ end
330
+ # rubocop:enable Metrics/ClassLength
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ module Runtime
5
+ module Probes
6
+ module HTTP
7
+ HTTP_SCHEME = /\Ahttps?:/i
8
+
9
+ module NetHTTPClassHook
10
+ def get(...)
11
+ Registry.observe(operation: 'Net::HTTP.get') { super }
12
+ end
13
+
14
+ def get_response(...)
15
+ Registry.observe(operation: 'Net::HTTP.get_response') { super }
16
+ end
17
+
18
+ def start(...)
19
+ Registry.observe(operation: 'Net::HTTP.start') { super }
20
+ end
21
+ end
22
+
23
+ module NetHTTPClassRequestHook
24
+ def request(...)
25
+ Registry.observe(operation: 'Net::HTTP.request') { super }
26
+ end
27
+ end
28
+
29
+ module NetHTTPInstanceHook
30
+ def start(...)
31
+ Registry.observe(operation: 'Net::HTTP.start') { super }
32
+ end
33
+
34
+ def request(...)
35
+ Registry.observe(operation: 'Net::HTTP.request') { super }
36
+ end
37
+ end
38
+
39
+ module URIHook
40
+ def open(*arguments, **, &)
41
+ return super unless HTTP.http_target?(arguments.first)
42
+
43
+ Registry.observe(operation: 'URI.open') { super }
44
+ end
45
+ end
46
+
47
+ module OpenURIHook
48
+ def open_uri(*arguments, **, &)
49
+ return super unless HTTP.http_target?(arguments.first)
50
+
51
+ Registry.observe(operation: 'OpenURI.open_uri') { super }
52
+ end
53
+ end
54
+
55
+ module_function
56
+
57
+ def install!(registry)
58
+ if defined?(Net::HTTP)
59
+ registry.prepend_once(Net::HTTP.singleton_class, NetHTTPClassHook)
60
+ registry.prepend_once(Net::HTTP, NetHTTPInstanceHook)
61
+ registry.prepend_once(Net::HTTP.singleton_class, NetHTTPClassRequestHook) if Net::HTTP.respond_to?(:request)
62
+ end
63
+ registry.prepend_once(URI.singleton_class, URIHook) if defined?(URI) && URI.respond_to?(:open)
64
+ return unless defined?(OpenURI) && OpenURI.respond_to?(:open_uri)
65
+
66
+ registry.prepend_once(OpenURI.singleton_class, OpenURIHook)
67
+ end
68
+
69
+ def http_target?(value)
70
+ return value.match?(HTTP_SCHEME) if value.instance_of?(String) && value.valid_encoding?
71
+ return false unless defined?(URI::HTTP)
72
+
73
+ value.instance_of?(URI::HTTP) || (defined?(URI::HTTPS) && value.instance_of?(URI::HTTPS))
74
+ rescue StandardError
75
+ false
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ module Runtime
5
+ module Probes
6
+ module IOSelect
7
+ module_function
8
+
9
+ def timeout_measurement(arguments)
10
+ { timeout_present: arguments.length >= 4 && !arguments[3].nil? }
11
+ end
12
+
13
+ module IOHook
14
+ def select(*arguments, &)
15
+ Registry.observe(
16
+ operation: 'IO.select',
17
+ measurements: IOSelect.timeout_measurement(arguments)
18
+ ) { super }
19
+ end
20
+ end
21
+
22
+ module KernelInstanceHook
23
+ def select(*arguments, &)
24
+ Registry.observe(
25
+ operation: 'Kernel.select',
26
+ measurements: IOSelect.timeout_measurement(arguments)
27
+ ) { super }
28
+ end
29
+
30
+ private :select
31
+ end
32
+
33
+ module KernelSingletonHook
34
+ def select(*arguments, &)
35
+ Registry.observe(
36
+ operation: 'Kernel.select',
37
+ measurements: IOSelect.timeout_measurement(arguments)
38
+ ) { super }
39
+ end
40
+ end
41
+
42
+ def install!(registry)
43
+ registry.prepend_once(IO.singleton_class, IOHook)
44
+ registry.prepend_once(Kernel, KernelInstanceHook)
45
+ registry.prepend_once(Kernel.singleton_class, KernelSingletonHook)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end