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.
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 +41 -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 +320 -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,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative '../../errors'
6
+ require_relative '../event'
7
+ require_relative '../session'
8
+
9
+ module FiberAudit
10
+ module Runtime
11
+ module JSONL
12
+ # Versioned builders and validators stay together to keep one schema authority.
13
+ # rubocop:disable Metrics/ModuleLength
14
+ module Schema
15
+ SCHEMA_VERSION = '1.0'
16
+ RECORD_TYPES = %w[session_start event session_end].freeze
17
+ ENVELOPE_KEYS = %w[
18
+ schema_version record_type session_id sequence recorded_at monotonic_ns payload
19
+ ].freeze
20
+ POLICY_KEYS = %w[
21
+ redaction sampling_rate max_events_per_second max_events_per_session
22
+ max_record_bytes max_session_bytes fail_open
23
+ ].freeze
24
+ EVENT_KEYS = %w[
25
+ kind source duration_ns operation location execution_context thread_id fiber_id measurements
26
+ ].freeze
27
+ LOCATION_KEYS = %w[path line column].freeze
28
+ END_KEYS = %w[status events_observed events_emitted dropped internal_errors].freeze
29
+ DROPPED_KEYS = %w[
30
+ sampling rate_limit session_event_limit session_byte_limit record_size_limit
31
+ ].freeze
32
+ UUID = Validation::UUID
33
+ TIMESTAMP = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z\z/
34
+
35
+ module_function
36
+
37
+ def start_record(session)
38
+ require_type!(session, Session, 'session')
39
+ build_record(
40
+ record_type: 'session_start',
41
+ session_id: session.id,
42
+ sequence: 0,
43
+ recorded_at: session.started_at,
44
+ monotonic_ns: session.started_monotonic_ns,
45
+ payload: {
46
+ 'tool_version' => session.tool_version,
47
+ 'ruby_version' => session.ruby_version,
48
+ 'policy' => policy_payload(session.policy)
49
+ }
50
+ )
51
+ end
52
+
53
+ def event_record(session_id:, sequence:, event:)
54
+ require_type!(event, Event, 'event')
55
+ build_record(
56
+ record_type: 'event',
57
+ session_id: session_id,
58
+ sequence: sequence,
59
+ recorded_at: event.occurred_at,
60
+ monotonic_ns: event.monotonic_ns,
61
+ payload: {
62
+ 'kind' => event.kind.to_s,
63
+ 'source' => event.source.to_s,
64
+ 'duration_ns' => event.duration_ns,
65
+ 'operation' => event.operation,
66
+ 'location' => location_payload(event.location),
67
+ 'execution_context' => event.execution_context.to_s,
68
+ 'thread_id' => event.thread_id,
69
+ 'fiber_id' => event.fiber_id,
70
+ 'measurements' => event.measurements
71
+ }
72
+ )
73
+ end
74
+
75
+ def end_record(session_id:, sequence:, summary:)
76
+ require_type!(summary, SessionSummary, 'summary')
77
+ build_record(
78
+ record_type: 'session_end',
79
+ session_id: session_id,
80
+ sequence: sequence,
81
+ recorded_at: summary.ended_at,
82
+ monotonic_ns: summary.ended_monotonic_ns,
83
+ payload: {
84
+ 'status' => summary.status.to_s,
85
+ 'events_observed' => summary.events_observed,
86
+ 'events_emitted' => summary.events_emitted,
87
+ 'dropped' => {
88
+ 'sampling' => summary.sampled_out,
89
+ 'rate_limit' => summary.rate_limited,
90
+ 'session_event_limit' => summary.session_event_limited,
91
+ 'session_byte_limit' => summary.session_byte_limited,
92
+ 'record_size_limit' => summary.oversize
93
+ },
94
+ 'internal_errors' => summary.internal_errors
95
+ }
96
+ )
97
+ end
98
+
99
+ def validate!(record)
100
+ require_exact_keys!(record, ENVELOPE_KEYS, 'record')
101
+ require_value!(record['schema_version'] == SCHEMA_VERSION, 'schema_version must be 1.0')
102
+ require_value!(RECORD_TYPES.include?(record['record_type']), 'record_type is invalid')
103
+ validate_uuid!(record['session_id'])
104
+ validate_sequence!(record['sequence'], record['record_type'])
105
+ parse_timestamp(record['recorded_at'], 'recorded_at')
106
+ require_non_negative_integer!(record['monotonic_ns'], 'monotonic_ns')
107
+
108
+ case record['record_type']
109
+ when 'session_start' then validate_start_payload!(record['payload'])
110
+ when 'event' then validate_event_payload!(record)
111
+ when 'session_end' then validate_end_payload!(record)
112
+ end
113
+
114
+ record
115
+ rescue RuntimeContractError
116
+ raise
117
+ rescue StandardError => e
118
+ raise RuntimeContractError, "invalid runtime JSONL record: #{e.message}"
119
+ end
120
+
121
+ def dump(record, max_record_bytes:)
122
+ validate!(record)
123
+ unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
124
+ raise RuntimeContractError, 'max_record_bytes must be a positive Integer'
125
+ end
126
+
127
+ json = "#{::JSON.generate(record)}\n"
128
+ if json.bytesize > max_record_bytes
129
+ raise RuntimeSafetyError,
130
+ "runtime JSONL record is #{json.bytesize} bytes; limit is #{max_record_bytes}"
131
+ end
132
+
133
+ json.freeze
134
+ rescue ::JSON::GeneratorError => e
135
+ raise RuntimeContractError, "runtime JSONL record is not JSON-safe: #{e.message}"
136
+ end
137
+
138
+ def build_record(record_type:, session_id:, sequence:, recorded_at:, monotonic_ns:, payload:)
139
+ record = {
140
+ 'schema_version' => SCHEMA_VERSION,
141
+ 'record_type' => record_type,
142
+ 'session_id' => session_id.is_a?(String) ? session_id.dup : session_id,
143
+ 'sequence' => sequence,
144
+ 'recorded_at' => format_time(recorded_at),
145
+ 'monotonic_ns' => monotonic_ns,
146
+ 'payload' => payload
147
+ }
148
+ validate!(record)
149
+ deep_freeze(record)
150
+ end
151
+ private_class_method :build_record
152
+
153
+ def policy_payload(policy)
154
+ {
155
+ 'redaction' => policy.redaction.to_s,
156
+ 'sampling_rate' => policy.sampling_rate,
157
+ 'max_events_per_second' => policy.max_events_per_second,
158
+ 'max_events_per_session' => policy.max_events_per_session,
159
+ 'max_record_bytes' => policy.max_record_bytes,
160
+ 'max_session_bytes' => policy.max_session_bytes,
161
+ 'fail_open' => policy.fail_open
162
+ }
163
+ end
164
+ private_class_method :policy_payload
165
+
166
+ def location_payload(location)
167
+ return unless location
168
+
169
+ { 'path' => location.path, 'line' => location.line, 'column' => location.column }
170
+ end
171
+ private_class_method :location_payload
172
+
173
+ def validate_start_payload!(payload)
174
+ require_exact_keys!(payload, %w[tool_version ruby_version policy], 'session_start payload')
175
+ Validation.string(payload['tool_version'], 'tool_version', max_bytes: 64)
176
+ Validation.string(payload['ruby_version'], 'ruby_version', max_bytes: 64)
177
+ require_exact_keys!(payload['policy'], POLICY_KEYS, 'policy')
178
+ values = payload['policy']
179
+ Policy.new(
180
+ redaction: values['redaction'],
181
+ sampling_rate: values['sampling_rate'],
182
+ max_events_per_second: values['max_events_per_second'],
183
+ max_events_per_session: values['max_events_per_session'],
184
+ max_record_bytes: values['max_record_bytes'],
185
+ max_session_bytes: values['max_session_bytes'],
186
+ fail_open: values['fail_open']
187
+ )
188
+ end
189
+ private_class_method :validate_start_payload!
190
+
191
+ def validate_event_payload!(record)
192
+ payload = record['payload']
193
+ require_exact_keys!(payload, EVENT_KEYS, 'event payload')
194
+ location = validate_location_payload!(payload['location'])
195
+ Event.new(
196
+ kind: payload['kind'],
197
+ source: payload['source'],
198
+ occurred_at: parse_timestamp(record['recorded_at'], 'recorded_at'),
199
+ monotonic_ns: record['monotonic_ns'],
200
+ duration_ns: payload['duration_ns'],
201
+ operation: payload['operation'],
202
+ location: location,
203
+ execution_context: payload['execution_context'],
204
+ thread_id: payload['thread_id'],
205
+ fiber_id: payload['fiber_id'],
206
+ measurements: payload['measurements']
207
+ )
208
+ end
209
+ private_class_method :validate_event_payload!
210
+
211
+ def validate_location_payload!(payload)
212
+ return if payload.nil?
213
+
214
+ require_exact_keys!(payload, LOCATION_KEYS, 'location')
215
+ Location.new(path: payload['path'], line: payload['line'], column: payload['column'])
216
+ end
217
+ private_class_method :validate_location_payload!
218
+
219
+ def validate_end_payload!(record)
220
+ payload = record['payload']
221
+ require_exact_keys!(payload, END_KEYS, 'session_end payload')
222
+ require_exact_keys!(payload['dropped'], DROPPED_KEYS, 'dropped')
223
+ dropped = payload['dropped']
224
+ SessionSummary.new(
225
+ ended_at: parse_timestamp(record['recorded_at'], 'recorded_at'),
226
+ ended_monotonic_ns: record['monotonic_ns'],
227
+ status: payload['status'],
228
+ events_observed: payload['events_observed'],
229
+ events_emitted: payload['events_emitted'],
230
+ sampled_out: dropped['sampling'],
231
+ rate_limited: dropped['rate_limit'],
232
+ session_event_limited: dropped['session_event_limit'],
233
+ session_byte_limited: dropped['session_byte_limit'],
234
+ oversize: dropped['record_size_limit'],
235
+ internal_errors: payload['internal_errors']
236
+ )
237
+ end
238
+ private_class_method :validate_end_payload!
239
+
240
+ def require_exact_keys!(value, expected, path)
241
+ raise RuntimeContractError, "#{path} must be an object" unless value.is_a?(Hash)
242
+
243
+ keys = value.keys
244
+ unknown = keys - expected
245
+ missing = expected - keys
246
+ raise RuntimeContractError, "unknown key #{unknown.first.inspect} at #{path}" unless unknown.empty?
247
+ raise RuntimeContractError, "missing key #{missing.first.inspect} at #{path}" unless missing.empty?
248
+ end
249
+ private_class_method :require_exact_keys!
250
+
251
+ def validate_uuid!(value)
252
+ require_value!(value.is_a?(String) && value.match?(UUID), 'session_id must be a canonical lowercase UUID')
253
+ end
254
+ private_class_method :validate_uuid!
255
+
256
+ def validate_sequence!(value, record_type)
257
+ minimum = record_type == 'session_start' ? 0 : 1
258
+ require_non_negative_integer!(value, 'sequence')
259
+ require_value!(value.zero?, 'session_start sequence must be 0') if record_type == 'session_start'
260
+ require_value!(value >= minimum, "#{record_type} sequence must be positive") unless record_type == 'session_start'
261
+ end
262
+ private_class_method :validate_sequence!
263
+
264
+ def parse_timestamp(value, field)
265
+ require_value!(
266
+ value.is_a?(String) && value.match?(TIMESTAMP),
267
+ "#{field} must be UTC RFC3339 with six fractional digits"
268
+ )
269
+ Time.iso8601(value)
270
+ rescue ArgumentError
271
+ raise RuntimeContractError, "#{field} is not a valid timestamp"
272
+ end
273
+ private_class_method :parse_timestamp
274
+
275
+ def format_time(value)
276
+ Validation.utc_time(value, 'recorded_at').iso8601(6)
277
+ end
278
+ private_class_method :format_time
279
+
280
+ def require_non_negative_integer!(value, field)
281
+ require_value!(value.is_a?(Integer) && value >= 0, "#{field} must be a non-negative Integer")
282
+ end
283
+ private_class_method :require_non_negative_integer!
284
+
285
+ def require_type!(value, type, field)
286
+ raise RuntimeContractError, "#{field} must be a #{type}" unless value.is_a?(type)
287
+ end
288
+ private_class_method :require_type!
289
+
290
+ def require_value!(condition, message)
291
+ raise RuntimeContractError, message unless condition
292
+ end
293
+ private_class_method :require_value!
294
+
295
+ def deep_freeze(value)
296
+ case value
297
+ when Hash
298
+ value.each do |key, entry|
299
+ deep_freeze(key)
300
+ deep_freeze(entry)
301
+ end
302
+ when Array
303
+ value.each { |entry| deep_freeze(entry) }
304
+ end
305
+ value.freeze
306
+ end
307
+ private_class_method :deep_freeze
308
+ end
309
+ # rubocop:enable Metrics/ModuleLength
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../errors'
4
+ require_relative '../validation'
5
+ require_relative 'schema'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ module JSONL
10
+ class Writer
11
+ attr_reader :bytes_written, :max_record_bytes
12
+
13
+ def self.open(path:, max_record_bytes:)
14
+ safe_path = Validation.string(path, 'runtime output path', max_bytes: 4_096)
15
+ # The returned Writer deliberately owns this descriptor until #close.
16
+ io = File.open(safe_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) # rubocop:disable Style/FileOpen
17
+ io.binmode
18
+ io.sync = true
19
+ new(io: io, max_record_bytes: max_record_bytes, owns_io: true)
20
+ rescue StandardError
21
+ io&.close
22
+ raise
23
+ end
24
+
25
+ def initialize(io:, max_record_bytes:, owns_io: false)
26
+ raise RuntimeContractError, 'io must respond to write' unless io.respond_to?(:write)
27
+ unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
28
+ raise RuntimeContractError, 'max_record_bytes must be a positive Integer'
29
+ end
30
+ raise RuntimeContractError, 'owns_io must be a Boolean' unless [true, false].include?(owns_io)
31
+
32
+ @io = io
33
+ @max_record_bytes = max_record_bytes
34
+ @owns_io = owns_io
35
+ @bytes_written = 0
36
+ @state = :active
37
+ end
38
+
39
+ def prepare(record)
40
+ ensure_active!
41
+ Schema.dump(record, max_record_bytes: max_record_bytes)
42
+ end
43
+
44
+ def write(record)
45
+ write_line(prepare(record))
46
+ end
47
+
48
+ def write_line(line)
49
+ ensure_active!
50
+ validate_line!(line)
51
+ completed = false
52
+ begin
53
+ write_all(line)
54
+ @io.flush if @io.respond_to?(:flush)
55
+ completed = true
56
+ ensure
57
+ @state = :failed unless completed
58
+ end
59
+ line.bytesize
60
+ end
61
+
62
+ def active?
63
+ @state == :active
64
+ end
65
+
66
+ def failed?
67
+ @state == :failed
68
+ end
69
+
70
+ def closed?
71
+ @state == :closed
72
+ end
73
+
74
+ def close
75
+ return if closed?
76
+
77
+ completed = false
78
+ begin
79
+ @io.close if @owns_io && @io.respond_to?(:close) && !@io.closed?
80
+ completed = true
81
+ ensure
82
+ @state = :failed unless completed
83
+ end
84
+ @state = :closed unless failed?
85
+ nil
86
+ end
87
+
88
+ private
89
+
90
+ def write_all(line)
91
+ offset = 0
92
+ while offset < line.bytesize
93
+ written = @io.write(line.byteslice(offset, line.bytesize - offset))
94
+ validate_write_result!(written, line.bytesize - offset)
95
+ offset += written
96
+ @bytes_written += written
97
+ end
98
+ end
99
+
100
+ def ensure_active!
101
+ raise RuntimeSafetyError, "runtime JSONL writer is #{@state}" unless active?
102
+ end
103
+
104
+ def validate_line!(line)
105
+ valid = line.is_a?(String) && line.valid_encoding? && line.end_with?("\n") && line.count("\n") == 1
106
+ raise RuntimeContractError, 'runtime JSONL line must be one valid complete line' unless valid
107
+ return unless line.bytesize > max_record_bytes
108
+
109
+ raise RuntimeSafetyError,
110
+ "runtime JSONL record is #{line.bytesize} bytes; limit is #{max_record_bytes}"
111
+ end
112
+
113
+ def validate_write_result!(written, remaining)
114
+ return if written.is_a?(Integer) && written.positive? && written <= remaining
115
+
116
+ @state = :failed
117
+ raise RuntimeSafetyError, "runtime JSONL write returned invalid byte count: #{written.inspect}"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end