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,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