hyperprobe-agent 1.2.27.pre.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 +7 -0
- data/LICENSE +13 -0
- data/README.md +157 -0
- data/lib/hyperprobe/agent.rb +438 -0
- data/lib/hyperprobe/core/broker.rb +213 -0
- data/lib/hyperprobe/core/evaluator.rb +128 -0
- data/lib/hyperprobe/core/logger.rb +177 -0
- data/lib/hyperprobe/core/monitoring_engine.rb +683 -0
- data/lib/hyperprobe/core/quota.rb +111 -0
- data/lib/hyperprobe/core/safe_ast_validator.rb +91 -0
- data/lib/hyperprobe/core/safety.rb +179 -0
- data/lib/hyperprobe/core/serializer.rb +425 -0
- data/lib/hyperprobe/core/trace_extractor.rb +158 -0
- data/lib/hyperprobe/lambda.rb +90 -0
- data/lib/hyperprobe/protos/agent_pb.rb +33 -0
- data/lib/hyperprobe/protos/agent_services_pb.rb +31 -0
- data/lib/hyperprobe/protos.rb +14 -0
- data/lib/hyperprobe/railtie.rb +16 -0
- data/lib/hyperprobe/version.rb +5 -0
- data/lib/hyperprobe-agent.rb +3 -0
- data/lib/hyperprobe.rb +153 -0
- metadata +149 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HyperProbe
|
|
4
|
+
module Core
|
|
5
|
+
class TokenBucket
|
|
6
|
+
attr_reader :capacity, :tokens
|
|
7
|
+
|
|
8
|
+
def initialize(capacity, refill_rate_per_sec)
|
|
9
|
+
@capacity = capacity.to_f
|
|
10
|
+
@refill_rate = refill_rate_per_sec.to_f / 1000.0 # tokens per millisecond
|
|
11
|
+
@tokens = @capacity
|
|
12
|
+
@last_refill = monotonic_ms
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def try_consume(count = 1)
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
refill
|
|
19
|
+
if @tokens >= count
|
|
20
|
+
@tokens -= count
|
|
21
|
+
true
|
|
22
|
+
else
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reserve(count)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
refill
|
|
31
|
+
if @tokens >= count
|
|
32
|
+
@tokens -= count
|
|
33
|
+
BandwidthReservation.new(self, count)
|
|
34
|
+
else
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def release_tokens(count)
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@tokens = [@capacity, @tokens + count].min
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def refill
|
|
49
|
+
now = monotonic_ms
|
|
50
|
+
delta = now - @last_refill
|
|
51
|
+
amount = delta * @refill_rate
|
|
52
|
+
@tokens = [@capacity, @tokens + amount].min
|
|
53
|
+
@last_refill = now
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def monotonic_ms
|
|
57
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class BandwidthReservation
|
|
62
|
+
attr_reader :tokens
|
|
63
|
+
|
|
64
|
+
def initialize(bucket, tokens)
|
|
65
|
+
@bucket = bucket
|
|
66
|
+
@tokens = tokens
|
|
67
|
+
@committed = false
|
|
68
|
+
@released = false
|
|
69
|
+
@mutex = Mutex.new
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def commit
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
@committed = true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def release
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
return if @committed || @released
|
|
81
|
+
|
|
82
|
+
@released = true
|
|
83
|
+
@bucket.release_tokens(@tokens)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class QuotaManager
|
|
89
|
+
def initialize(hits_per_sec = 10, bytes_per_sec = 200 * 1024)
|
|
90
|
+
@eval_bucket = TokenBucket.new(hits_per_sec, hits_per_sec)
|
|
91
|
+
@bandwidth_bucket = TokenBucket.new(bytes_per_sec, bytes_per_sec)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def can_evaluate?
|
|
95
|
+
@eval_bucket.try_consume(1)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
alias can_evaluate can_evaluate?
|
|
99
|
+
|
|
100
|
+
def can_send?(bytes)
|
|
101
|
+
@bandwidth_bucket.try_consume(bytes)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
alias can_send can_send?
|
|
105
|
+
|
|
106
|
+
def reserve_bandwidth(bytes)
|
|
107
|
+
@bandwidth_bucket.reserve(bytes)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ripper'
|
|
4
|
+
|
|
5
|
+
module HyperProbe
|
|
6
|
+
module Core
|
|
7
|
+
class SafeASTValidator
|
|
8
|
+
MAX_EXPRESSION_LENGTH = 256
|
|
9
|
+
CACHE_CAPACITY = 200
|
|
10
|
+
|
|
11
|
+
FORBIDDEN_OPERATORS = %w[
|
|
12
|
+
= += -= *= /= %= << >> |= &= ^=
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
FORBIDDEN_IDENTIFIERS = %w[
|
|
16
|
+
system exec spawn fork exit exit! abort
|
|
17
|
+
eval instance_eval class_eval module_eval
|
|
18
|
+
send __send__ public_send define_method
|
|
19
|
+
const_set const_get instance_variable_set remove_instance_variable
|
|
20
|
+
delete delete! delete_all destroy destroy!
|
|
21
|
+
update update! update_all save save! create create!
|
|
22
|
+
truncate drop clear write require load
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
FORBIDDEN_CONSTANTS = %w[
|
|
26
|
+
File Dir IO FileUtils Socket TCPSocket UDPSocket Net
|
|
27
|
+
Process Kernel ObjectSpace GC System
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
@cache = {}
|
|
31
|
+
@cache_mutex = Mutex.new
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
def validate!(expression)
|
|
35
|
+
return if expression.nil? || expression.strip.empty?
|
|
36
|
+
|
|
37
|
+
clean_expr = expression.strip
|
|
38
|
+
if clean_expr.length > MAX_EXPRESSION_LENGTH
|
|
39
|
+
raise SecurityError, "Expression is too long (max #{MAX_EXPRESSION_LENGTH} characters)."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@cache_mutex.synchronize do
|
|
43
|
+
return if @cache[clean_expr]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
tokens = Ripper.lex(clean_expr)
|
|
47
|
+
|
|
48
|
+
tokens.each_with_index do |tok, idx|
|
|
49
|
+
_pos, type, text, _state = tok
|
|
50
|
+
|
|
51
|
+
# 1. Reject Shell Backtick / Command Execution (`...` or %x{...})
|
|
52
|
+
if type == :on_backtick || type == :on_xstring_beg
|
|
53
|
+
raise SecurityError, 'Shell command execution is strictly forbidden in probe expressions.'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 2. Reject Mutating Operators and Assignments (=, +=, -=, <<, etc.)
|
|
57
|
+
if type == :on_op && FORBIDDEN_OPERATORS.include?(text)
|
|
58
|
+
raise SecurityError, "Assignments and state mutations ('#{text}') are strictly forbidden in probe expressions."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 3. Reject Forbidden System, File, and Metaprogramming Identifiers
|
|
62
|
+
if (type == :on_ident || type == :on_kw) && FORBIDDEN_IDENTIFIERS.include?(text)
|
|
63
|
+
raise SecurityError, "Method or function invocation '#{text}()' is strictly forbidden during probe evaluation."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 4. Reject Dangerous Class Invocations (File.delete, Net::HTTP, etc.)
|
|
67
|
+
if type == :on_const && FORBIDDEN_CONSTANTS.include?(text)
|
|
68
|
+
# Check if followed by a method call like File.read or Net::HTTP.get
|
|
69
|
+
next_tokens = tokens[(idx + 1)..]
|
|
70
|
+
has_method_call = next_tokens&.any? { |t| t[1] == :on_period || t[1] == :on_op && t[2] == '::' }
|
|
71
|
+
if has_method_call
|
|
72
|
+
raise SecurityError, "Access to system constant '#{text}' is strictly forbidden in probe expressions."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
@cache_mutex.synchronize do
|
|
78
|
+
@cache.shift if @cache.size >= CACHE_CAPACITY
|
|
79
|
+
@cache[clean_expr] = true
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def reset_cache!
|
|
84
|
+
@cache_mutex.synchronize do
|
|
85
|
+
@cache.clear
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HyperProbe
|
|
4
|
+
module Core
|
|
5
|
+
module AgentHealth
|
|
6
|
+
GREEN = :GREEN
|
|
7
|
+
YELLOW = :YELLOW
|
|
8
|
+
RED = :RED
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class SafetyMonitor
|
|
12
|
+
SAMPLE_INTERVAL_SECONDS = 0.1
|
|
13
|
+
LAG_WINDOW_SIZE = 10
|
|
14
|
+
LAG_YELLOW_BREACHES = 4
|
|
15
|
+
LAG_RED_BREACHES = 7
|
|
16
|
+
SEVERE_LAG_WINDOW_SIZE = 5
|
|
17
|
+
SEVERE_LAG_RED_BREACHES = 3
|
|
18
|
+
SEVERE_LAG_MULTIPLIER = 4.0
|
|
19
|
+
|
|
20
|
+
attr_reader :health, :last_thread_lag_ms, :cumulative_pause_time, :max_lag_ms, :pause_budget_ms
|
|
21
|
+
|
|
22
|
+
def initialize(on_state_change, max_lag_ms: 50.0, pause_budget_ms: 15.0, is_ephemeral_lambda: false)
|
|
23
|
+
@on_state_change = on_state_change
|
|
24
|
+
@max_lag_ms = max_lag_ms.to_f
|
|
25
|
+
@pause_budget_ms = pause_budget_ms.to_f
|
|
26
|
+
@is_ephemeral_lambda = is_ephemeral_lambda
|
|
27
|
+
|
|
28
|
+
@health = AgentHealth::GREEN
|
|
29
|
+
@last_heartbeat = monotonic_ms
|
|
30
|
+
@last_thread_lag_ms = 0.0
|
|
31
|
+
@thread_lag_samples = []
|
|
32
|
+
@cumulative_pause_time = 0.0
|
|
33
|
+
@last_window_reset = monotonic_ms
|
|
34
|
+
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@notification_mutex = Mutex.new
|
|
37
|
+
@running = false
|
|
38
|
+
@thread = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def start
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
return if @running
|
|
44
|
+
|
|
45
|
+
@running = true
|
|
46
|
+
@last_heartbeat = monotonic_ms
|
|
47
|
+
@last_window_reset = monotonic_ms
|
|
48
|
+
|
|
49
|
+
@thread = Thread.new { monitor_loop }
|
|
50
|
+
@thread.name = 'hyperprobe-safety' if @thread.respond_to?(:name=)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stop
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@running = false
|
|
57
|
+
end
|
|
58
|
+
@thread&.wakeup if @thread&.alive?
|
|
59
|
+
@thread&.join(1.0) rescue nil
|
|
60
|
+
@thread = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def report_pause_duration(ms)
|
|
64
|
+
callback_info = nil
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@cumulative_pause_time += ms.to_f
|
|
67
|
+
callback_info = evaluate_health_locked
|
|
68
|
+
end
|
|
69
|
+
trigger_callback(callback_info) if callback_info
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def record_thread_lag(thread_lag_ms)
|
|
73
|
+
callback_info = nil
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
record_thread_lag_locked(thread_lag_ms)
|
|
76
|
+
callback_info = evaluate_health_locked
|
|
77
|
+
end
|
|
78
|
+
trigger_callback(callback_info) if callback_info
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def get_health
|
|
82
|
+
@mutex.synchronize { @health }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def monitor_loop
|
|
88
|
+
while @running
|
|
89
|
+
wait_started = monotonic_ms
|
|
90
|
+
sleep SAMPLE_INTERVAL_SECONDS
|
|
91
|
+
break unless @running
|
|
92
|
+
|
|
93
|
+
callback_info = nil
|
|
94
|
+
@mutex.synchronize do
|
|
95
|
+
now = monotonic_ms
|
|
96
|
+
# Expected interval is 100ms; excess time is lag/jitter
|
|
97
|
+
thread_lag_ms = [0.0, now - wait_started - (SAMPLE_INTERVAL_SECONDS * 1000.0)].max
|
|
98
|
+
record_thread_lag_locked(thread_lag_ms)
|
|
99
|
+
|
|
100
|
+
# Reset pause budget window every second
|
|
101
|
+
if now - @last_window_reset > 1000.0
|
|
102
|
+
@cumulative_pause_time = 0.0
|
|
103
|
+
@last_window_reset = now
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
callback_info = evaluate_health_locked
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
trigger_callback(callback_info) if callback_info
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record_thread_lag_locked(lag_ms)
|
|
114
|
+
clean_lag = [0.0, lag_ms.to_f].max
|
|
115
|
+
@last_thread_lag_ms = clean_lag
|
|
116
|
+
@thread_lag_samples << clean_lag
|
|
117
|
+
@thread_lag_samples.shift while @thread_lag_samples.length > LAG_WINDOW_SIZE
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def evaluate_health_locked
|
|
121
|
+
prev_health = @health
|
|
122
|
+
reason = nil
|
|
123
|
+
|
|
124
|
+
if @is_ephemeral_lambda
|
|
125
|
+
@health = AgentHealth::GREEN
|
|
126
|
+
return nil if prev_health == AgentHealth::GREEN
|
|
127
|
+
|
|
128
|
+
return [@health, 'System stabilized in Lambda mode.']
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
recent_lags = @thread_lag_samples.dup
|
|
132
|
+
lag_breaches = recent_lags.count { |lag| lag > @max_lag_ms }
|
|
133
|
+
severe_lag_ms = @max_lag_ms * SEVERE_LAG_MULTIPLIER
|
|
134
|
+
severe_window = recent_lags.last(SEVERE_LAG_WINDOW_SIZE)
|
|
135
|
+
severe_lag_breaches = severe_window.count { |lag| lag > severe_lag_ms }
|
|
136
|
+
|
|
137
|
+
if severe_lag_breaches >= SEVERE_LAG_RED_BREACHES
|
|
138
|
+
@health = AgentHealth::RED
|
|
139
|
+
reason = "Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
|
|
140
|
+
elsif lag_breaches >= LAG_RED_BREACHES
|
|
141
|
+
@health = AgentHealth::RED
|
|
142
|
+
reason = "Execution Thread Lag exceeded #{@max_lag_ms.round(1)}ms in #{lag_breaches}/#{LAG_WINDOW_SIZE} recent readings"
|
|
143
|
+
elsif @cumulative_pause_time > @pause_budget_ms
|
|
144
|
+
@health = AgentHealth::RED
|
|
145
|
+
reason = "Cumulative Pause Budget (#{@cumulative_pause_time.round(1)}ms) exceeded limit (#{@pause_budget_ms.round(1)}ms)"
|
|
146
|
+
elsif severe_lag_breaches.positive?
|
|
147
|
+
@health = AgentHealth::YELLOW
|
|
148
|
+
reason = "Moderate impact: Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
|
|
149
|
+
elsif lag_breaches >= LAG_YELLOW_BREACHES
|
|
150
|
+
@health = AgentHealth::YELLOW
|
|
151
|
+
reason = "Moderate impact: Execution Thread Lag exceeded #{@max_lag_ms.round(1)}ms in #{lag_breaches}/#{LAG_WINDOW_SIZE} recent readings"
|
|
152
|
+
elsif @cumulative_pause_time > (@pause_budget_ms / 2.0)
|
|
153
|
+
@health = AgentHealth::YELLOW
|
|
154
|
+
reason = "Moderate impact: Cumulative Pause Budget (#{@cumulative_pause_time.round(1)}ms) reached 50% of limit (#{@pause_budget_ms.round(1)}ms)"
|
|
155
|
+
else
|
|
156
|
+
@health = AgentHealth::GREEN
|
|
157
|
+
reason = 'System stabilized.' if prev_health != AgentHealth::GREEN
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
return unless @health != prev_health
|
|
161
|
+
|
|
162
|
+
[@health, reason]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def trigger_callback(callback_info)
|
|
166
|
+
health, reason = callback_info
|
|
167
|
+
@notification_mutex.synchronize do
|
|
168
|
+
@on_state_change&.call(health, reason)
|
|
169
|
+
end
|
|
170
|
+
rescue StandardError
|
|
171
|
+
# Never crash application thread or safety monitor on callback error
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def monotonic_ms
|
|
175
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|