hyperprobe-agent 1.2.27.pre.3-java
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 +386 -0
- data/lib/hyperprobe/agent.rb +537 -0
- data/lib/hyperprobe/core/broker.rb +183 -0
- data/lib/hyperprobe/core/evaluator.rb +418 -0
- data/lib/hyperprobe/core/lexical_scope.rb +222 -0
- data/lib/hyperprobe/core/logger.rb +198 -0
- data/lib/hyperprobe/core/monitoring_engine.rb +857 -0
- data/lib/hyperprobe/core/quota.rb +111 -0
- data/lib/hyperprobe/core/safe_ast_validator.rb +170 -0
- data/lib/hyperprobe/core/safety.rb +256 -0
- data/lib/hyperprobe/core/serializer.rb +437 -0
- data/lib/hyperprobe/core/trace_extractor.rb +158 -0
- data/lib/hyperprobe/core/transports/java_grpc.rb +57 -0
- data/lib/hyperprobe/jars/hyperprobe-grpc.jar +0 -0
- data/lib/hyperprobe/lambda.rb +92 -0
- data/lib/hyperprobe/protos/agent_descriptor.rb +7 -0
- data/lib/hyperprobe/protos/agent_pb.rb +33 -0
- data/lib/hyperprobe/protos/agent_services_pb.rb +31 -0
- data/lib/hyperprobe/protos/java_messages.rb +199 -0
- data/lib/hyperprobe/protos.rb +14 -0
- data/lib/hyperprobe/railtie.rb +22 -0
- data/lib/hyperprobe/version.rb +5 -0
- data/lib/hyperprobe-agent.rb +3 -0
- data/lib/hyperprobe.rb +282 -0
- data/transport/README.md +98 -0
- data/transport/THIRD_PARTY_NOTICES.md +57 -0
- data/transport/pom.xml +78 -0
- data/transport/src/main/java/co/hyperprobe/transport/GrpcTransport.java +118 -0
- data/transport/src/main/java/co/hyperprobe/transport/ProtoCodec.java +43 -0
- metadata +117 -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,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ripper'
|
|
4
|
+
|
|
5
|
+
module HyperProbe
|
|
6
|
+
module Core
|
|
7
|
+
# This is deliberately a small expression language, not read-only Ruby.
|
|
8
|
+
# Compile the entire tree before executing even a short-circuited branch.
|
|
9
|
+
class SafeASTValidator
|
|
10
|
+
MAX_EXPRESSION_LENGTH = 256
|
|
11
|
+
MAX_NODES = 128
|
|
12
|
+
MAX_DEPTH = 32
|
|
13
|
+
BINARY_OPERATORS = %i[+ - * / % == != < <= > >= && || and or].freeze
|
|
14
|
+
UNARY_OPERATORS = %i[+@ -@ ! not].freeze
|
|
15
|
+
CLASS = Object.instance_method(:class)
|
|
16
|
+
IDENTITY = BasicObject.instance_method(:equal?)
|
|
17
|
+
STRING_SIZE = String.instance_method(:bytesize)
|
|
18
|
+
STRING_SLICE = String.instance_method(:byteslice)
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def validate!(expression)
|
|
22
|
+
parse!(expression)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Copy through a builtin, never dup/clone/to_str hooks on application data.
|
|
27
|
+
def source(expression, limit = MAX_EXPRESSION_LENGTH)
|
|
28
|
+
unless IDENTITY.bind(CLASS.bind(expression).call).call(String)
|
|
29
|
+
raise SecurityError, 'Expression must be an exact builtin String'
|
|
30
|
+
end
|
|
31
|
+
if STRING_SIZE.bind(expression).call > limit
|
|
32
|
+
raise SecurityError, "Expression is too long (max #{limit} bytes)"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
copy = STRING_SLICE.bind(expression).call(0, limit + 1)
|
|
36
|
+
raise SecurityError, "Expression is too long (max #{limit} bytes)" if copy.bytesize > limit
|
|
37
|
+
copy
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse!(expression)
|
|
41
|
+
return [:literal, nil] if IDENTITY.bind(expression).call(nil)
|
|
42
|
+
|
|
43
|
+
text = source(expression)
|
|
44
|
+
return [:literal, nil] if text.strip.empty?
|
|
45
|
+
|
|
46
|
+
tree = Ripper.sexp(text)
|
|
47
|
+
raise ArgumentError, 'Invalid expression syntax' unless tree
|
|
48
|
+
|
|
49
|
+
# Only ordinary quoted strings are supported. Decode their contents
|
|
50
|
+
# ourselves; Ripper returns escaped source, not evaluated strings.
|
|
51
|
+
strings = {}
|
|
52
|
+
quote = nil
|
|
53
|
+
Ripper.lex(text).each do |position, type, token, _state|
|
|
54
|
+
case type
|
|
55
|
+
when :on_tstring_beg
|
|
56
|
+
reject!('string syntax') unless ["'", '"'].include?(token)
|
|
57
|
+
quote = token
|
|
58
|
+
when :on_tstring_content
|
|
59
|
+
strings[position] = decode_string(token, quote)
|
|
60
|
+
when :on_tstring_end
|
|
61
|
+
quote = nil
|
|
62
|
+
when :on_embexpr_beg, :on_embvar, :on_heredoc_beg
|
|
63
|
+
reject!('string interpolation or heredoc')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
compile(tree, strings, [0], 0)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Kept for callers that used to clear the lexical validator's cache.
|
|
71
|
+
# Parsing is bounded and no user-controlled expressions are retained.
|
|
72
|
+
def reset_cache!; end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def reject!(feature)
|
|
77
|
+
raise SecurityError, "Unsupported #{feature}: forbidden in probe expressions"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def decode_string(text, quote)
|
|
81
|
+
reject!('string syntax') unless quote
|
|
82
|
+
text.gsub(/\\(.)/m) do
|
|
83
|
+
char = Regexp.last_match(1)
|
|
84
|
+
if quote == "'"
|
|
85
|
+
["'", '\\'].include?(char) ? char : "\\#{char}"
|
|
86
|
+
else
|
|
87
|
+
escapes = { 'n' => "\n", 'r' => "\r", 't' => "\t", '\\' => '\\', '"' => '"', "'" => "'", '#' => '#' }
|
|
88
|
+
reject!('string escape') unless escapes.key?(char)
|
|
89
|
+
escapes[char]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def compile(node, strings, budget, depth)
|
|
95
|
+
budget[0] += 1
|
|
96
|
+
reject!('expression complexity') if budget[0] > MAX_NODES || depth > MAX_DEPTH
|
|
97
|
+
child = ->(value) { compile(value, strings, budget, depth + 1) }
|
|
98
|
+
|
|
99
|
+
case node[0]
|
|
100
|
+
when :program, :paren
|
|
101
|
+
reject!('multiple statements') unless node[1]&.length == 1
|
|
102
|
+
child.call(node[1][0])
|
|
103
|
+
when :vcall, :var_ref
|
|
104
|
+
token = node[1]
|
|
105
|
+
if token[0] == :@ident
|
|
106
|
+
[:local, token[1]]
|
|
107
|
+
elsif token[0] == :@kw && %w[true false nil].include?(token[1])
|
|
108
|
+
[:literal, { 'true' => true, 'false' => false, 'nil' => nil }[token[1]]]
|
|
109
|
+
else
|
|
110
|
+
reject!('variable or constant access')
|
|
111
|
+
end
|
|
112
|
+
when :@int
|
|
113
|
+
[:literal, Integer(node[1])]
|
|
114
|
+
when :@float
|
|
115
|
+
[:literal, Float(node[1])]
|
|
116
|
+
when :string_literal
|
|
117
|
+
content = node[1]
|
|
118
|
+
reject!('string syntax') unless content[0] == :string_content
|
|
119
|
+
value = content.drop(1).map do |part|
|
|
120
|
+
reject!('string interpolation') unless part[0] == :@tstring_content
|
|
121
|
+
strings.fetch(part[2])
|
|
122
|
+
end.join
|
|
123
|
+
[:literal, value]
|
|
124
|
+
when :symbol_literal
|
|
125
|
+
symbol = node[1]
|
|
126
|
+
reject!('symbol syntax') unless symbol[0] == :symbol && %i[@ident @op @kw].include?(symbol[1][0])
|
|
127
|
+
[:literal, symbol[1][1].to_sym]
|
|
128
|
+
when :@label
|
|
129
|
+
[:literal, node[1][0...-1].to_sym]
|
|
130
|
+
when :array
|
|
131
|
+
[:array, (node[1] || []).map { |item| child.call(item) }]
|
|
132
|
+
when :hash
|
|
133
|
+
pairs = node[1]
|
|
134
|
+
reject!('hash syntax') if pairs && pairs[0] != :assoclist_from_args
|
|
135
|
+
[:hash, (pairs ? pairs[1] : []).map do |pair|
|
|
136
|
+
reject!('hash expansion') unless pair[0] == :assoc_new
|
|
137
|
+
[child.call(pair[1]), child.call(pair[2])]
|
|
138
|
+
end]
|
|
139
|
+
when :aref
|
|
140
|
+
args = node[2]
|
|
141
|
+
unless args && args[0] == :args_add_block && args[2] == false && args[1].length == 1
|
|
142
|
+
reject!('subscript arguments')
|
|
143
|
+
end
|
|
144
|
+
[:lookup, child.call(node[1]), child.call(args[1][0])]
|
|
145
|
+
when :call
|
|
146
|
+
unless (node[2][0] == :@period || node[2] == :'.') && node[3][0] == :@ident && %w[length size].include?(node[3][1])
|
|
147
|
+
reject!('method call')
|
|
148
|
+
end
|
|
149
|
+
[:length, child.call(node[1])]
|
|
150
|
+
when :method_add_arg
|
|
151
|
+
unless node[1][0] == :call && node[2] == [:arg_paren, nil]
|
|
152
|
+
reject!('method arguments or function call')
|
|
153
|
+
end
|
|
154
|
+
child.call(node[1])
|
|
155
|
+
when :binary
|
|
156
|
+
reject!('operator') unless BINARY_OPERATORS.include?(node[2])
|
|
157
|
+
[:binary, node[2], child.call(node[1]), child.call(node[3])]
|
|
158
|
+
when :unary
|
|
159
|
+
reject!('unary operator') unless UNARY_OPERATORS.include?(node[1])
|
|
160
|
+
[:unary, node[1], child.call(node[2])]
|
|
161
|
+
when :ifop
|
|
162
|
+
[:conditional, child.call(node[1]), child.call(node[2]), child.call(node[3])]
|
|
163
|
+
else
|
|
164
|
+
reject!("syntax #{node[0]}")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require 'java' if RUBY_ENGINE == 'jruby'
|
|
5
|
+
|
|
6
|
+
module HyperProbe
|
|
7
|
+
module Core
|
|
8
|
+
module AgentHealth
|
|
9
|
+
GREEN = :GREEN
|
|
10
|
+
YELLOW = :YELLOW
|
|
11
|
+
RED = :RED
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class SafetyMonitor
|
|
15
|
+
SAMPLE_INTERVAL_SECONDS = 0.1
|
|
16
|
+
LAG_WINDOW_SIZE = 10
|
|
17
|
+
LAG_YELLOW_BREACHES = 4
|
|
18
|
+
LAG_RED_BREACHES = 7
|
|
19
|
+
SEVERE_LAG_WINDOW_SIZE = 5
|
|
20
|
+
SEVERE_LAG_RED_BREACHES = 3
|
|
21
|
+
SEVERE_LAG_MULTIPLIER = 4.0
|
|
22
|
+
HEAP_SAMPLE_INTERVAL_MS = 5000.0
|
|
23
|
+
HEAP_YELLOW_FRACTION = 0.15
|
|
24
|
+
HEAP_RED_FRACTION = 0.05
|
|
25
|
+
|
|
26
|
+
attr_reader :health, :last_thread_lag_ms, :cumulative_pause_time, :max_lag_ms, :pause_budget_ms
|
|
27
|
+
|
|
28
|
+
def initialize(on_state_change, max_lag_ms: 50.0, pause_budget_ms: 15.0, is_ephemeral_lambda: false,
|
|
29
|
+
heap_runtime: (RUBY_ENGINE == 'jruby' ? Java::JavaLang::Runtime.get_runtime : nil))
|
|
30
|
+
@on_state_change = on_state_change
|
|
31
|
+
@max_lag_ms = max_lag_ms.to_f
|
|
32
|
+
@pause_budget_ms = pause_budget_ms.to_f
|
|
33
|
+
@is_ephemeral_lambda = is_ephemeral_lambda
|
|
34
|
+
@heap_runtime = heap_runtime
|
|
35
|
+
@heap_available_fraction = 1.0
|
|
36
|
+
|
|
37
|
+
@health = AgentHealth::GREEN
|
|
38
|
+
@last_heartbeat = monotonic_ms
|
|
39
|
+
@last_thread_lag_ms = 0.0
|
|
40
|
+
@thread_lag_samples = []
|
|
41
|
+
@cumulative_pause_time = 0.0
|
|
42
|
+
@last_window_reset = monotonic_ms
|
|
43
|
+
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@notification_mutex = Monitor.new
|
|
46
|
+
@transition_generation = 0
|
|
47
|
+
@running = false
|
|
48
|
+
@thread = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def start
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
return if @running && @thread&.alive?
|
|
54
|
+
|
|
55
|
+
@running = true
|
|
56
|
+
@last_heartbeat = monotonic_ms
|
|
57
|
+
@last_window_reset = monotonic_ms
|
|
58
|
+
@last_heap_sample = @last_window_reset
|
|
59
|
+
|
|
60
|
+
@thread = Thread.new { monitor_loop }
|
|
61
|
+
@thread.name = 'hyperprobe-safety' if @thread.respond_to?(:name=)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def after_fork
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
@running = false
|
|
68
|
+
@thread = nil
|
|
69
|
+
end
|
|
70
|
+
start
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def stop
|
|
74
|
+
thread = @mutex.synchronize do
|
|
75
|
+
@running = false
|
|
76
|
+
current = @thread
|
|
77
|
+
@thread = nil
|
|
78
|
+
current
|
|
79
|
+
end
|
|
80
|
+
return if !thread || thread == Thread.current
|
|
81
|
+
|
|
82
|
+
thread.kill
|
|
83
|
+
thread.join(0.1)
|
|
84
|
+
rescue Exception
|
|
85
|
+
# Stop is agent-owned cleanup, including when called from a trap.
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def report_pause_duration(ms)
|
|
89
|
+
callback_info = nil
|
|
90
|
+
return unless @mutex.try_lock
|
|
91
|
+
begin
|
|
92
|
+
@cumulative_pause_time += ms.to_f
|
|
93
|
+
callback_info = evaluate_health_locked
|
|
94
|
+
# A live monitor delivers transitions off the application thread.
|
|
95
|
+
@pending_notification = callback_info if @running && callback_info
|
|
96
|
+
ensure
|
|
97
|
+
@mutex.unlock
|
|
98
|
+
end
|
|
99
|
+
trigger_callback(callback_info) if callback_info && !@running
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def record_thread_lag(thread_lag_ms)
|
|
103
|
+
callback_info = nil
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
record_thread_lag_locked(thread_lag_ms)
|
|
106
|
+
callback_info = evaluate_health_locked
|
|
107
|
+
end
|
|
108
|
+
trigger_callback(callback_info) if callback_info
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def get_health
|
|
112
|
+
@mutex.synchronize { @health }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def with_health(expected)
|
|
116
|
+
@notification_mutex.synchronize do
|
|
117
|
+
return unless @mutex.synchronize { @health == expected }
|
|
118
|
+
|
|
119
|
+
yield
|
|
120
|
+
end
|
|
121
|
+
rescue Exception
|
|
122
|
+
# Serialize cooldown actions with transition delivery, not state updates.
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def monitor_loop
|
|
128
|
+
while @running
|
|
129
|
+
wait_started = monotonic_ms
|
|
130
|
+
sleep SAMPLE_INTERVAL_SECONDS
|
|
131
|
+
break unless @running
|
|
132
|
+
|
|
133
|
+
callback_info = nil
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
now = monotonic_ms
|
|
136
|
+
# Expected interval is 100ms; excess time is lag/jitter
|
|
137
|
+
thread_lag_ms = [0.0, now - wait_started - (SAMPLE_INTERVAL_SECONDS * 1000.0)].max
|
|
138
|
+
record_thread_lag_locked(thread_lag_ms)
|
|
139
|
+
|
|
140
|
+
if @heap_runtime && !@is_ephemeral_lambda && now - @last_heap_sample >= HEAP_SAMPLE_INTERVAL_MS
|
|
141
|
+
sample_heap_locked
|
|
142
|
+
@last_heap_sample = now
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Reset pause budget window every second
|
|
146
|
+
if now - @last_window_reset > 1000.0
|
|
147
|
+
@cumulative_pause_time = 0.0
|
|
148
|
+
@last_window_reset = now
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
callback_info = evaluate_health_locked || @pending_notification
|
|
152
|
+
@pending_notification = nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
trigger_callback(callback_info) if callback_info
|
|
156
|
+
end
|
|
157
|
+
rescue Exception
|
|
158
|
+
@running = false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def record_thread_lag_locked(lag_ms)
|
|
162
|
+
clean_lag = [0.0, lag_ms.to_f].max
|
|
163
|
+
@last_thread_lag_ms = clean_lag
|
|
164
|
+
@thread_lag_samples << clean_lag
|
|
165
|
+
@thread_lag_samples.shift while @thread_lag_samples.length > LAG_WINDOW_SIZE
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def sample_heap_locked
|
|
169
|
+
maximum = @heap_runtime.max_memory.to_f
|
|
170
|
+
return unless maximum.positive?
|
|
171
|
+
|
|
172
|
+
committed = @heap_runtime.total_memory
|
|
173
|
+
free = @heap_runtime.free_memory
|
|
174
|
+
@heap_available_fraction = (free + maximum - committed) / maximum
|
|
175
|
+
rescue Exception
|
|
176
|
+
# Preserve the last heap health and retry at the next sample.
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def evaluate_health_locked
|
|
180
|
+
prev_health = @health
|
|
181
|
+
reason = nil
|
|
182
|
+
|
|
183
|
+
if @is_ephemeral_lambda
|
|
184
|
+
@health = AgentHealth::GREEN
|
|
185
|
+
return nil if prev_health == AgentHealth::GREEN
|
|
186
|
+
|
|
187
|
+
@transition_generation += 1
|
|
188
|
+
return [@health, 'System stabilized in Lambda mode.', @transition_generation]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
recent_lags = @thread_lag_samples.dup
|
|
192
|
+
lag_breaches = recent_lags.count { |lag| lag > @max_lag_ms }
|
|
193
|
+
severe_lag_ms = @max_lag_ms * SEVERE_LAG_MULTIPLIER
|
|
194
|
+
severe_window = recent_lags.last(SEVERE_LAG_WINDOW_SIZE)
|
|
195
|
+
severe_lag_breaches = severe_window.count { |lag| lag > severe_lag_ms }
|
|
196
|
+
|
|
197
|
+
if @heap_runtime
|
|
198
|
+
if @heap_available_fraction < HEAP_RED_FRACTION
|
|
199
|
+
@health = AgentHealth::RED
|
|
200
|
+
reason = "JVM heap headroom (#{(@heap_available_fraction * 100).round(1)}%) below 5% of maximum heap"
|
|
201
|
+
elsif @heap_available_fraction < HEAP_YELLOW_FRACTION
|
|
202
|
+
@health = AgentHealth::YELLOW
|
|
203
|
+
reason = "JVM heap headroom (#{(@heap_available_fraction * 100).round(1)}%) below 15% of maximum heap"
|
|
204
|
+
else
|
|
205
|
+
@health = AgentHealth::GREEN
|
|
206
|
+
reason = 'JVM heap headroom stabilized.' if prev_health != AgentHealth::GREEN
|
|
207
|
+
end
|
|
208
|
+
elsif severe_lag_breaches >= SEVERE_LAG_RED_BREACHES
|
|
209
|
+
@health = AgentHealth::RED
|
|
210
|
+
reason = "Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
|
|
211
|
+
elsif lag_breaches >= LAG_RED_BREACHES
|
|
212
|
+
@health = AgentHealth::RED
|
|
213
|
+
reason = "Execution Thread Lag exceeded #{@max_lag_ms.round(1)}ms in #{lag_breaches}/#{LAG_WINDOW_SIZE} recent readings"
|
|
214
|
+
elsif @cumulative_pause_time > @pause_budget_ms
|
|
215
|
+
@health = AgentHealth::RED
|
|
216
|
+
reason = "Cumulative Pause Budget (#{@cumulative_pause_time.round(1)}ms) exceeded limit (#{@pause_budget_ms.round(1)}ms)"
|
|
217
|
+
elsif severe_lag_breaches.positive?
|
|
218
|
+
@health = AgentHealth::YELLOW
|
|
219
|
+
reason = "Moderate impact: Severe Execution Thread Lag exceeded #{severe_lag_ms.round(1)}ms in #{severe_lag_breaches}/#{SEVERE_LAG_WINDOW_SIZE} recent readings"
|
|
220
|
+
elsif lag_breaches >= LAG_YELLOW_BREACHES
|
|
221
|
+
@health = AgentHealth::YELLOW
|
|
222
|
+
reason = "Moderate impact: Execution Thread Lag exceeded #{@max_lag_ms.round(1)}ms in #{lag_breaches}/#{LAG_WINDOW_SIZE} recent readings"
|
|
223
|
+
elsif @cumulative_pause_time > (@pause_budget_ms / 2.0)
|
|
224
|
+
@health = AgentHealth::YELLOW
|
|
225
|
+
reason = "Moderate impact: Cumulative Pause Budget (#{@cumulative_pause_time.round(1)}ms) reached 50% of limit (#{@pause_budget_ms.round(1)}ms)"
|
|
226
|
+
else
|
|
227
|
+
@health = AgentHealth::GREEN
|
|
228
|
+
reason = 'System stabilized.' if prev_health != AgentHealth::GREEN
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
return unless @health != prev_health
|
|
232
|
+
|
|
233
|
+
@transition_generation += 1
|
|
234
|
+
[@health, reason, @transition_generation]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def trigger_callback(callback_info)
|
|
238
|
+
health, reason, generation = callback_info
|
|
239
|
+
@notification_mutex.synchronize do
|
|
240
|
+
# State transitions release @mutex before acquiring the delivery lock.
|
|
241
|
+
# Recheck here so a delayed notification cannot undo a newer one.
|
|
242
|
+
current = @mutex.synchronize { generation == @transition_generation && health == @health }
|
|
243
|
+
return unless current
|
|
244
|
+
|
|
245
|
+
@on_state_change&.call(health, reason)
|
|
246
|
+
end
|
|
247
|
+
rescue Exception
|
|
248
|
+
# Never crash application thread or safety monitor on callback error
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def monotonic_ms
|
|
252
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|