fiber_audit 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/.fiber-audit.example.yml +19 -0
- data/ARCHITECTURE.md +726 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +201 -0
- data/README.md +66 -8
- data/lib/fiber_audit/cli.rb +87 -2
- data/lib/fiber_audit/configuration.rb +106 -6
- data/lib/fiber_audit/errors.rb +2 -0
- data/lib/fiber_audit/operation_vocabulary.rb +42 -0
- data/lib/fiber_audit/reporters/text.rb +1 -1
- data/lib/fiber_audit/runtime/active_operations.rb +146 -0
- data/lib/fiber_audit/runtime/boot.rb +83 -0
- data/lib/fiber_audit/runtime/clock.rb +35 -0
- data/lib/fiber_audit/runtime/environment.rb +289 -0
- data/lib/fiber_audit/runtime/event.rb +86 -0
- data/lib/fiber_audit/runtime/execution_context.rb +89 -0
- data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
- data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
- data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
- data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
- data/lib/fiber_audit/runtime/limits.rb +102 -0
- data/lib/fiber_audit/runtime/location.rb +44 -0
- data/lib/fiber_audit/runtime/policy.rb +121 -0
- data/lib/fiber_audit/runtime/probes/base.rb +333 -0
- data/lib/fiber_audit/runtime/probes/http.rb +80 -0
- data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
- data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
- data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
- data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
- data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
- data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
- data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
- data/lib/fiber_audit/runtime/rails_integration.rb +320 -0
- data/lib/fiber_audit/runtime/recorder.rb +333 -0
- data/lib/fiber_audit/runtime/redactor.rb +102 -0
- data/lib/fiber_audit/runtime/sampler.rb +26 -0
- data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
- data/lib/fiber_audit/runtime/session.rb +112 -0
- data/lib/fiber_audit/runtime/supervisor.rb +114 -0
- data/lib/fiber_audit/runtime/validation.rb +68 -0
- data/lib/fiber_audit/runtime/watchdog.rb +479 -0
- data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
- data/lib/fiber_audit/runtime.rb +37 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
- data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
- data/lib/fiber_audit/static/rules/io_select.rb +2 -4
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
- data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
- data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
- data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
- data/lib/fiber_audit/version.rb +1 -1
- data/lib/fiber_audit.rb +1 -0
- metadata +38 -2
|
@@ -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
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative 'http'
|
|
5
|
+
require_relative 'io_select'
|
|
6
|
+
require_relative 'socket'
|
|
7
|
+
require_relative 'subprocess'
|
|
8
|
+
require_relative 'synchronization'
|
|
9
|
+
require_relative 'thread_state'
|
|
10
|
+
require_relative 'thread_wait'
|
|
11
|
+
|
|
12
|
+
module FiberAudit
|
|
13
|
+
module Runtime
|
|
14
|
+
module Probes
|
|
15
|
+
# Process-local owner of idempotent probe installation and dispatch.
|
|
16
|
+
class Registry
|
|
17
|
+
PROBE_INSTALLERS = [
|
|
18
|
+
Subprocess,
|
|
19
|
+
ThreadWait,
|
|
20
|
+
Synchronization,
|
|
21
|
+
ThreadState,
|
|
22
|
+
IOSelect,
|
|
23
|
+
Socket,
|
|
24
|
+
HTTP
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
module RequireInstanceHook
|
|
28
|
+
def require(...)
|
|
29
|
+
result = super
|
|
30
|
+
Registry.rescan_current!
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private :require
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module RequireSingletonHook
|
|
38
|
+
def require(...)
|
|
39
|
+
result = super
|
|
40
|
+
Registry.rescan_current!
|
|
41
|
+
result
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
def activate(base:)
|
|
47
|
+
raise RuntimeContractError, 'base must be a Runtime::Probes::Base' unless base.is_a?(Base)
|
|
48
|
+
|
|
49
|
+
registry = new(base: base)
|
|
50
|
+
@current = registry
|
|
51
|
+
registry.install!
|
|
52
|
+
registry
|
|
53
|
+
rescue StandardError
|
|
54
|
+
registry&.deactivate
|
|
55
|
+
@current = nil if @current.equal?(registry)
|
|
56
|
+
raise
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def observe(operation:, measurements: {}, emit_start: false, measurement_builder: nil, &application)
|
|
60
|
+
raise ArgumentError, 'probe observation requires a block' unless application
|
|
61
|
+
|
|
62
|
+
registry = current_for_observation
|
|
63
|
+
return application.call unless registry
|
|
64
|
+
|
|
65
|
+
registry.base.observe(
|
|
66
|
+
operation: operation,
|
|
67
|
+
measurements: measurements,
|
|
68
|
+
emit_start: emit_start,
|
|
69
|
+
measurement_builder: measurement_builder,
|
|
70
|
+
&application
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def rescan_current!
|
|
75
|
+
current_for_observation&.scan!
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def deactivate(registry)
|
|
79
|
+
@current = nil if @current.equal?(registry)
|
|
80
|
+
registry&.deactivate
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def current
|
|
84
|
+
current_for_observation
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def current_for_observation
|
|
90
|
+
registry = @current
|
|
91
|
+
return unless registry
|
|
92
|
+
return registry if registry.active_for_current_process?
|
|
93
|
+
return unless registry.stale_process?
|
|
94
|
+
|
|
95
|
+
FiberAudit::Runtime::Boot.current if defined?(FiberAudit::Runtime::Boot)
|
|
96
|
+
registry = @current
|
|
97
|
+
registry if registry&.active_for_current_process?
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
attr_reader :base, :owner_pid
|
|
102
|
+
|
|
103
|
+
def initialize(base:)
|
|
104
|
+
@base = base
|
|
105
|
+
@owner_pid = Process.pid
|
|
106
|
+
@mutex = Mutex.new
|
|
107
|
+
@active = true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def install!
|
|
111
|
+
scan!
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def scan!
|
|
116
|
+
return self unless active_for_current_process?
|
|
117
|
+
|
|
118
|
+
base.with_guard do
|
|
119
|
+
@mutex.synchronize do
|
|
120
|
+
prepend_once(Kernel, RequireInstanceHook)
|
|
121
|
+
prepend_once(Kernel.singleton_class, RequireSingletonHook)
|
|
122
|
+
PROBE_INSTALLERS.each { |installer| installer.install!(self) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
self
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
base.instrumentation_failure(e)
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def prepend_once(target, hook)
|
|
132
|
+
unless target.is_a?(Module) && hook.is_a?(Module)
|
|
133
|
+
raise RuntimeContractError, 'probe target and hook must be Modules'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
target.prepend(hook) unless target.ancestors.include?(hook)
|
|
137
|
+
target
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def deactivate
|
|
141
|
+
@active = false
|
|
142
|
+
base.deactivate
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def active_for_current_process?
|
|
147
|
+
@active && owner_pid == Process.pid
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def stale_process?
|
|
151
|
+
owner_pid != Process.pid
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../operation_vocabulary'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
module Probes
|
|
8
|
+
module Socket
|
|
9
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
10
|
+
MODULE_COMPARE = Module.instance_method(:<=)
|
|
11
|
+
CONST_DEFINED = Module.instance_method(:const_defined?)
|
|
12
|
+
CONST_GET = Module.instance_method(:const_get)
|
|
13
|
+
|
|
14
|
+
module ConstructorHook
|
|
15
|
+
def new(...)
|
|
16
|
+
operation = Socket.operation_for(self)
|
|
17
|
+
return super unless operation
|
|
18
|
+
|
|
19
|
+
Registry.observe(operation: operation) { super }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def install!(registry)
|
|
26
|
+
return unless defined?(::BasicSocket)
|
|
27
|
+
|
|
28
|
+
registry.prepend_once(::BasicSocket.singleton_class, ConstructorHook)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def operation_for(klass)
|
|
32
|
+
name = MODULE_NAME.bind_call(klass)
|
|
33
|
+
return unless safe_constant_name?(name)
|
|
34
|
+
return unless exact_socket?(klass, name) || ip_socket_subclass?(klass)
|
|
35
|
+
return unless resolve_constant(name).equal?(klass)
|
|
36
|
+
|
|
37
|
+
"#{name}.new"
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def exact_socket?(klass, name)
|
|
43
|
+
return false unless OperationVocabulary::FA1006_EXACT.include?(name)
|
|
44
|
+
|
|
45
|
+
resolve_constant(name).equal?(klass)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ip_socket_subclass?(klass)
|
|
49
|
+
return false unless defined?(::IPSocket)
|
|
50
|
+
|
|
51
|
+
MODULE_COMPARE.bind_call(klass, ::IPSocket) == true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def safe_constant_name?(name)
|
|
55
|
+
name.is_a?(String) && name.bytesize <= 240 &&
|
|
56
|
+
name.match?(/\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_constant(name)
|
|
60
|
+
namespace = Object
|
|
61
|
+
name.split('::').each do |part|
|
|
62
|
+
unless CONST_DEFINED.bind_call(namespace, part, false)
|
|
63
|
+
namespace = nil
|
|
64
|
+
break
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
namespace = CONST_GET.bind_call(namespace, part, false)
|
|
68
|
+
end
|
|
69
|
+
namespace
|
|
70
|
+
rescue NameError, TypeError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|