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 'clock'
|
|
4
|
+
require_relative 'event'
|
|
5
|
+
require_relative 'jsonl/writer'
|
|
6
|
+
require_relative 'limits'
|
|
7
|
+
require_relative 'sampler'
|
|
8
|
+
require_relative 'session'
|
|
9
|
+
|
|
10
|
+
module FiberAudit
|
|
11
|
+
module Runtime
|
|
12
|
+
# Thread-safe coordinator for one bounded append-only runtime session.
|
|
13
|
+
# rubocop:disable Metrics/ClassLength
|
|
14
|
+
class Recorder
|
|
15
|
+
RESULTS = %i[
|
|
16
|
+
emitted sampled_out rate_limited session_event_limited session_byte_limited
|
|
17
|
+
oversize internal_error inactive
|
|
18
|
+
].freeze
|
|
19
|
+
MAX_COUNTER = Limits::MAX_COUNTER
|
|
20
|
+
OUTCOME_COUNTERS = 6
|
|
21
|
+
|
|
22
|
+
attr_reader :session, :writer
|
|
23
|
+
|
|
24
|
+
def self.start(...)
|
|
25
|
+
new(...)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(session:, writer:, clock: Clock.new, random: Sampler::RANDOM_SOURCE)
|
|
29
|
+
validate_dependencies!(session, writer, clock, random)
|
|
30
|
+
@session = session
|
|
31
|
+
@writer = writer
|
|
32
|
+
@clock = clock
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@state = :starting
|
|
35
|
+
@sequence = 1
|
|
36
|
+
@in_flight = 0
|
|
37
|
+
@start_written = false
|
|
38
|
+
@summary = nil
|
|
39
|
+
@limits = Limits.new(policy: session.policy, started_monotonic_ns: session.started_monotonic_ns)
|
|
40
|
+
@sampler = Sampler.new(policy: session.policy, random: random)
|
|
41
|
+
@end_reserve_bytes = end_reserve_bytes
|
|
42
|
+
start_session!
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
startup_failure!(e)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record(&factory)
|
|
48
|
+
record_observation(sample: true, factory: factory)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Control evidence is never sampled, but still consumes every configured
|
|
52
|
+
# rate, count, record-size, and session-size budget.
|
|
53
|
+
def record_control(&factory)
|
|
54
|
+
record_observation(sample: false, factory: factory)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Accounts an instrumentation failure without retaining exception data.
|
|
58
|
+
def internal_error!
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
return false if @state == :closed
|
|
61
|
+
|
|
62
|
+
@limits.internal_error!
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def close(status: :completed)
|
|
68
|
+
requested_status = normalize_status(status)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
return @summary if @summary
|
|
71
|
+
|
|
72
|
+
@state = :closing
|
|
73
|
+
@limits.internal_error!(count: @in_flight) if @in_flight.positive?
|
|
74
|
+
ended_at, ended_monotonic_ns, clock_error = closing_times
|
|
75
|
+
errors = [clock_error].compact
|
|
76
|
+
summary = build_summary(requested_status, ended_at, ended_monotonic_ns)
|
|
77
|
+
errors.concat(write_end_record(summary))
|
|
78
|
+
errors.concat(close_writer)
|
|
79
|
+
summary = build_summary(requested_status, ended_at, ended_monotonic_ns) unless errors.empty?
|
|
80
|
+
@summary = summary
|
|
81
|
+
@state = :closed
|
|
82
|
+
raise errors.first if errors.any? && !session.policy.fail_open?
|
|
83
|
+
|
|
84
|
+
@summary
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def active?
|
|
89
|
+
state?(:active)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def disabled?
|
|
93
|
+
state?(:disabled)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def closed?
|
|
97
|
+
state?(:closed)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def summary
|
|
101
|
+
@mutex.synchronize { @summary }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def validate_dependencies!(candidate_session, candidate_writer, candidate_clock, candidate_random)
|
|
107
|
+
raise RuntimeContractError, 'session must be a FiberAudit::Runtime::Session' unless candidate_session.is_a?(Session)
|
|
108
|
+
unless candidate_writer.is_a?(JSONL::Writer)
|
|
109
|
+
raise RuntimeContractError, 'writer must be a FiberAudit::Runtime::JSONL::Writer'
|
|
110
|
+
end
|
|
111
|
+
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
|
|
112
|
+
raise RuntimeContractError, 'random source must respond to call' unless candidate_random.respond_to?(:call)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def start_session!
|
|
116
|
+
raise RuntimeSafetyError, 'runtime JSONL writer is not empty' unless writer.bytes_written.zero?
|
|
117
|
+
if session.started_monotonic_ns > MAX_COUNTER
|
|
118
|
+
raise RuntimeSafetyError, 'session monotonic start exceeds signed 64-bit range'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
line = writer.prepare(JSONL::Schema.start_record(session))
|
|
122
|
+
required = line.bytesize + @end_reserve_bytes
|
|
123
|
+
unless session.policy.session_bytes_allowed?(written_bytes: 0, next_record_bytes: required)
|
|
124
|
+
raise RuntimeSafetyError, 'runtime session limit cannot contain start and end records'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
writer.write_line(line)
|
|
128
|
+
@start_written = true
|
|
129
|
+
@state = :active
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def startup_failure!(error)
|
|
133
|
+
raise error unless defined?(@session) && session.policy.fail_open?
|
|
134
|
+
|
|
135
|
+
@limits&.internal_error!
|
|
136
|
+
@state = :disabled
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def record_observation(sample:, factory:)
|
|
140
|
+
raise ArgumentError, 'record requires an event factory block' unless factory
|
|
141
|
+
|
|
142
|
+
decision = begin_observation(sample: sample)
|
|
143
|
+
return decision unless decision == :selected
|
|
144
|
+
|
|
145
|
+
completed = false
|
|
146
|
+
begin
|
|
147
|
+
event = factory.call
|
|
148
|
+
completed = true
|
|
149
|
+
ensure
|
|
150
|
+
release_failed_factory unless completed
|
|
151
|
+
end
|
|
152
|
+
finish_observation(event)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def begin_observation(sample:)
|
|
156
|
+
@mutex.synchronize do
|
|
157
|
+
return :inactive unless @state == :active
|
|
158
|
+
|
|
159
|
+
@limits.observe!
|
|
160
|
+
if sample && !@sampler.sample?
|
|
161
|
+
@limits.drop!(:sampled_out)
|
|
162
|
+
return :sampled_out
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@in_flight += 1
|
|
166
|
+
:selected
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
handle_internal_error!(e)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def finish_observation(event)
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
@in_flight -= 1
|
|
175
|
+
return finish_inactive_observation unless @state == :active
|
|
176
|
+
|
|
177
|
+
emit_or_drop(event)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
handle_internal_error!(e)
|
|
180
|
+
ensure
|
|
181
|
+
if writer.failed? && @state == :active
|
|
182
|
+
@limits.internal_error!
|
|
183
|
+
@state = :disabled
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def finish_inactive_observation
|
|
189
|
+
@limits.internal_error! if @state == :disabled
|
|
190
|
+
:inactive
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def emit_or_drop(event)
|
|
194
|
+
raise RuntimeContractError, 'event factory must return a FiberAudit::Runtime::Event' unless event.is_a?(Event)
|
|
195
|
+
|
|
196
|
+
now_ns = @clock.monotonic_ns
|
|
197
|
+
reason = @limits.preflight_event(now_ns: now_ns)
|
|
198
|
+
return account_preflight_drop(reason) if reason
|
|
199
|
+
|
|
200
|
+
line = prepare_event(event)
|
|
201
|
+
return :oversize unless line
|
|
202
|
+
|
|
203
|
+
unless event_bytes_allowed?(line)
|
|
204
|
+
@limits.drop!(:session_byte_limited)
|
|
205
|
+
return :session_byte_limited
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
writer.write_line(line)
|
|
209
|
+
@limits.emitted!(now_ns: now_ns)
|
|
210
|
+
@sequence += 1
|
|
211
|
+
:emitted
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def account_preflight_drop(reason)
|
|
215
|
+
@limits.drop!(reason)
|
|
216
|
+
reason
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def prepare_event(event)
|
|
220
|
+
record = JSONL::Schema.event_record(session_id: session.id, sequence: @sequence, event: event)
|
|
221
|
+
writer.prepare(record)
|
|
222
|
+
rescue RuntimeSafetyError
|
|
223
|
+
@limits.drop!(:oversize)
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def event_bytes_allowed?(line)
|
|
228
|
+
required = line.bytesize + @end_reserve_bytes
|
|
229
|
+
session.policy.session_bytes_allowed?(
|
|
230
|
+
written_bytes: writer.bytes_written,
|
|
231
|
+
next_record_bytes: required
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def release_failed_factory
|
|
236
|
+
@mutex.synchronize do
|
|
237
|
+
@in_flight -= 1
|
|
238
|
+
@limits.internal_error! if @state == :disabled
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def handle_internal_error!(error)
|
|
243
|
+
@limits.internal_error!
|
|
244
|
+
@state = :disabled
|
|
245
|
+
raise error unless session.policy.fail_open?
|
|
246
|
+
|
|
247
|
+
:internal_error
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def closing_times
|
|
251
|
+
[@clock.wall_time, @clock.monotonic_ns, nil]
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
@limits.internal_error!
|
|
254
|
+
[session.started_at, session.started_monotonic_ns, e]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def write_end_record(summary)
|
|
258
|
+
return [] unless @start_written && writer.active?
|
|
259
|
+
|
|
260
|
+
record = JSONL::Schema.end_record(session_id: session.id, sequence: @sequence, summary: summary)
|
|
261
|
+
line = writer.prepare(record)
|
|
262
|
+
if line.bytesize > @end_reserve_bytes
|
|
263
|
+
raise RuntimeSafetyError, 'runtime session end record exceeded its reserved bytes'
|
|
264
|
+
end
|
|
265
|
+
unless session.policy.session_bytes_allowed?(written_bytes: writer.bytes_written, next_record_bytes: line.bytesize)
|
|
266
|
+
raise RuntimeSafetyError, 'runtime session end record exceeded the session byte limit'
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
writer.write_line(line)
|
|
270
|
+
[]
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
@limits.internal_error!
|
|
273
|
+
[e]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def close_writer
|
|
277
|
+
writer.close
|
|
278
|
+
[]
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
@limits.internal_error!
|
|
281
|
+
[e]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def build_summary(requested_status, ended_at, ended_monotonic_ns)
|
|
285
|
+
status = summary_status(requested_status)
|
|
286
|
+
SessionSummary.new(
|
|
287
|
+
ended_at: ended_at,
|
|
288
|
+
ended_monotonic_ns: ended_monotonic_ns,
|
|
289
|
+
status: status,
|
|
290
|
+
**@limits.counters
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def summary_status(requested)
|
|
295
|
+
return :aborted if requested == :aborted
|
|
296
|
+
return :degraded if requested == :degraded || @limits.counters[:internal_errors].positive?
|
|
297
|
+
|
|
298
|
+
:completed
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def normalize_status(value)
|
|
302
|
+
normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
|
|
303
|
+
return normalized if SessionSummary::STATUSES.include?(normalized)
|
|
304
|
+
|
|
305
|
+
raise RuntimeContractError, "status must be one of: #{SessionSummary::STATUSES.join(', ')}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def end_reserve_bytes
|
|
309
|
+
outcome = MAX_COUNTER / OUTCOME_COUNTERS
|
|
310
|
+
summary = SessionSummary.new(
|
|
311
|
+
ended_at: session.started_at,
|
|
312
|
+
ended_monotonic_ns: MAX_COUNTER,
|
|
313
|
+
status: :completed,
|
|
314
|
+
events_observed: outcome * OUTCOME_COUNTERS,
|
|
315
|
+
events_emitted: outcome,
|
|
316
|
+
sampled_out: outcome,
|
|
317
|
+
rate_limited: outcome,
|
|
318
|
+
session_event_limited: outcome,
|
|
319
|
+
session_byte_limited: outcome,
|
|
320
|
+
oversize: outcome,
|
|
321
|
+
internal_errors: MAX_COUNTER
|
|
322
|
+
)
|
|
323
|
+
record = JSONL::Schema.end_record(session_id: session.id, sequence: MAX_COUNTER, summary: summary)
|
|
324
|
+
writer.prepare(record).bytesize
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def state?(expected)
|
|
328
|
+
@mutex.synchronize { @state == expected }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
# rubocop:enable Metrics/ClassLength
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'location'
|
|
5
|
+
require_relative 'policy'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
class Redactor
|
|
10
|
+
REDACTED = '[redacted]'
|
|
11
|
+
EXTERNAL = '[external]'
|
|
12
|
+
WINDOWS_ABSOLUTE = %r{\A[A-Za-z]:/}
|
|
13
|
+
|
|
14
|
+
attr_reader :root, :policy
|
|
15
|
+
|
|
16
|
+
def initialize(root:, policy: Policy.new)
|
|
17
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
|
|
18
|
+
|
|
19
|
+
safe_root = Validation.string(root, 'root', max_bytes: 4_096)
|
|
20
|
+
@root = canonical_root(safe_root).freeze
|
|
21
|
+
@policy = policy
|
|
22
|
+
freeze
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
raise RuntimeContractError, 'root is invalid'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def location(path:, line: nil, column: nil)
|
|
28
|
+
Location.new(path: redact_path(path), line: line, column: column)
|
|
29
|
+
rescue RuntimeContractError
|
|
30
|
+
Location.new(path: REDACTED, line: nil, column: nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def operation(value)
|
|
34
|
+
return if value.nil?
|
|
35
|
+
|
|
36
|
+
text = value.is_a?(String) || value.is_a?(Symbol) ? value.to_s : nil
|
|
37
|
+
return REDACTED unless text&.valid_encoding? && text.bytesize <= 256
|
|
38
|
+
return REDACTED if text.match?(Validation::CONTROL)
|
|
39
|
+
return REDACTED unless text.match?(Validation::OPERATION)
|
|
40
|
+
|
|
41
|
+
text.dup.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def canonical_root(value)
|
|
47
|
+
normalized = value.tr('\\', '/')
|
|
48
|
+
return Pathname.new(normalized).cleanpath.to_s if windows_absolute?(normalized)
|
|
49
|
+
|
|
50
|
+
Pathname.new(normalized).expand_path.cleanpath.to_s.tr('\\', '/')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def redact_path(value)
|
|
54
|
+
return REDACTED unless value.is_a?(String) && value.valid_encoding? && !value.empty?
|
|
55
|
+
return REDACTED if value.bytesize > Location::MAX_PATH_BYTES || value.match?(Validation::CONTROL)
|
|
56
|
+
|
|
57
|
+
normalized = value.tr('\\', '/')
|
|
58
|
+
return absolute_path(normalized) if windows_absolute?(normalized)
|
|
59
|
+
|
|
60
|
+
path = Pathname.new(normalized)
|
|
61
|
+
return relative_path(path) unless path.absolute?
|
|
62
|
+
|
|
63
|
+
absolute_path(path.expand_path.cleanpath.to_s.tr('\\', '/'))
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
REDACTED
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def absolute_path(path)
|
|
69
|
+
clean = Pathname.new(path).cleanpath.to_s.tr('\\', '/')
|
|
70
|
+
return EXTERNAL unless inside_root?(clean)
|
|
71
|
+
|
|
72
|
+
clean[(root.length + 1)..]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def relative_path(path)
|
|
76
|
+
clean = path.cleanpath
|
|
77
|
+
value = clean.to_s
|
|
78
|
+
return EXTERNAL if value == '..' || value.start_with?('../')
|
|
79
|
+
return REDACTED if value == '.'
|
|
80
|
+
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def inside_root?(path)
|
|
85
|
+
candidate, boundary = comparable_paths(path)
|
|
86
|
+
candidate.start_with?("#{boundary}/")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def comparable_paths(path)
|
|
90
|
+
if windows_absolute?(path) && windows_absolute?(root)
|
|
91
|
+
[path.downcase, root.downcase]
|
|
92
|
+
else
|
|
93
|
+
[path, root]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def windows_absolute?(path)
|
|
98
|
+
path.match?(WINDOWS_ABSOLUTE)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'policy'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
class Sampler
|
|
8
|
+
RANDOM_SOURCE = -> { Random.rand }.freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :policy
|
|
11
|
+
|
|
12
|
+
def initialize(policy:, random: RANDOM_SOURCE)
|
|
13
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
|
|
14
|
+
raise RuntimeContractError, 'random source must respond to call' unless random.respond_to?(:call)
|
|
15
|
+
|
|
16
|
+
@policy = policy
|
|
17
|
+
@random = random
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sample?
|
|
22
|
+
policy.sample?(draw: @random.call)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'watchdog'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
# Explicit-runtime-only hooks for schedulers installed after RUBYOPT boot.
|
|
8
|
+
class SchedulerObserver
|
|
9
|
+
module FiberHook
|
|
10
|
+
# Ruby's scheduler API fixes this writer-like method name.
|
|
11
|
+
# rubocop:disable Naming/AccessorMethodName
|
|
12
|
+
def set_scheduler(scheduler)
|
|
13
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_replacing(thread: Thread.current) if Fiber.scheduler
|
|
14
|
+
result = super
|
|
15
|
+
if scheduler
|
|
16
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_installed(
|
|
17
|
+
scheduler: scheduler,
|
|
18
|
+
thread: Thread.current
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
# rubocop:enable Naming/AccessorMethodName
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module SchedulerCloseHook
|
|
27
|
+
def close(...)
|
|
28
|
+
FiberAudit::Runtime::SchedulerObserver.scheduler_closing(thread: Thread.current)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def activate(watchdog:)
|
|
35
|
+
raise RuntimeContractError, 'watchdog must be a FiberAudit::Runtime::Watchdog' unless watchdog.is_a?(Watchdog)
|
|
36
|
+
|
|
37
|
+
install_fiber_hook!
|
|
38
|
+
observer = new(watchdog: watchdog)
|
|
39
|
+
@current = observer
|
|
40
|
+
observer.attach_existing!
|
|
41
|
+
observer
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scheduler_replacing(thread:)
|
|
45
|
+
current_for_process&.scheduler_closing(thread: thread)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def scheduler_installed(scheduler:, thread:)
|
|
49
|
+
observer = current_for_process
|
|
50
|
+
return unless observer
|
|
51
|
+
|
|
52
|
+
observer.scheduler_installed(scheduler: scheduler, thread: thread)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scheduler_closing(thread:)
|
|
56
|
+
current_for_process&.scheduler_closing(thread: thread)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def deactivate(observer)
|
|
60
|
+
@current = nil if @current.equal?(observer)
|
|
61
|
+
observer.deactivate
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def current_for_process
|
|
67
|
+
observer = @current
|
|
68
|
+
return observer if observer&.active_for_current_process?
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def install_fiber_hook!
|
|
74
|
+
singleton = Fiber.singleton_class
|
|
75
|
+
singleton.prepend(FiberHook) unless singleton.ancestors.include?(FiberHook)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
attr_reader :watchdog, :owner_pid
|
|
80
|
+
|
|
81
|
+
def initialize(watchdog:)
|
|
82
|
+
@watchdog = watchdog
|
|
83
|
+
@owner_pid = Process.pid
|
|
84
|
+
@active = true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def active_for_current_process?
|
|
88
|
+
@active && owner_pid == Process.pid
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def attach_existing!
|
|
92
|
+
scheduler = Fiber.scheduler
|
|
93
|
+
scheduler_installed(scheduler: scheduler, thread: Thread.current) if scheduler
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def scheduler_installed(scheduler:, thread:)
|
|
98
|
+
return self unless active_for_current_process? && watchdog.enabled?
|
|
99
|
+
|
|
100
|
+
install_close_hook!(scheduler)
|
|
101
|
+
watchdog.scheduler_installed(thread: thread)
|
|
102
|
+
self
|
|
103
|
+
rescue StandardError
|
|
104
|
+
watchdog.scheduler_unsupported(thread: thread)
|
|
105
|
+
raise unless watchdog.fail_open?
|
|
106
|
+
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def scheduler_closing(thread:)
|
|
111
|
+
watchdog.scheduler_closing(thread: thread) if active_for_current_process?
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def deactivate
|
|
116
|
+
@active = false
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def install_close_hook!(scheduler)
|
|
123
|
+
singleton = scheduler.singleton_class
|
|
124
|
+
singleton.prepend(SchedulerCloseHook) unless singleton.ancestors.include?(SchedulerCloseHook)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../version'
|
|
4
|
+
require_relative 'policy'
|
|
5
|
+
require_relative 'validation'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
Session = Data.define(
|
|
10
|
+
:id,
|
|
11
|
+
:started_at,
|
|
12
|
+
:started_monotonic_ns,
|
|
13
|
+
:policy,
|
|
14
|
+
:tool_version,
|
|
15
|
+
:ruby_version
|
|
16
|
+
) do
|
|
17
|
+
def initialize(
|
|
18
|
+
id:,
|
|
19
|
+
started_at:,
|
|
20
|
+
started_monotonic_ns:,
|
|
21
|
+
policy: Policy.new,
|
|
22
|
+
tool_version: FiberAudit::VERSION,
|
|
23
|
+
ruby_version: RUBY_VERSION
|
|
24
|
+
)
|
|
25
|
+
super(
|
|
26
|
+
id: normalize_id(id),
|
|
27
|
+
started_at: Validation.utc_time(started_at, 'started_at'),
|
|
28
|
+
started_monotonic_ns: Validation.integer(started_monotonic_ns, 'started_monotonic_ns'),
|
|
29
|
+
policy: normalize_policy(policy),
|
|
30
|
+
tool_version: Validation.string(tool_version, 'tool_version', max_bytes: 64),
|
|
31
|
+
ruby_version: Validation.string(ruby_version, 'ruby_version', max_bytes: 64)
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def normalize_id(value)
|
|
38
|
+
unless value.is_a?(String) && value.match?(Validation::UUID)
|
|
39
|
+
raise RuntimeContractError, 'id must be a canonical lowercase UUID'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
value.dup.freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_policy(value)
|
|
46
|
+
return value if value.is_a?(Policy)
|
|
47
|
+
|
|
48
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy'
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
SessionSummary = Data.define(
|
|
53
|
+
:ended_at,
|
|
54
|
+
:ended_monotonic_ns,
|
|
55
|
+
:status,
|
|
56
|
+
:events_observed,
|
|
57
|
+
:events_emitted,
|
|
58
|
+
:sampled_out,
|
|
59
|
+
:rate_limited,
|
|
60
|
+
:session_event_limited,
|
|
61
|
+
:session_byte_limited,
|
|
62
|
+
:oversize,
|
|
63
|
+
:internal_errors
|
|
64
|
+
) do
|
|
65
|
+
def initialize(ended_at:, ended_monotonic_ns:, status:, **counters)
|
|
66
|
+
unknown = counters.keys - SessionSummary::COUNTERS
|
|
67
|
+
missing = SessionSummary::COUNTERS - counters.keys
|
|
68
|
+
raise RuntimeContractError, "unknown session summary field: #{unknown.first}" unless unknown.empty?
|
|
69
|
+
raise RuntimeContractError, "missing session summary field: #{missing.first}" unless missing.empty?
|
|
70
|
+
|
|
71
|
+
normalized_status = status.is_a?(String) || status.is_a?(Symbol) ? status.to_sym : nil
|
|
72
|
+
unless SessionSummary::STATUSES.include?(normalized_status)
|
|
73
|
+
raise RuntimeContractError, "status must be one of: #{SessionSummary::STATUSES.join(', ')}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
normalized_counters = counters.to_h do |name, value|
|
|
77
|
+
[name, Validation.integer(value, name.to_s)]
|
|
78
|
+
end
|
|
79
|
+
accounted = %i[
|
|
80
|
+
events_emitted
|
|
81
|
+
sampled_out
|
|
82
|
+
rate_limited
|
|
83
|
+
session_event_limited
|
|
84
|
+
session_byte_limited
|
|
85
|
+
oversize
|
|
86
|
+
].sum { |name| normalized_counters.fetch(name) }
|
|
87
|
+
if accounted > normalized_counters.fetch(:events_observed)
|
|
88
|
+
raise RuntimeContractError, 'event accounting exceeds events_observed'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
super(
|
|
92
|
+
ended_at: Validation.utc_time(ended_at, 'ended_at'),
|
|
93
|
+
ended_monotonic_ns: Validation.integer(ended_monotonic_ns, 'ended_monotonic_ns'),
|
|
94
|
+
status: normalized_status,
|
|
95
|
+
**normalized_counters
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
SessionSummary.const_set(:STATUSES, %i[completed degraded aborted].freeze)
|
|
101
|
+
SessionSummary.const_set(:COUNTERS, %i[
|
|
102
|
+
events_observed
|
|
103
|
+
events_emitted
|
|
104
|
+
sampled_out
|
|
105
|
+
rate_limited
|
|
106
|
+
session_event_limited
|
|
107
|
+
session_byte_limited
|
|
108
|
+
oversize
|
|
109
|
+
internal_errors
|
|
110
|
+
].freeze)
|
|
111
|
+
end
|
|
112
|
+
end
|