fiber_audit 0.2.1 → 0.3.1

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +7 -8
  3. data/ARCHITECTURE.md +97 -673
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +163 -86
  6. data/lib/fiber_audit/cli.rb +19 -5
  7. data/lib/fiber_audit/configuration.rb +31 -6
  8. data/lib/fiber_audit/operation_semantics.rb +172 -0
  9. data/lib/fiber_audit/operation_vocabulary.rb +2 -1
  10. data/lib/fiber_audit/runtime/active_operations.rb +39 -9
  11. data/lib/fiber_audit/runtime/boot.rb +4 -0
  12. data/lib/fiber_audit/runtime/environment.rb +58 -0
  13. data/lib/fiber_audit/runtime/execution_context.rb +55 -43
  14. data/lib/fiber_audit/runtime/lifecycle.rb +91 -50
  15. data/lib/fiber_audit/runtime/operation_liveness_monitor.rb +282 -0
  16. data/lib/fiber_audit/runtime/operation_liveness_policy.rb +47 -0
  17. data/lib/fiber_audit/runtime/probes/base.rb +27 -4
  18. data/lib/fiber_audit/runtime/probes/subprocess.rb +89 -8
  19. data/lib/fiber_audit/runtime/probes/thread_state.rb +0 -14
  20. data/lib/fiber_audit/runtime/scheduler_evidence_classifier.rb +60 -0
  21. data/lib/fiber_audit/runtime/scheduler_observer.rb +54 -18
  22. data/lib/fiber_audit/runtime/scheduler_snapshot.rb +95 -0
  23. data/lib/fiber_audit/runtime/watchdog.rb +54 -4
  24. data/lib/fiber_audit/runtime.rb +5 -0
  25. data/lib/fiber_audit/static/call_site_extractor.rb +1 -0
  26. data/lib/fiber_audit/static/rules/base.rb +19 -0
  27. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +67 -10
  28. data/lib/fiber_audit/static/rules/direct_socket.rb +54 -19
  29. data/lib/fiber_audit/static/rules/io_select.rb +6 -6
  30. data/lib/fiber_audit/static/rules/net_http_in_request.rb +11 -8
  31. data/lib/fiber_audit/static/rules/synchronization.rb +13 -8
  32. data/lib/fiber_audit/static/rules/thread_current_state.rb +20 -30
  33. data/lib/fiber_audit/static/rules/thread_join.rb +9 -7
  34. data/lib/fiber_audit/version.rb +1 -1
  35. metadata +6 -1
@@ -14,6 +14,8 @@ require_relative 'scheduler_observer'
14
14
  require_relative 'session'
15
15
  require_relative 'watchdog'
16
16
  require_relative 'watchdog_policy'
17
+ require_relative 'operation_liveness_monitor'
18
+ require_relative 'operation_liveness_policy'
17
19
 
18
20
  module FiberAudit
19
21
  module Runtime
@@ -21,6 +23,7 @@ module FiberAudit
21
23
  class Lifecycle
22
24
  attr_reader :settings, :owner_pid, :output_path, :recorder,
23
25
  :watchdog, :active_operations, :watchdog_policy,
26
+ :operation_liveness_monitor, :operation_liveness_policy,
24
27
  :redactor, :probe_registry, :execution_context_store,
25
28
  :rails_integration
26
29
 
@@ -31,6 +34,7 @@ module FiberAudit
31
34
  def initialize(
32
35
  settings:,
33
36
  watchdog_policy: nil,
37
+ operation_liveness_policy: nil,
34
38
  probes_enabled: false,
35
39
  clock: Clock.new,
36
40
  session_id_source: SecureRandom.method(:uuid),
@@ -39,11 +43,12 @@ module FiberAudit
39
43
  random: Sampler::RANDOM_SOURCE
40
44
  )
41
45
  validate_dependencies!(
42
- settings, watchdog_policy, probes_enabled, clock,
46
+ settings, watchdog_policy, operation_liveness_policy, probes_enabled, clock,
43
47
  session_id_source, pid_source, writer_factory, random
44
48
  )
45
49
  @settings = settings
46
50
  @watchdog_policy = watchdog_policy
51
+ @operation_liveness_policy = operation_liveness_policy
47
52
  @probes_enabled = probes_enabled
48
53
  @clock = clock
49
54
  @session_id_source = session_id_source
@@ -52,15 +57,7 @@ module FiberAudit
52
57
  @random = random
53
58
  @owner_pid = current_pid
54
59
  @state = :starting
55
- @output_path = nil
56
- @recorder = nil
57
- @watchdog = nil
58
- @scheduler_observer = nil
59
- @active_operations = nil
60
- @redactor = nil
61
- @probe_registry = nil
62
- @execution_context_store = nil
63
- @rails_integration = nil
60
+ reset_process_components!
64
61
  start_process_session!
65
62
  rescue StandardError => e
66
63
  startup_failure!(e)
@@ -84,15 +81,7 @@ module FiberAudit
84
81
 
85
82
  abandon_inherited_runtime!
86
83
  @owner_pid = pid
87
- @output_path = nil
88
- @recorder = nil
89
- @watchdog = nil
90
- @scheduler_observer = nil
91
- @active_operations = nil
92
- @redactor = nil
93
- @probe_registry = nil
94
- @execution_context_store = nil
95
- @rails_integration = nil
84
+ reset_process_components!
96
85
  @state = :starting
97
86
  # Reset fiber-local context after fork
98
87
  ExecutionContext.reset!
@@ -122,13 +111,18 @@ module FiberAudit
122
111
 
123
112
  private
124
113
 
125
- def validate_dependencies!(candidate_settings, watchdog, probes, candidate_clock, session_ids, pids, writers, random)
114
+ def validate_dependencies!(candidate_settings, watchdog, liveness, probes, candidate_clock,
115
+ session_ids, pids, writers, random)
126
116
  unless candidate_settings.is_a?(Environment::Settings)
127
117
  raise RuntimeContractError, 'settings must be FiberAudit::Runtime::Environment::Settings'
128
118
  end
129
119
  unless watchdog.nil? || watchdog.is_a?(WatchdogPolicy)
130
120
  raise RuntimeContractError, 'watchdog_policy must be a FiberAudit::Runtime::WatchdogPolicy or nil'
131
121
  end
122
+ unless liveness.nil? || liveness.is_a?(OperationLivenessPolicy)
123
+ raise RuntimeContractError,
124
+ 'operation_liveness_policy must be a FiberAudit::Runtime::OperationLivenessPolicy or nil'
125
+ end
132
126
  raise RuntimeContractError, 'probes_enabled must be a Boolean' unless [true, false].include?(probes)
133
127
  raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
134
128
 
@@ -142,6 +136,19 @@ module FiberAudit
142
136
  end
143
137
  end
144
138
 
139
+ def reset_process_components!
140
+ @output_path = nil
141
+ @recorder = nil
142
+ @watchdog = nil
143
+ @operation_liveness_monitor = nil
144
+ @scheduler_observer = nil
145
+ @active_operations = nil
146
+ @redactor = nil
147
+ @probe_registry = nil
148
+ @execution_context_store = nil
149
+ @rails_integration = nil
150
+ end
151
+
145
152
  def start_process_session!
146
153
  session_id = @session_id_source.call
147
154
  started_at = @clock.wall_time
@@ -175,6 +182,7 @@ module FiberAudit
175
182
  @redactor = Redactor.new(root: settings.project_root, policy: settings.policy)
176
183
  @execution_context_store = ExecutionContext if @probes_enabled
177
184
  setup_watchdog! if watchdog_policy
185
+ setup_operation_liveness! if @probes_enabled && operation_liveness_policy
178
186
  setup_rails_integration! if @probes_enabled
179
187
  setup_probes! if @probes_enabled
180
188
  rescue StandardError => e
@@ -202,6 +210,13 @@ module FiberAudit
202
210
  ensure
203
211
  @rails_integration = nil
204
212
  end
213
+ begin
214
+ @operation_liveness_monitor&.stop
215
+ rescue StandardError
216
+ nil
217
+ ensure
218
+ @operation_liveness_monitor = nil
219
+ end
205
220
  begin
206
221
  if @scheduler_observer
207
222
  SchedulerObserver.deactivate(@scheduler_observer)
@@ -215,6 +230,15 @@ module FiberAudit
215
230
  end
216
231
  end
217
232
 
233
+ def setup_operation_liveness!
234
+ @operation_liveness_monitor = OperationLivenessMonitor.new(
235
+ policy: operation_liveness_policy,
236
+ recorder: recorder,
237
+ active_operations: active_operations,
238
+ clock: @clock
239
+ )
240
+ end
241
+
218
242
  def setup_rails_integration!
219
243
  @rails_integration = RailsIntegration.activate(
220
244
  context_store: @execution_context_store,
@@ -272,44 +296,61 @@ module FiberAudit
272
296
  def abandon_inherited_runtime!
273
297
  recorder&.writer&.close
274
298
  ensure
275
- @recorder = nil
276
- @watchdog = nil
277
- @scheduler_observer = nil
278
- @active_operations = nil
279
- @redactor = nil
280
- @probe_registry = nil
281
- @execution_context_store = nil
282
- @rails_integration = nil
299
+ reset_process_components!
283
300
  end
284
301
 
285
302
  def stop_runtime_observers
286
303
  error = nil
287
- begin
288
- RailsIntegration.deactivate(@rails_integration) if @rails_integration
289
- rescue StandardError => e
290
- error ||= e
291
- ensure
292
- @rails_integration = nil
293
- end
294
- begin
295
- Probes::Registry.deactivate(probe_registry) if probe_registry
296
- rescue StandardError => e
297
- error ||= e
298
- ensure
299
- @probe_registry = nil
300
- end
301
- begin
302
- SchedulerObserver.deactivate(@scheduler_observer) if @scheduler_observer
303
- watchdog&.stop
304
- rescue StandardError => e
305
- error ||= e
306
- ensure
307
- @scheduler_observer = nil
308
- end
304
+ component_error = stop_rails_integration
305
+ error ||= component_error
306
+ component_error = stop_probe_registry
307
+ error ||= component_error
308
+ component_error = stop_operation_liveness_monitor
309
+ error ||= component_error
310
+ component_error = stop_watchdog
311
+ error ||= component_error
309
312
  recorder&.internal_error! if error
310
313
  error
311
314
  end
312
315
 
316
+ def stop_rails_integration
317
+ RailsIntegration.deactivate(@rails_integration) if @rails_integration
318
+ nil
319
+ rescue StandardError => e
320
+ e
321
+ ensure
322
+ @rails_integration = nil
323
+ end
324
+
325
+ def stop_probe_registry
326
+ Probes::Registry.deactivate(probe_registry) if probe_registry
327
+ nil
328
+ rescue StandardError => e
329
+ e
330
+ ensure
331
+ @probe_registry = nil
332
+ end
333
+
334
+ def stop_operation_liveness_monitor
335
+ operation_liveness_monitor&.stop
336
+ nil
337
+ rescue StandardError => e
338
+ e
339
+ ensure
340
+ @operation_liveness_monitor = nil
341
+ end
342
+
343
+ def stop_watchdog
344
+ SchedulerObserver.deactivate(@scheduler_observer) if @scheduler_observer
345
+ watchdog&.stop
346
+ nil
347
+ rescue StandardError => e
348
+ e
349
+ ensure
350
+ @scheduler_observer = nil
351
+ @watchdog = nil
352
+ end
353
+
313
354
  def startup_failure!(error)
314
355
  raise error unless defined?(@settings) && settings.policy.fail_open?
315
356
 
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'active_operations'
4
+ require_relative 'clock'
5
+ require_relative 'event'
6
+ require_relative 'operation_liveness_policy'
7
+ require_relative 'recorder'
8
+ require_relative 'scheduler_evidence_classifier'
9
+
10
+ module FiberAudit
11
+ module Runtime
12
+ class OperationLivenessMonitor
13
+ SOURCE = :operation_liveness_monitor
14
+ STOP_TIMEOUT_SECONDS = 1
15
+ MAX_START_EVENTS_PER_POLL = 10
16
+ Tracked = Data.define(:long_active_sequence, :entry)
17
+
18
+ attr_reader :policy, :recorder, :active_operations, :state
19
+
20
+ def initialize(policy:, recorder:, active_operations:, clock: Clock.new,
21
+ thread_factory: ->(&block) { Thread.new(&block) })
22
+ validate_dependencies!(policy, recorder, active_operations, clock, thread_factory)
23
+ @policy = policy
24
+ @recorder = recorder
25
+ @active_operations = active_operations
26
+ @clock = clock
27
+ @thread_factory = thread_factory
28
+ @poll_mutex = Mutex.new
29
+ @wait_mutex = Mutex.new
30
+ @condition = ConditionVariable.new
31
+ @tracked = {}
32
+ @long_active_sequence = 0
33
+ @monitor_thread = nil
34
+ @state = policy.enabled? ? :starting : :disabled
35
+ @stopping = false
36
+ @stopped = false
37
+ activate!
38
+ end
39
+
40
+ def enabled? = policy.enabled?
41
+ def fail_open? = recorder.session.policy.fail_open?
42
+
43
+ def poll(now_ns: nil)
44
+ return state unless enabled? && state == :active
45
+
46
+ observed_now = now_ns.nil? ? @clock.monotonic_ns : Validation.integer(now_ns, 'operation liveness poll time')
47
+ @poll_mutex.synchronize { poll_operations(observed_now) }
48
+ state
49
+ rescue StandardError => e
50
+ handle_failure(e)
51
+ raise e unless fail_open?
52
+
53
+ :unsupported
54
+ end
55
+
56
+ def stop
57
+ return self if @stopped
58
+
59
+ @wait_mutex.synchronize do
60
+ @stopping = true
61
+ @condition.broadcast
62
+ end
63
+ stop_monitor_thread
64
+ now_ns = safe_monotonic_ns
65
+ @poll_mutex.synchronize { close_tracked(now_ns, operation_finished: false) }
66
+ @stopped = true
67
+ self
68
+ rescue StandardError => e
69
+ handle_failure(e)
70
+ @stopped = true
71
+ raise e unless fail_open?
72
+
73
+ self
74
+ end
75
+
76
+ private
77
+
78
+ def activate!
79
+ if policy.enabled?
80
+ start_monitor_thread
81
+ @state = :active
82
+ emit_state(:operation_liveness_active)
83
+ else
84
+ emit_state(:operation_liveness_disabled)
85
+ end
86
+ rescue StandardError => e
87
+ handle_failure(e)
88
+ raise e unless fail_open?
89
+ end
90
+
91
+ def validate_dependencies!(candidate_policy, candidate_recorder, operations, candidate_clock, threads)
92
+ unless candidate_policy.is_a?(OperationLivenessPolicy)
93
+ raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::OperationLivenessPolicy'
94
+ end
95
+ unless candidate_recorder.is_a?(Recorder)
96
+ raise RuntimeContractError,
97
+ 'recorder must be a FiberAudit::Runtime::Recorder'
98
+ end
99
+ unless operations.is_a?(ActiveOperations)
100
+ raise RuntimeContractError, 'active_operations must be FiberAudit::Runtime::ActiveOperations'
101
+ end
102
+ raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
103
+ raise RuntimeContractError, 'thread_factory must respond to call' unless threads.respond_to?(:call)
104
+ end
105
+
106
+ def poll_operations(now_ns)
107
+ snapshot = active_operations.snapshot_with_metadata
108
+ current = snapshot.entries.to_h { |entry| [entry.sequence, entry] }
109
+ complete_absent(current, now_ns)
110
+ start_overdue(snapshot, current, now_ns)
111
+ end
112
+
113
+ def complete_absent(current, now_ns)
114
+ @tracked.keys.reject { |sequence| current.key?(sequence) }.each do |sequence|
115
+ emit_completion(@tracked.delete(sequence), now_ns, operation_finished: true)
116
+ end
117
+ end
118
+
119
+ def start_overdue(snapshot, current, now_ns)
120
+ overdue = current.values.reject do |entry|
121
+ @tracked.key?(entry.sequence) || !long_active?(entry, now_ns)
122
+ end
123
+ bounded = overdue.first(MAX_START_EVENTS_PER_POLL)
124
+ batch_truncated = overdue.size > bounded.size
125
+ bounded.each do |entry|
126
+ @long_active_sequence += 1
127
+ tracked = Tracked.new(long_active_sequence: @long_active_sequence, entry: entry)
128
+ @tracked[entry.sequence] = tracked
129
+ emit_start(tracked, now_ns, snapshot, batch_truncated, overdue.size)
130
+ end
131
+ end
132
+
133
+ def long_active?(entry, now_ns)
134
+ raise RuntimeSafetyError, 'operation liveness clock moved backwards' if now_ns < entry.started_monotonic_ns
135
+
136
+ policy.long_active?(age_ns: now_ns - entry.started_monotonic_ns)
137
+ end
138
+
139
+ def classifier_measurements(entry)
140
+ SchedulerEvidenceClassifier.measurements(
141
+ operation: entry.operation,
142
+ scheduler_snapshot: entry.scheduler_snapshot
143
+ )
144
+ end
145
+
146
+ def emit_start(tracked, now_ns, snapshot, batch_truncated, candidate_count)
147
+ entry = tracked.entry
148
+ age_ns = now_ns - entry.started_monotonic_ns
149
+ measurements = {
150
+ long_active_sequence: tracked.long_active_sequence,
151
+ operation_sequence: entry.sequence,
152
+ operation_started_monotonic_ns: entry.started_monotonic_ns,
153
+ observed_age_ns: age_ns,
154
+ long_active_threshold_ns: policy.long_active_threshold_ns,
155
+ poll_interval_ns: policy.poll_interval_ns,
156
+ active_operation_total_count: snapshot.total_count,
157
+ active_operation_snapshot_truncated: snapshot.truncated?,
158
+ long_active_batch_truncated: batch_truncated,
159
+ long_active_candidate_count: candidate_count
160
+ }
161
+ measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
162
+ measurements.merge!(classifier_measurements(entry))
163
+ emit_event(kind: :operation_long_active_started, entry: entry, monotonic_ns: now_ns,
164
+ duration_ns: age_ns, measurements: measurements)
165
+ end
166
+
167
+ def emit_completion(tracked, now_ns, operation_finished:)
168
+ entry = tracked.entry
169
+ duration_ns = [now_ns - entry.started_monotonic_ns, 0].max
170
+ measurements = {
171
+ long_active_sequence: tracked.long_active_sequence,
172
+ operation_sequence: entry.sequence,
173
+ operation_started_monotonic_ns: entry.started_monotonic_ns,
174
+ long_active_threshold_ns: policy.long_active_threshold_ns,
175
+ operation_finished: operation_finished
176
+ }
177
+ measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
178
+ measurements.merge!(classifier_measurements(entry))
179
+ emit_event(kind: :operation_long_active_completed, entry: entry, monotonic_ns: now_ns,
180
+ duration_ns: duration_ns, measurements: measurements)
181
+ end
182
+
183
+ def close_tracked(now_ns, operation_finished:)
184
+ tracked = @tracked.values
185
+ @tracked.clear
186
+ tracked.each { |entry| emit_completion(entry, now_ns, operation_finished: operation_finished) }
187
+ end
188
+
189
+ def emit_state(kind)
190
+ emit_event(kind: kind, entry: nil, monotonic_ns: @clock.monotonic_ns,
191
+ measurements: { poll_interval_ns: policy.poll_interval_ns,
192
+ long_active_threshold_ns: policy.long_active_threshold_ns })
193
+ end
194
+
195
+ def emit_event(kind:, entry:, monotonic_ns:, duration_ns: nil, measurements: {})
196
+ recorder.record_control do
197
+ Event.new(
198
+ kind: kind,
199
+ source: SOURCE,
200
+ occurred_at: @clock.wall_time,
201
+ monotonic_ns: monotonic_ns,
202
+ duration_ns: duration_ns,
203
+ operation: entry&.operation,
204
+ location: entry&.location,
205
+ execution_context: entry&.execution_context || :unknown,
206
+ thread_id: entry&.thread_id,
207
+ fiber_id: entry&.fiber_id,
208
+ measurements: measurements
209
+ )
210
+ end
211
+ end
212
+
213
+ def start_monitor_thread
214
+ thread = @thread_factory.call { monitor_loop }
215
+ raise RuntimeContractError, 'thread_factory must return a Thread' unless thread.is_a?(Thread)
216
+
217
+ @monitor_thread = thread
218
+ thread.report_on_exception = false
219
+ thread.abort_on_exception = !fail_open?
220
+ thread.name = 'fiber-audit-operation-liveness' if thread.respond_to?(:name=)
221
+ end
222
+
223
+ def monitor_loop
224
+ loop do
225
+ should_stop = @wait_mutex.synchronize do
226
+ @condition.wait(@wait_mutex, policy.poll_interval_ms.fdiv(1_000)) unless @stopping
227
+ @stopping
228
+ end
229
+ break if should_stop
230
+
231
+ poll
232
+ end
233
+ rescue StandardError => e
234
+ handle_failure(e)
235
+ raise e unless fail_open?
236
+ end
237
+
238
+ def stop_monitor_thread
239
+ thread = @monitor_thread
240
+ return unless thread && thread != Thread.current
241
+ return if thread.join(STOP_TIMEOUT_SECONDS)
242
+
243
+ account_internal_error
244
+ thread.kill
245
+ thread.join
246
+ ensure
247
+ @monitor_thread = nil
248
+ end
249
+
250
+ def handle_failure(error)
251
+ first_failure = @state != :unsupported
252
+ @state = :unsupported
253
+ @wait_mutex&.synchronize do
254
+ @stopping = true
255
+ @condition&.broadcast
256
+ end
257
+ account_internal_error
258
+ begin
259
+ emit_state(:operation_liveness_unsupported) if first_failure
260
+ rescue StandardError
261
+ nil
262
+ end
263
+ error
264
+ end
265
+
266
+ def account_internal_error
267
+ recorder.internal_error!
268
+ rescue StandardError
269
+ nil
270
+ end
271
+
272
+ def safe_monotonic_ns
273
+ @clock.monotonic_ns
274
+ rescue StandardError => e
275
+ account_internal_error
276
+ raise e unless fail_open?
277
+
278
+ recorder.session.started_monotonic_ns
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validation'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ OperationLivenessPolicy = Data.define(:enabled, :poll_interval_ms, :long_active_threshold_ms) do
8
+ def initialize(**values)
9
+ unknown = values.keys - OperationLivenessPolicy::DEFAULTS.keys
10
+ raise RuntimeContractError, "unknown operation liveness policy field: #{unknown.first}" unless unknown.empty?
11
+
12
+ fields = OperationLivenessPolicy::DEFAULTS.merge(values)
13
+ raise RuntimeContractError, 'enabled must be a Boolean' unless [true, false].include?(fields[:enabled])
14
+
15
+ limits = OperationLivenessPolicy::LIMITS.to_h do |name, range|
16
+ value = fields.fetch(name)
17
+ unless value.is_a?(Integer) && range.cover?(value)
18
+ raise RuntimeContractError, "#{name} must be an Integer in #{range}"
19
+ end
20
+
21
+ [name, value]
22
+ end
23
+ super(enabled: fields[:enabled], **limits)
24
+ end
25
+
26
+ def enabled? = enabled
27
+ def poll_interval_ns = poll_interval_ms * OperationLivenessPolicy::NANOSECONDS_PER_MILLISECOND
28
+ def long_active_threshold_ns = long_active_threshold_ms * OperationLivenessPolicy::NANOSECONDS_PER_MILLISECOND
29
+
30
+ def long_active?(age_ns:)
31
+ Validation.integer(age_ns, 'active operation age') > long_active_threshold_ns
32
+ end
33
+ end
34
+
35
+ OperationLivenessPolicy.const_set(:NANOSECONDS_PER_MILLISECOND, 1_000_000)
36
+ OperationLivenessPolicy.const_set(:DEFAULTS, {
37
+ enabled: true,
38
+ poll_interval_ms: 100,
39
+ long_active_threshold_ms: 1_000
40
+ }.freeze)
41
+ OperationLivenessPolicy.const_set(:LIMITS, {
42
+ poll_interval_ms: 1..60_000,
43
+ long_active_threshold_ms: 1..86_400_000
44
+ }.freeze)
45
+ OperationLivenessPolicy.const_set(:DISABLED, OperationLivenessPolicy.new(enabled: false))
46
+ end
47
+ end
@@ -7,6 +7,8 @@ require_relative '../execution_context'
7
7
  require_relative '../rails_integration'
8
8
  require_relative '../recorder'
9
9
  require_relative '../redactor'
10
+ require_relative '../scheduler_evidence_classifier'
11
+ require_relative '../scheduler_snapshot'
10
12
 
11
13
  module FiberAudit
12
14
  module Runtime
@@ -27,7 +29,8 @@ module FiberAudit
27
29
  :thread_id,
28
30
  :fiber_id,
29
31
  :measurements,
30
- :execution_context
32
+ :execution_context,
33
+ :scheduler_snapshot
31
34
  )
32
35
 
33
36
  attr_reader :recorder, :clock, :redactor, :active_operations, :owner_pid, :execution_context_store
@@ -125,13 +128,15 @@ module FiberAudit
125
128
  thread = Thread.current
126
129
  fiber = Fiber.current
127
130
  captured_context = capture_execution_context
131
+ captured_scheduler_snapshot = capture_scheduler_snapshot
128
132
  handle = active_operations.register(
129
133
  operation: canonical_operation,
130
134
  monotonic_ns: started_ns,
131
135
  location: location,
132
136
  execution_context: captured_context,
133
137
  thread: thread,
134
- fiber: fiber
138
+ fiber: fiber,
139
+ scheduler_snapshot: captured_scheduler_snapshot
135
140
  )
136
141
  Observation.new(
137
142
  operation: canonical_operation,
@@ -141,7 +146,8 @@ module FiberAudit
141
146
  thread_id: thread.object_id,
142
147
  fiber_id: fiber.object_id,
143
148
  measurements: normalized_measurements,
144
- execution_context: captured_context
149
+ execution_context: captured_context,
150
+ scheduler_snapshot: captured_scheduler_snapshot
145
151
  )
146
152
  end
147
153
  # rubocop:enable Metrics/BlockLength
@@ -155,6 +161,10 @@ module FiberAudit
155
161
  Context::UNKNOWN
156
162
  end
157
163
 
164
+ def capture_scheduler_snapshot
165
+ SchedulerSnapshotCapture.capture
166
+ end
167
+
158
168
  def emit_start_observation(observation)
159
169
  emit_observation(:operation_started, observation, monotonic_ns: observation.started_monotonic_ns)
160
170
  rescue StandardError => e
@@ -207,11 +217,12 @@ module FiberAudit
207
217
  values.merge!(generated)
208
218
  end
209
219
  values[:operation_sequence] = observation.handle&.sequence
210
- values
220
+ enrich_scheduler_evidence(values, observation)
211
221
  end
212
222
 
213
223
  def emit_observation(kind, observation, monotonic_ns:, duration_ns: nil, measurements: nil)
214
224
  values = measurements || observation.measurements.merge(operation_sequence: observation.handle&.sequence)
225
+ values = enrich_scheduler_evidence(values.dup, observation)
215
226
  recorder.record do
216
227
  Event.new(
217
228
  kind: kind,
@@ -229,6 +240,18 @@ module FiberAudit
229
240
  end
230
241
  end
231
242
 
243
+ def enrich_scheduler_evidence(values, observation)
244
+ snapshot = observation.scheduler_snapshot
245
+ values.merge!(snapshot.to_measurements) if snapshot
246
+ values.merge!(
247
+ SchedulerEvidenceClassifier.measurements(
248
+ operation: observation.operation,
249
+ scheduler_snapshot: snapshot
250
+ )
251
+ )
252
+ values
253
+ end
254
+
232
255
  def normalize_measurements(value)
233
256
  raise RuntimeContractError, 'probe measurements must be a Hash' unless value.is_a?(Hash)
234
257