fiber_audit 0.1.0 → 0.2.0
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 +30 -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 +279 -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,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../execution_context'
|
|
4
|
+
require_relative 'location'
|
|
5
|
+
require_relative 'validation'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
Event = Data.define(
|
|
10
|
+
:kind,
|
|
11
|
+
:source,
|
|
12
|
+
:occurred_at,
|
|
13
|
+
:monotonic_ns,
|
|
14
|
+
:duration_ns,
|
|
15
|
+
:operation,
|
|
16
|
+
:location,
|
|
17
|
+
:execution_context,
|
|
18
|
+
:thread_id,
|
|
19
|
+
:fiber_id,
|
|
20
|
+
:measurements
|
|
21
|
+
) do
|
|
22
|
+
def initialize(
|
|
23
|
+
kind:,
|
|
24
|
+
source:,
|
|
25
|
+
occurred_at:,
|
|
26
|
+
monotonic_ns:,
|
|
27
|
+
duration_ns: nil,
|
|
28
|
+
operation: nil,
|
|
29
|
+
location: nil,
|
|
30
|
+
execution_context: :unknown,
|
|
31
|
+
thread_id: nil,
|
|
32
|
+
fiber_id: nil,
|
|
33
|
+
measurements: {}
|
|
34
|
+
)
|
|
35
|
+
super(
|
|
36
|
+
kind: Validation.identifier(kind, 'kind'),
|
|
37
|
+
source: Validation.identifier(source, 'source'),
|
|
38
|
+
occurred_at: Validation.utc_time(occurred_at, 'occurred_at'),
|
|
39
|
+
monotonic_ns: Validation.integer(monotonic_ns, 'monotonic_ns'),
|
|
40
|
+
duration_ns: Validation.integer(duration_ns, 'duration_ns', allow_nil: true),
|
|
41
|
+
operation: Validation.operation(operation, allow_nil: true),
|
|
42
|
+
location: normalize_location(location),
|
|
43
|
+
execution_context: normalize_context(execution_context),
|
|
44
|
+
thread_id: Validation.integer(thread_id, 'thread_id', allow_nil: true),
|
|
45
|
+
fiber_id: Validation.integer(fiber_id, 'fiber_id', allow_nil: true),
|
|
46
|
+
measurements: normalize_measurements(measurements)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def normalize_location(value)
|
|
53
|
+
return value if value.nil? || value.is_a?(Location)
|
|
54
|
+
|
|
55
|
+
raise RuntimeContractError, 'location must be a FiberAudit::Runtime::Location or nil'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_context(value)
|
|
59
|
+
normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
|
|
60
|
+
return normalized if Context::ALL.include?(normalized)
|
|
61
|
+
|
|
62
|
+
raise RuntimeContractError, "execution_context is invalid: #{value.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_measurements(value)
|
|
66
|
+
raise RuntimeContractError, 'measurements must be a Hash' unless value.is_a?(Hash)
|
|
67
|
+
if value.size > Event::MAX_MEASUREMENTS
|
|
68
|
+
raise RuntimeContractError, "measurements must contain at most #{Event::MAX_MEASUREMENTS} entries"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
value.each_with_object({}) do |(key, measurement), normalized|
|
|
72
|
+
name = Validation.identifier(key, 'measurement key').to_s.freeze
|
|
73
|
+
raise RuntimeContractError, "duplicate normalized measurement key: #{name}" if normalized.key?(name)
|
|
74
|
+
unless measurement.nil? || measurement == true || measurement == false ||
|
|
75
|
+
(measurement.is_a?(Numeric) && measurement.finite?)
|
|
76
|
+
raise RuntimeContractError, "measurement #{name} must be a finite number, Boolean, or nil"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
normalized[name] = measurement
|
|
80
|
+
end.freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Event.const_set(:MAX_MEASUREMENTS, 32)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../execution_context'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
# Fiber-local execution context stack.
|
|
8
|
+
# Uses fiber instance variables for isolation without Thread#[] visibility.
|
|
9
|
+
# PID-aware to handle fork correctly.
|
|
10
|
+
module ExecutionContext
|
|
11
|
+
MAX_DEPTH = 32
|
|
12
|
+
IVAR_KEY = :@__fiber_audit_execution_context__
|
|
13
|
+
IVAR_PID_KEY = :@__fiber_audit_execution_context_pid__
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def current
|
|
17
|
+
state = current_state
|
|
18
|
+
return Context::UNKNOWN unless state
|
|
19
|
+
|
|
20
|
+
state[:stack].last || Context::UNKNOWN
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with(context)
|
|
24
|
+
normalized = validate_context(context)
|
|
25
|
+
state = ensure_state
|
|
26
|
+
return yield if state[:stack].size >= MAX_DEPTH
|
|
27
|
+
|
|
28
|
+
state[:stack].push(normalized)
|
|
29
|
+
begin
|
|
30
|
+
yield
|
|
31
|
+
ensure
|
|
32
|
+
state[:stack].pop
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reset!
|
|
37
|
+
fiber = Fiber.current
|
|
38
|
+
fiber.remove_instance_variable(IVAR_KEY) if fiber.instance_variable_defined?(IVAR_KEY)
|
|
39
|
+
fiber.remove_instance_variable(IVAR_PID_KEY) if fiber.instance_variable_defined?(IVAR_PID_KEY)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def after_fork!
|
|
43
|
+
reset!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def current_state
|
|
49
|
+
fiber = Fiber.current
|
|
50
|
+
return nil unless fiber.instance_variable_defined?(IVAR_KEY)
|
|
51
|
+
|
|
52
|
+
pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
|
|
53
|
+
return nil unless pid == Process.pid
|
|
54
|
+
|
|
55
|
+
{ stack: fiber.instance_variable_get(IVAR_KEY), pid: pid }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ensure_state
|
|
59
|
+
fiber = Fiber.current
|
|
60
|
+
pid = Process.pid
|
|
61
|
+
|
|
62
|
+
if fiber.instance_variable_defined?(IVAR_KEY)
|
|
63
|
+
stored_pid = fiber.instance_variable_defined?(IVAR_PID_KEY) ? fiber.instance_variable_get(IVAR_PID_KEY) : nil
|
|
64
|
+
return { stack: fiber.instance_variable_get(IVAR_KEY), pid: pid } if stored_pid == pid
|
|
65
|
+
|
|
66
|
+
# PID mismatch - reset
|
|
67
|
+
reset!
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
stack = []
|
|
71
|
+
fiber.instance_variable_set(IVAR_KEY, stack)
|
|
72
|
+
fiber.instance_variable_set(IVAR_PID_KEY, pid)
|
|
73
|
+
{ stack: stack, pid: pid }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_context(value)
|
|
77
|
+
unless value.is_a?(Symbol) || value.is_a?(String)
|
|
78
|
+
raise RuntimeContractError, 'execution_context must be a Symbol or String'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
normalized = value.is_a?(Symbol) ? value : value.to_sym
|
|
82
|
+
return normalized if Context::ALL.include?(normalized)
|
|
83
|
+
|
|
84
|
+
raise RuntimeContractError, 'execution_context is invalid'
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'clock'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
# Scheduler-owned progress fiber observed by the watchdog thread.
|
|
8
|
+
class Heartbeat
|
|
9
|
+
Snapshot = Data.define(:sequence, :last_progress_ns, :thread_id, :fiber_id, :started, :stop_requested)
|
|
10
|
+
|
|
11
|
+
attr_reader :owner_thread
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
interval_ns:,
|
|
15
|
+
clock: Clock.new,
|
|
16
|
+
owner_thread: Thread.current,
|
|
17
|
+
on_tick: ->(_heartbeat) {},
|
|
18
|
+
on_error: ->(_heartbeat, _error) {}
|
|
19
|
+
)
|
|
20
|
+
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless clock.is_a?(Clock)
|
|
21
|
+
unless interval_ns.is_a?(Integer) && interval_ns.positive?
|
|
22
|
+
raise RuntimeContractError, 'interval_ns must be a positive Integer'
|
|
23
|
+
end
|
|
24
|
+
raise RuntimeContractError, 'owner_thread must be a Thread' unless owner_thread.is_a?(Thread)
|
|
25
|
+
raise RuntimeContractError, 'on_tick must respond to call' unless on_tick.respond_to?(:call)
|
|
26
|
+
raise RuntimeContractError, 'on_error must respond to call' unless on_error.respond_to?(:call)
|
|
27
|
+
|
|
28
|
+
@clock = clock
|
|
29
|
+
@interval_ns = interval_ns
|
|
30
|
+
@owner_thread = owner_thread
|
|
31
|
+
@on_tick = on_tick
|
|
32
|
+
@on_error = on_error
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@sequence = 0
|
|
35
|
+
@last_progress_ns = nil
|
|
36
|
+
@fiber_id = nil
|
|
37
|
+
@started = false
|
|
38
|
+
@start_requested = false
|
|
39
|
+
@stop_requested = false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start(schedule: Fiber.method(:schedule), sleeper: Kernel.method(:sleep))
|
|
43
|
+
raise RuntimeContractError, 'schedule must respond to call' unless schedule.respond_to?(:call)
|
|
44
|
+
raise RuntimeContractError, 'sleeper must respond to call' unless sleeper.respond_to?(:call)
|
|
45
|
+
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
return self if @start_requested
|
|
48
|
+
|
|
49
|
+
@start_requested = true
|
|
50
|
+
end
|
|
51
|
+
schedule.call { run(sleeper) }
|
|
52
|
+
self
|
|
53
|
+
rescue StandardError
|
|
54
|
+
@mutex.synchronize { @start_requested = false }
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tick
|
|
59
|
+
now_ns = @clock.monotonic_ns
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
@sequence += 1
|
|
62
|
+
@last_progress_ns = now_ns
|
|
63
|
+
@fiber_id ||= Fiber.current.object_id
|
|
64
|
+
@started = true
|
|
65
|
+
end
|
|
66
|
+
@on_tick.call(self)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def request_stop
|
|
71
|
+
@mutex.synchronize { @stop_requested = true }
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def snapshot
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
Snapshot.new(
|
|
78
|
+
sequence: @sequence,
|
|
79
|
+
last_progress_ns: @last_progress_ns,
|
|
80
|
+
thread_id: owner_thread.object_id,
|
|
81
|
+
fiber_id: @fiber_id,
|
|
82
|
+
started: @started,
|
|
83
|
+
stop_requested: @stop_requested
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def started?
|
|
89
|
+
@mutex.synchronize { @started }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def stop_requested?
|
|
93
|
+
@mutex.synchronize { @stop_requested }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def run(sleeper)
|
|
99
|
+
tick
|
|
100
|
+
loop do
|
|
101
|
+
break if stop_requested?
|
|
102
|
+
|
|
103
|
+
sleeper.call(@interval_ns.fdiv(1_000_000_000))
|
|
104
|
+
break if stop_requested?
|
|
105
|
+
|
|
106
|
+
tick
|
|
107
|
+
end
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
@on_error.call(self, e)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -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
|