hyperprobe-agent 1.2.27.pre.1 → 1.2.27.pre.3
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/README.md +316 -87
- data/lib/hyperprobe/agent.rb +243 -144
- data/lib/hyperprobe/core/broker.rb +58 -88
- data/lib/hyperprobe/core/evaluator.rb +358 -68
- data/lib/hyperprobe/core/lexical_scope.rb +222 -0
- data/lib/hyperprobe/core/logger.rb +33 -12
- data/lib/hyperprobe/core/monitoring_engine.rb +294 -120
- data/lib/hyperprobe/core/safe_ast_validator.rb +140 -61
- data/lib/hyperprobe/core/safety.rb +92 -15
- data/lib/hyperprobe/core/serializer.rb +354 -342
- data/lib/hyperprobe/core/transports/java_grpc.rb +57 -0
- data/lib/hyperprobe/lambda.rb +44 -42
- data/lib/hyperprobe/protos/agent_descriptor.rb +7 -0
- data/lib/hyperprobe/protos/java_messages.rb +199 -0
- data/lib/hyperprobe/protos.rb +7 -7
- data/lib/hyperprobe/railtie.rb +6 -0
- data/lib/hyperprobe/version.rb +1 -1
- data/lib/hyperprobe.rb +149 -20
- metadata +25 -9
|
@@ -2,18 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
require 'set'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'monitor'
|
|
6
|
+
require 'debug_inspector' if RUBY_ENGINE == 'ruby'
|
|
7
|
+
require 'objspace' if RUBY_ENGINE == 'ruby'
|
|
5
8
|
require_relative 'logger'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
require 'binding_of_caller'
|
|
9
|
-
rescue LoadError
|
|
10
|
-
# binding_of_caller is optional
|
|
11
|
-
end
|
|
9
|
+
require_relative 'evaluator'
|
|
10
|
+
require_relative 'lexical_scope'
|
|
12
11
|
|
|
13
12
|
module HyperProbe
|
|
14
13
|
module Core
|
|
15
14
|
class MonitoringEngine
|
|
16
15
|
DURATION_TTL_SECONDS = 60.0
|
|
16
|
+
MAX_DURATION_ENTRIES = 10_000
|
|
17
|
+
MAX_PROBES = 100
|
|
18
|
+
MAX_STACK_FRAMES = 16
|
|
19
|
+
MAX_CALLER_SCOPES = 256
|
|
20
|
+
MAX_INSPECTOR_STACK_DEPTH = 128
|
|
21
|
+
MAX_CALLER_ISEQS = 32
|
|
22
|
+
MAX_CALLER_ISEQ_BYTES = 16_384
|
|
23
|
+
ISEQ_TO_A = RubyVM::InstructionSequence.instance_method(:to_a) if RUBY_ENGINE == 'ruby'
|
|
24
|
+
ISEQ_EACH_CHILD = RubyVM::InstructionSequence.instance_method(:each_child) if RUBY_ENGINE == 'ruby'
|
|
25
|
+
ISEQ_MEMORY_SIZE = ObjectSpace.method(:memsize_of) if RUBY_ENGINE == 'ruby'
|
|
26
|
+
LOCAL_VARIABLES = Binding.instance_method(:local_variables)
|
|
27
|
+
LOCAL_GET = Binding.instance_method(:local_variable_get)
|
|
28
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
29
|
+
SINGLETON_CLASS = Module.instance_method(:singleton_class?)
|
|
30
|
+
INSTANCE_METHODS = Module.instance_method(:instance_methods)
|
|
31
|
+
INSTANCE_METHOD = Module.instance_method(:instance_method)
|
|
32
|
+
METHODS = Kernel.instance_method(:methods)
|
|
33
|
+
METHOD = Kernel.instance_method(:method)
|
|
17
34
|
|
|
18
35
|
LEVEL_COLORS = {
|
|
19
36
|
'ERROR' => "\e[31m",
|
|
@@ -34,40 +51,59 @@ module HyperProbe
|
|
|
34
51
|
@on_capture = on_capture
|
|
35
52
|
@custom_set_trace_id = custom_set_trace_id
|
|
36
53
|
|
|
37
|
-
@mutex =
|
|
54
|
+
@mutex = Monitor.new
|
|
55
|
+
@path_mutex = Monitor.new
|
|
38
56
|
@active_locations = {} # [resolved_file_path, line] => Array<ActiveProbeContext>
|
|
39
57
|
@probe_map = {} # probe_id => Probe
|
|
40
58
|
@instrumented_files = Set.new
|
|
41
59
|
@path_cache = {}
|
|
60
|
+
@caller_locals_cache = ObjectSpace::WeakMap.new if RUBY_ENGINE == 'ruby'
|
|
42
61
|
@duration_starts = {} # [probe_id, correlation_id] => [start_time_monotonic, created_at_time]
|
|
43
62
|
@next_duration_cleanup = monotonic_time + DURATION_TTL_SECONDS
|
|
44
63
|
|
|
45
64
|
@global_config = {}
|
|
65
|
+
@safe_evaluation = Evaluator.safe_evaluation_enabled?
|
|
46
66
|
@total_hits = 0
|
|
47
67
|
@total_skips = 0
|
|
48
68
|
@is_suspended = false
|
|
49
69
|
@is_active = false
|
|
50
70
|
@closed = false
|
|
71
|
+
@owner_pid = Process.pid
|
|
72
|
+
@generation = -1
|
|
51
73
|
|
|
52
74
|
@log = Logger.get_logger('hyperprobe:inspector')
|
|
53
75
|
@targeted_tracepoints = {} # location_key => TracePoint instance
|
|
76
|
+
# JRuby's compiled LINE site is activated by CALL/RETURN interest.
|
|
77
|
+
# Share one empty activation hook rather than adding CALL to every probe.
|
|
78
|
+
@jruby_call_hook = TracePoint.new(:call) {} if RUBY_PLATFORM.include?('java')
|
|
79
|
+
@stdout_queue = SizedQueue.new(100)
|
|
80
|
+
@stdout_thread = Thread.new do
|
|
81
|
+
loop { $stdout.write(@stdout_queue.pop) }
|
|
82
|
+
rescue Exception
|
|
83
|
+
# A broken or blocked output stream must only affect the agent worker.
|
|
84
|
+
end
|
|
85
|
+
@stdout_thread.name = 'hyperprobe-stdout'
|
|
54
86
|
end
|
|
55
87
|
|
|
56
88
|
def set_global_config(config)
|
|
57
89
|
@mutex.synchronize do
|
|
58
90
|
@global_config = (config || {}).dup
|
|
91
|
+
disabled = @global_config.fetch(:disable_safe_evaluation) { @global_config['disable_safe_evaluation'] }
|
|
92
|
+
@safe_evaluation = Evaluator.safe_evaluation_enabled?(disabled)
|
|
59
93
|
end
|
|
60
94
|
end
|
|
61
95
|
|
|
62
|
-
def set_probes(probes)
|
|
96
|
+
def set_probes(probes, generation: nil)
|
|
63
97
|
@mutex.synchronize do
|
|
64
98
|
return if @closed
|
|
99
|
+
return if generation && generation <= @generation
|
|
100
|
+
@generation = generation if generation
|
|
65
101
|
|
|
66
102
|
new_active_locations = {}
|
|
67
103
|
new_probe_map = {}
|
|
68
104
|
new_instrumented_files = Set.new
|
|
69
105
|
|
|
70
|
-
Array(probes).each do |probe|
|
|
106
|
+
Array(probes).first(MAX_PROBES).each do |probe|
|
|
71
107
|
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
72
108
|
next unless probe_id
|
|
73
109
|
|
|
@@ -80,10 +116,15 @@ module HyperProbe
|
|
|
80
116
|
primary_key = [resolved_path, runtime_line]
|
|
81
117
|
|
|
82
118
|
new_active_locations[primary_key] ||= []
|
|
83
|
-
|
|
119
|
+
probe_type = probe.respond_to?(:type) ? probe.type : probe[:type]
|
|
120
|
+
local_names = nil
|
|
121
|
+
if probe_type_to_i(probe_type) == 1
|
|
122
|
+
previous = @active_locations[primary_key]&.find { |ctx| ctx[:probe].equal?(probe) && !ctx[:is_secondary] }
|
|
123
|
+
local_names = previous ? previous[:local_names] : LexicalScope.locals_for(resolved_path, runtime_line)
|
|
124
|
+
end
|
|
125
|
+
new_active_locations[primary_key] << { probe: probe, is_secondary: false, local_names: local_names }
|
|
84
126
|
new_instrumented_files.add(resolved_path)
|
|
85
127
|
|
|
86
|
-
probe_type = probe.respond_to?(:type) ? probe.type : probe[:type]
|
|
87
128
|
# Duration secondary location
|
|
88
129
|
is_duration = probe_type == 5 || probe_type == :PROBE_TYPE_DURATION
|
|
89
130
|
if is_duration
|
|
@@ -104,6 +145,7 @@ module HyperProbe
|
|
|
104
145
|
@active_locations = new_active_locations
|
|
105
146
|
@probe_map = new_probe_map
|
|
106
147
|
@instrumented_files = new_instrumented_files
|
|
148
|
+
@duration_starts.delete_if { |(id, _), _entry| !new_probe_map.key?(id) }
|
|
107
149
|
|
|
108
150
|
update_tracepoint_state_unlocked
|
|
109
151
|
end
|
|
@@ -138,6 +180,7 @@ module HyperProbe
|
|
|
138
180
|
end
|
|
139
181
|
|
|
140
182
|
def close
|
|
183
|
+
@stdout_thread&.kill
|
|
141
184
|
@mutex.synchronize do
|
|
142
185
|
return if @closed
|
|
143
186
|
|
|
@@ -147,9 +190,21 @@ module HyperProbe
|
|
|
147
190
|
@probe_map.clear
|
|
148
191
|
@instrumented_files.clear
|
|
149
192
|
@duration_starts.clear
|
|
193
|
+
@caller_locals_cache = nil
|
|
150
194
|
end
|
|
151
195
|
end
|
|
152
196
|
|
|
197
|
+
def after_fork
|
|
198
|
+
# The child must not acquire locks or use resources owned by parent threads.
|
|
199
|
+
@closed = true
|
|
200
|
+
@is_suspended = true
|
|
201
|
+
@targeted_tracepoints.each_value { |tp| tp.disable rescue nil }
|
|
202
|
+
@jruby_call_hook&.disable
|
|
203
|
+
@is_active = false
|
|
204
|
+
rescue Exception
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
153
208
|
private
|
|
154
209
|
|
|
155
210
|
def update_tracepoint_state_unlocked
|
|
@@ -158,6 +213,8 @@ module HyperProbe
|
|
|
158
213
|
return
|
|
159
214
|
end
|
|
160
215
|
|
|
216
|
+
@jruby_call_hook&.enable
|
|
217
|
+
|
|
161
218
|
# 1. Remove stale tracepoints
|
|
162
219
|
active_keys = @active_locations.keys.to_set
|
|
163
220
|
@targeted_tracepoints.delete_if do |loc_key, tp|
|
|
@@ -177,51 +234,71 @@ module HyperProbe
|
|
|
177
234
|
@is_active = !@targeted_tracepoints.empty?
|
|
178
235
|
end
|
|
179
236
|
|
|
180
|
-
def
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
next
|
|
185
|
-
|
|
186
|
-
# Check path match
|
|
187
|
-
t_path = @path_cache[t.path] || resolve_filepath(t.path)
|
|
188
|
-
next unless t_path == resolved_path && t.lineno == lineno
|
|
237
|
+
def create_tracepoint_for_location(loc_key, resolved_path, lineno)
|
|
238
|
+
TracePoint.new(:line) do |t|
|
|
239
|
+
next if @is_suspended || @closed || @owner_pid != Process.pid
|
|
240
|
+
# Fast integer check first to minimize overhead on uninstrumented lines
|
|
241
|
+
next unless t.lineno == lineno
|
|
189
242
|
|
|
190
243
|
contexts = nil
|
|
191
|
-
|
|
244
|
+
# Never wait for installation or shutdown on an application thread.
|
|
245
|
+
next unless @mutex.try_enter
|
|
246
|
+
begin
|
|
247
|
+
# Installation resolves symlinks; never resolve filesystem paths here.
|
|
248
|
+
next unless t.path == resolved_path || @path_cache[t.path] == resolved_path || File.expand_path(t.path) == resolved_path
|
|
192
249
|
contexts = @active_locations[loc_key]
|
|
250
|
+
if contexts && !contexts.empty?
|
|
251
|
+
handle_hit(contexts, t.binding)
|
|
252
|
+
end
|
|
253
|
+
ensure
|
|
254
|
+
@mutex.exit
|
|
193
255
|
end
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
256
|
+
rescue SignalException, SystemExit
|
|
257
|
+
raise
|
|
258
|
+
rescue Exception
|
|
259
|
+
# Includes trap-context ThreadError; host termination remains untouched.
|
|
260
|
+
# This boundary never encloses application code.
|
|
261
|
+
nil
|
|
199
262
|
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def enable_location_tracepoint_unlocked(loc_key)
|
|
266
|
+
resolved_path, lineno = loc_key
|
|
200
267
|
|
|
201
268
|
target_method = find_method_target(resolved_path, lineno)
|
|
202
269
|
targeted_enabled = false
|
|
270
|
+
tp = nil
|
|
203
271
|
|
|
204
272
|
if target_method
|
|
205
273
|
begin
|
|
274
|
+
tp = create_tracepoint_for_location(loc_key, resolved_path, lineno)
|
|
206
275
|
tp.enable(target: target_method, target_line: lineno)
|
|
207
276
|
targeted_enabled = true
|
|
208
277
|
rescue ArgumentError, StandardError
|
|
209
|
-
# Target option not accepted by interpreter
|
|
278
|
+
# Target option not accepted by interpreter or line is outside method instructions.
|
|
279
|
+
# Must discard and disable this TracePoint because CRuby does not allow un-targeted
|
|
280
|
+
# re-enable on a TracePoint instance once target has been attempted.
|
|
281
|
+
tp&.disable rescue nil
|
|
282
|
+
tp = nil
|
|
283
|
+
targeted_enabled = false
|
|
210
284
|
end
|
|
211
285
|
end
|
|
212
286
|
|
|
213
287
|
unless targeted_enabled
|
|
214
288
|
begin
|
|
289
|
+
tp = create_tracepoint_for_location(loc_key, resolved_path, lineno)
|
|
215
290
|
tp.enable
|
|
216
291
|
rescue StandardError
|
|
217
292
|
# TracePoint enable error
|
|
293
|
+
tp = nil
|
|
218
294
|
end
|
|
219
295
|
end
|
|
220
296
|
|
|
221
|
-
@targeted_tracepoints[loc_key] = tp
|
|
297
|
+
@targeted_tracepoints[loc_key] = tp if tp
|
|
222
298
|
end
|
|
223
299
|
|
|
224
300
|
def disable_all_tracepoints_unlocked
|
|
301
|
+
@jruby_call_hook&.disable
|
|
225
302
|
@targeted_tracepoints.each_value do |tp|
|
|
226
303
|
tp.disable rescue nil
|
|
227
304
|
end
|
|
@@ -229,17 +306,48 @@ module HyperProbe
|
|
|
229
306
|
@is_active = false
|
|
230
307
|
end
|
|
231
308
|
|
|
309
|
+
def method_line_range(m)
|
|
310
|
+
return nil unless defined?(RubyVM::InstructionSequence) && RubyVM::InstructionSequence.respond_to?(:of)
|
|
311
|
+
|
|
312
|
+
iseq = RubyVM::InstructionSequence.of(m) rescue nil
|
|
313
|
+
return nil unless iseq
|
|
314
|
+
|
|
315
|
+
data = iseq.to_a[4] rescue nil
|
|
316
|
+
if data.is_a?(Hash) && data[:code_location]
|
|
317
|
+
loc = data[:code_location]
|
|
318
|
+
return (loc[0]..loc[2]) if loc.is_a?(Array) && loc.length >= 3
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
lines = []
|
|
322
|
+
collect_lines = ->(seq) {
|
|
323
|
+
(seq.trace_points rescue []).each { |tp_info| lines << tp_info[0] }
|
|
324
|
+
(seq.each_child rescue []).each { |child| collect_lines.call(child) }
|
|
325
|
+
}
|
|
326
|
+
collect_lines.call(iseq)
|
|
327
|
+
return (lines.min..lines.max) unless lines.empty?
|
|
328
|
+
|
|
329
|
+
nil
|
|
330
|
+
rescue StandardError
|
|
331
|
+
nil
|
|
332
|
+
end
|
|
333
|
+
|
|
232
334
|
def find_method_target(file_path, line_number)
|
|
335
|
+
return nil unless defined?(RubyVM::InstructionSequence)
|
|
233
336
|
best_match = nil
|
|
234
337
|
best_line = 0
|
|
338
|
+
deadline = monotonic_time + 0.05
|
|
235
339
|
|
|
236
340
|
ObjectSpace.each_object(Module) do |mod|
|
|
237
|
-
|
|
341
|
+
break if monotonic_time >= deadline
|
|
342
|
+
next if SINGLETON_CLASS.bind(mod).call || MODULE_NAME.bind(mod).call.nil?
|
|
238
343
|
|
|
239
344
|
[:instance_methods, :methods].each do |mtype|
|
|
240
|
-
|
|
345
|
+
implementation = mtype == :instance_methods ? INSTANCE_METHODS : METHODS
|
|
346
|
+
methods_list = implementation.bind(mod).call(false)
|
|
241
347
|
methods_list.each do |sym|
|
|
242
|
-
|
|
348
|
+
break if monotonic_time >= deadline
|
|
349
|
+
implementation = mtype == :instance_methods ? INSTANCE_METHOD : METHOD
|
|
350
|
+
m = implementation.bind(mod).call(sym) rescue nil
|
|
243
351
|
next unless m
|
|
244
352
|
|
|
245
353
|
loc = m.source_location
|
|
@@ -248,10 +356,16 @@ module HyperProbe
|
|
|
248
356
|
m_file = loc[0]
|
|
249
357
|
m_line = loc[1]
|
|
250
358
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
359
|
+
next unless m_line <= line_number && m_line > best_line
|
|
360
|
+
next unless m_file == file_path || @path_cache[m_file] == file_path || File.expand_path(m_file) == file_path
|
|
361
|
+
|
|
362
|
+
range = method_line_range(m)
|
|
363
|
+
if range
|
|
364
|
+
next unless range.cover?(line_number)
|
|
254
365
|
end
|
|
366
|
+
|
|
367
|
+
best_match = m
|
|
368
|
+
best_line = m_line
|
|
255
369
|
end
|
|
256
370
|
end
|
|
257
371
|
end
|
|
@@ -264,12 +378,12 @@ module HyperProbe
|
|
|
264
378
|
def resolve_filepath(runtime_location)
|
|
265
379
|
return '' if runtime_location.nil? || runtime_location.empty?
|
|
266
380
|
|
|
267
|
-
cached = @path_cache[runtime_location]
|
|
381
|
+
cached = @path_mutex.synchronize { @path_cache[runtime_location] }
|
|
268
382
|
return cached if cached
|
|
269
383
|
|
|
270
384
|
if File.exist?(runtime_location)
|
|
271
385
|
resolved = File.realpath(runtime_location) rescue File.expand_path(runtime_location)
|
|
272
|
-
@path_cache[runtime_location] = resolved
|
|
386
|
+
@path_mutex.synchronize { @path_cache[runtime_location] = resolved }
|
|
273
387
|
return resolved
|
|
274
388
|
end
|
|
275
389
|
|
|
@@ -280,13 +394,13 @@ module HyperProbe
|
|
|
280
394
|
attempt = File.expand_path(suffix, cwd)
|
|
281
395
|
if File.exist?(attempt)
|
|
282
396
|
resolved = File.realpath(attempt) rescue attempt
|
|
283
|
-
@path_cache[runtime_location] = resolved
|
|
397
|
+
@path_mutex.synchronize { @path_cache[runtime_location] = resolved }
|
|
284
398
|
return resolved
|
|
285
399
|
end
|
|
286
400
|
end
|
|
287
401
|
|
|
288
402
|
fallback = File.expand_path(runtime_location, cwd)
|
|
289
|
-
@path_cache[runtime_location] = fallback
|
|
403
|
+
@path_mutex.synchronize { @path_cache[runtime_location] = fallback }
|
|
290
404
|
fallback
|
|
291
405
|
end
|
|
292
406
|
|
|
@@ -296,23 +410,30 @@ module HyperProbe
|
|
|
296
410
|
contexts.each do |ctx|
|
|
297
411
|
probe = ctx[:probe]
|
|
298
412
|
is_secondary = ctx[:is_secondary]
|
|
299
|
-
process_single_probe_hit(probe, binding_ctx, is_secondary)
|
|
413
|
+
process_single_probe_hit(probe, binding_ctx, is_secondary, ctx[:local_names])
|
|
414
|
+
rescue SignalException, SystemExit
|
|
415
|
+
raise
|
|
416
|
+
rescue Exception
|
|
417
|
+
# One broken probe must not prevent another probe or the host line.
|
|
418
|
+
next
|
|
300
419
|
end
|
|
301
|
-
rescue
|
|
302
|
-
|
|
420
|
+
rescue SignalException, SystemExit
|
|
421
|
+
raise
|
|
422
|
+
rescue Exception
|
|
423
|
+
nil
|
|
303
424
|
ensure
|
|
304
425
|
elapsed_ms = (monotonic_time - start_time) * 1000.0
|
|
305
426
|
@safety_monitor.report_pause_duration(elapsed_ms) rescue nil
|
|
306
427
|
end
|
|
307
428
|
end
|
|
308
429
|
|
|
309
|
-
def process_single_probe_hit(probe, binding_ctx, is_secondary)
|
|
430
|
+
def process_single_probe_hit(probe, binding_ctx, is_secondary, local_names = nil)
|
|
310
431
|
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
311
432
|
probe_type = probe.respond_to?(:type) ? probe.type : probe[:type]
|
|
312
433
|
probe_type_num = probe_type.is_a?(Integer) ? probe_type : probe_type_to_i(probe_type)
|
|
313
434
|
|
|
314
435
|
# 1. Rate-limiting admission check
|
|
315
|
-
unless @quota_manager.can_evaluate?
|
|
436
|
+
unless probe_type_num == 5 || @quota_manager.can_evaluate?
|
|
316
437
|
@mutex.synchronize { @total_skips += 1 }
|
|
317
438
|
return
|
|
318
439
|
end
|
|
@@ -321,9 +442,9 @@ module HyperProbe
|
|
|
321
442
|
condition = probe.respond_to?(:condition) ? probe.condition : probe[:condition]
|
|
322
443
|
if condition && !condition.empty?
|
|
323
444
|
begin
|
|
324
|
-
passed = Evaluator.eval_condition(condition, binding_ctx)
|
|
445
|
+
passed = Evaluator.eval_condition(condition, binding_ctx, safe: @safe_evaluation)
|
|
325
446
|
return unless passed
|
|
326
|
-
rescue StandardError, ScriptError => e
|
|
447
|
+
rescue StandardError, ScriptError, SecurityError => e
|
|
327
448
|
report_error(probe_id, "Condition evaluation failed: #{e.class.name}: #{e.message}")
|
|
328
449
|
return
|
|
329
450
|
end
|
|
@@ -332,7 +453,7 @@ module HyperProbe
|
|
|
332
453
|
# 3. Dispatch to Probe Type Handler
|
|
333
454
|
case probe_type_num
|
|
334
455
|
when 1 # SNAPSHOT
|
|
335
|
-
handle_snapshot(probe, binding_ctx)
|
|
456
|
+
handle_snapshot(probe, binding_ctx, local_names)
|
|
336
457
|
when 2 # LOG
|
|
337
458
|
handle_log(probe, binding_ctx)
|
|
338
459
|
when 3 # COUNTER
|
|
@@ -344,14 +465,14 @@ module HyperProbe
|
|
|
344
465
|
end
|
|
345
466
|
end
|
|
346
467
|
|
|
347
|
-
def handle_snapshot(probe, binding_ctx)
|
|
468
|
+
def handle_snapshot(probe, binding_ctx, local_names = nil)
|
|
348
469
|
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
349
470
|
|
|
350
471
|
max_depth = get_probe_positive_int(probe, :max_object_depth, 3)
|
|
351
472
|
max_array_length = get_probe_positive_int(probe, :max_array_length, 3)
|
|
352
473
|
max_object_properties = get_probe_positive_int(probe, :max_object_properties, 50)
|
|
353
474
|
max_string_length = get_probe_positive_int(probe, :max_string_length, 1024)
|
|
354
|
-
stack_frame_depth = get_probe_positive_int(probe, :stack_frame_depth, 3)
|
|
475
|
+
stack_frame_depth = [get_probe_positive_int(probe, :stack_frame_depth, 3), MAX_STACK_FRAMES].min
|
|
355
476
|
redact_keys = @global_config[:redact_keys] || @global_config['redact_keys']
|
|
356
477
|
redact_values = @global_config[:redact_values] || @global_config['redact_values']
|
|
357
478
|
|
|
@@ -370,7 +491,7 @@ module HyperProbe
|
|
|
370
491
|
watch_expressions = get_probe_opt(probe, :watch_expressions) || []
|
|
371
492
|
serialized_watches = {}
|
|
372
493
|
if watch_expressions && !watch_expressions.empty?
|
|
373
|
-
raw_watches = Evaluator.evaluate_watches(watch_expressions, binding_ctx)
|
|
494
|
+
raw_watches = Evaluator.evaluate_watches(watch_expressions.to_a, binding_ctx, safe: @safe_evaluation)
|
|
374
495
|
raw_watches.each do |expr, val|
|
|
375
496
|
watch_path = "watch[#{JSON.generate(expr)}]"
|
|
376
497
|
serialized_watches[expr] = serializer.serialize(val, watch_path, 0, visited)
|
|
@@ -383,7 +504,8 @@ module HyperProbe
|
|
|
383
504
|
binding_ctx,
|
|
384
505
|
stack_frame_depth,
|
|
385
506
|
serializer,
|
|
386
|
-
visited
|
|
507
|
+
visited,
|
|
508
|
+
local_names
|
|
387
509
|
)
|
|
388
510
|
|
|
389
511
|
event = build_base_event(probe_id)
|
|
@@ -392,8 +514,10 @@ module HyperProbe
|
|
|
392
514
|
event[:watch_results_json] = Serializer.safe_dump_json(serialized_watches)
|
|
393
515
|
|
|
394
516
|
emit_event(probe, event)
|
|
395
|
-
rescue StandardError => e
|
|
517
|
+
rescue StandardError, ScriptError, SecurityError => e
|
|
396
518
|
report_error(probe_id, "Snapshot capture failed: #{e.class.name}: #{e.message}")
|
|
519
|
+
ensure
|
|
520
|
+
serializer&.finish_capture
|
|
397
521
|
end
|
|
398
522
|
|
|
399
523
|
def handle_log(probe, binding_ctx)
|
|
@@ -402,7 +526,7 @@ module HyperProbe
|
|
|
402
526
|
max_string_length = get_probe_positive_int(probe, :max_string_length, 1024)
|
|
403
527
|
redact_values = @global_config[:redact_values] || @global_config['redact_values']
|
|
404
528
|
|
|
405
|
-
evaluated = Evaluator.evaluate_log_template(template, binding_ctx)
|
|
529
|
+
evaluated = Evaluator.evaluate_log_template(template, binding_ctx, safe: @safe_evaluation)
|
|
406
530
|
|
|
407
531
|
# Redact values
|
|
408
532
|
if redact_values && !redact_values.empty?
|
|
@@ -428,13 +552,13 @@ module HyperProbe
|
|
|
428
552
|
"#{COLOR_BOLD}#{color}[#{log_level}]#{COLOR_RESET} " \
|
|
429
553
|
"#{COLOR_BOLD}#{COLOR_MAGENTA}[HyperProbe LOG]#{COLOR_RESET} " \
|
|
430
554
|
"#{evaluated}\n"
|
|
431
|
-
|
|
555
|
+
@stdout_queue.push(out_line, true) rescue nil
|
|
432
556
|
end
|
|
433
557
|
|
|
434
558
|
event = build_base_event(probe_id)
|
|
435
559
|
event[:evaluated_log] = evaluated
|
|
436
560
|
emit_event(probe, event)
|
|
437
|
-
rescue StandardError => e
|
|
561
|
+
rescue StandardError, ScriptError, SecurityError => e
|
|
438
562
|
report_error(probe_id, "Log evaluation failed: #{e.class.name}: #{e.message}")
|
|
439
563
|
end
|
|
440
564
|
|
|
@@ -450,11 +574,9 @@ module HyperProbe
|
|
|
450
574
|
metric_expr = get_probe_opt(probe, :metric_expression)
|
|
451
575
|
return if metric_expr.nil? || metric_expr.empty?
|
|
452
576
|
|
|
453
|
-
raw_val = nil
|
|
454
577
|
begin
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
rescue StandardError, ScriptError => e
|
|
578
|
+
val = Evaluator.evaluate_metric(metric_expr, binding_ctx, safe: @safe_evaluation)
|
|
579
|
+
rescue StandardError, ScriptError, SecurityError => e
|
|
458
580
|
report_error(probe_id, "Metric evaluation failed: #{e.class.name}: #{e.message}")
|
|
459
581
|
return
|
|
460
582
|
end
|
|
@@ -463,7 +585,7 @@ module HyperProbe
|
|
|
463
585
|
if val
|
|
464
586
|
event[:metric_value] = val
|
|
465
587
|
else
|
|
466
|
-
event[:capture_error] =
|
|
588
|
+
event[:capture_error] = 'Metric evaluation failed: expression must return a finite number'
|
|
467
589
|
end
|
|
468
590
|
emit_event(probe, event)
|
|
469
591
|
end
|
|
@@ -474,8 +596,8 @@ module HyperProbe
|
|
|
474
596
|
|
|
475
597
|
corr_id = nil
|
|
476
598
|
begin
|
|
477
|
-
corr_id = Evaluator.evaluate_correlation(corr_expr, binding_ctx)
|
|
478
|
-
rescue StandardError, ScriptError => e
|
|
599
|
+
corr_id = Evaluator.evaluate_correlation(corr_expr, binding_ctx, safe: @safe_evaluation)
|
|
600
|
+
rescue StandardError, ScriptError, SecurityError => e
|
|
479
601
|
report_error(probe_id, "Duration correlation failed: #{e.message}")
|
|
480
602
|
return
|
|
481
603
|
end
|
|
@@ -486,6 +608,10 @@ module HyperProbe
|
|
|
486
608
|
now_mono = monotonic_time
|
|
487
609
|
@mutex.synchronize do
|
|
488
610
|
cleanup_durations_unlocked(now_mono)
|
|
611
|
+
if @duration_starts.size >= MAX_DURATION_ENTRIES
|
|
612
|
+
# Evict oldest entry to prevent unbounded memory growth
|
|
613
|
+
@duration_starts.shift
|
|
614
|
+
end
|
|
489
615
|
@duration_starts[dur_key] = [now_mono, now_mono]
|
|
490
616
|
end
|
|
491
617
|
else
|
|
@@ -496,6 +622,10 @@ module HyperProbe
|
|
|
496
622
|
start_entry = @duration_starts.delete(dur_key)
|
|
497
623
|
end
|
|
498
624
|
return unless start_entry
|
|
625
|
+
unless @quota_manager.can_evaluate?
|
|
626
|
+
@mutex.synchronize { @total_skips += 1 }
|
|
627
|
+
return
|
|
628
|
+
end
|
|
499
629
|
|
|
500
630
|
duration_ms = (now_mono - start_entry[0]) * 1000.0
|
|
501
631
|
event = build_base_event(probe_id)
|
|
@@ -537,66 +667,21 @@ module HyperProbe
|
|
|
537
667
|
emit_event(nil, event)
|
|
538
668
|
end
|
|
539
669
|
|
|
540
|
-
def extract_stack_and_variables(probe, binding_ctx, stack_frame_depth, serializer, visited)
|
|
541
|
-
sdk_path_snippet = 'hyperprobe/'
|
|
542
|
-
all_locations = caller_locations(0, 50) || []
|
|
543
|
-
|
|
544
|
-
user_locations = all_locations.reject do |loc|
|
|
545
|
-
p = (loc.respond_to?(:path) ? loc.path : loc.to_s) || ''
|
|
546
|
-
p.include?(sdk_path_snippet) ||
|
|
547
|
-
p.include?('<internal:') ||
|
|
548
|
-
p.include?('org/jruby/') ||
|
|
549
|
-
p.include?('org.jruby.') ||
|
|
550
|
-
p.include?('java/lang/') ||
|
|
551
|
-
p.include?('sun/reflect') ||
|
|
552
|
-
p.include?('jdk/internal')
|
|
553
|
-
end
|
|
554
|
-
|
|
555
|
-
# Capture caller bindings if available (via binding_of_caller)
|
|
556
|
-
user_caller_bindings = []
|
|
557
|
-
begin
|
|
558
|
-
if defined?(Binding) && binding_ctx.respond_to?(:of_caller)
|
|
559
|
-
(1..50).each do |depth|
|
|
560
|
-
b = binding_ctx.of_caller(depth) rescue nil
|
|
561
|
-
break unless b
|
|
562
|
-
|
|
563
|
-
loc = b.source_location
|
|
564
|
-
next unless loc
|
|
565
|
-
|
|
566
|
-
p = (loc[0] || '').to_s
|
|
567
|
-
next if p.include?(sdk_path_snippet) ||
|
|
568
|
-
p.include?('<internal:') ||
|
|
569
|
-
p.include?('org/jruby/') ||
|
|
570
|
-
p.include?('org.jruby.') ||
|
|
571
|
-
p.include?('java/lang/') ||
|
|
572
|
-
p.include?('sun/reflect') ||
|
|
573
|
-
p.include?('jdk/internal')
|
|
574
|
-
|
|
575
|
-
user_caller_bindings << b
|
|
576
|
-
end
|
|
577
|
-
end
|
|
578
|
-
rescue StandardError, ScriptError
|
|
579
|
-
# Fallback if caller binding inspection is restricted
|
|
580
|
-
end
|
|
581
|
-
|
|
670
|
+
def extract_stack_and_variables(probe, binding_ctx, stack_frame_depth, serializer, visited, local_names)
|
|
582
671
|
stack_frames = []
|
|
583
672
|
captured_vars = []
|
|
584
673
|
|
|
585
|
-
|
|
586
|
-
total_frames = [total_frames, 1].max
|
|
587
|
-
|
|
588
|
-
(0...total_frames).each do |frame_idx|
|
|
589
|
-
loc = user_locations[frame_idx]
|
|
674
|
+
snapshot_frames(binding_ctx, local_names, stack_frame_depth).each_with_index do |(loc, current_b, frame_local_names), frame_idx|
|
|
590
675
|
|
|
591
676
|
func = loc&.label || loc&.base_label || '(anonymous)'
|
|
592
|
-
file = loc ?
|
|
677
|
+
file = loc ? (loc.absolute_path || loc.path || 'unknown') : 'unknown'
|
|
593
678
|
line_no = loc&.lineno || 0
|
|
594
679
|
|
|
595
680
|
# If frame_idx == 0 and location is unpopulated, fallback to probe location
|
|
596
681
|
if frame_idx == 0 && (file == 'unknown' || line_no == 0)
|
|
597
682
|
runtime_loc = probe.respond_to?(:runtime_location) ? probe.runtime_location : probe[:runtime_location]
|
|
598
683
|
line_no = (probe.respond_to?(:runtime_line) ? probe.runtime_line : probe[:runtime_line]).to_i
|
|
599
|
-
file =
|
|
684
|
+
file = runtime_loc
|
|
600
685
|
end
|
|
601
686
|
|
|
602
687
|
stack_frames << {
|
|
@@ -606,17 +691,19 @@ module HyperProbe
|
|
|
606
691
|
column_number: 0
|
|
607
692
|
}
|
|
608
693
|
|
|
609
|
-
# Resolve frame binding (Frame 0 uses binding_ctx; Frame 1..N uses user_caller_bindings)
|
|
610
|
-
current_b = (frame_idx == 0) ? binding_ctx : user_caller_bindings[frame_idx]
|
|
611
|
-
|
|
612
694
|
locals_hash = {}
|
|
695
|
+
closure_hash = {}
|
|
613
696
|
if current_b
|
|
614
|
-
|
|
697
|
+
capture_closures = get_probe_opt(probe, :capture_closures)
|
|
698
|
+
capture_closures = @global_config[:capture_closures] if capture_closures.nil?
|
|
699
|
+
LOCAL_VARIABLES.bind(current_b).call.first(128).each do |var_sym|
|
|
700
|
+
is_local = frame_local_names && frame_local_names.include?(var_sym)
|
|
701
|
+
next unless is_local || capture_closures == true
|
|
615
702
|
var_name = var_sym.to_s
|
|
616
|
-
next if var_name.start_with?('_') && var_name != '_x'
|
|
617
703
|
|
|
618
704
|
begin
|
|
619
|
-
|
|
705
|
+
target = is_local ? locals_hash : closure_hash
|
|
706
|
+
target[var_name] = LOCAL_GET.bind(current_b).call(var_sym)
|
|
620
707
|
rescue StandardError, ScriptError => e
|
|
621
708
|
locals_hash[var_name] = "[Error accessing variable: #{e.message}]"
|
|
622
709
|
end
|
|
@@ -632,26 +719,113 @@ module HyperProbe
|
|
|
632
719
|
'vars' => serialized_locals
|
|
633
720
|
}
|
|
634
721
|
]
|
|
722
|
+
unless closure_hash.empty?
|
|
723
|
+
captured_vars.last << {
|
|
724
|
+
'type' => 'closure', 'name' => 'Closure',
|
|
725
|
+
'vars' => serializer.serialize(closure_hash, "frame[#{frame_idx}].scopes[1]", 0, visited)
|
|
726
|
+
}
|
|
727
|
+
end
|
|
635
728
|
end
|
|
636
729
|
|
|
637
730
|
[stack_frames, captured_vars]
|
|
638
731
|
end
|
|
639
732
|
|
|
733
|
+
def snapshot_frames(binding_ctx, local_names, depth)
|
|
734
|
+
return [] unless depth.positive?
|
|
735
|
+
|
|
736
|
+
collect = proc do |locations, inspector|
|
|
737
|
+
frames = []
|
|
738
|
+
locations.first(50).each_with_index do |loc, index|
|
|
739
|
+
path = loc.path || ''
|
|
740
|
+
next if path.empty? || path.include?('hyperprobe/') || path.include?('<internal:') ||
|
|
741
|
+
path.include?('org/jruby/') || path.include?('org.jruby.') ||
|
|
742
|
+
path.include?('java/lang/') || path.include?('sun/reflect') || path.include?('jdk/internal')
|
|
743
|
+
|
|
744
|
+
current_b = nil
|
|
745
|
+
names = nil
|
|
746
|
+
if frames.empty?
|
|
747
|
+
current_b = binding_ctx
|
|
748
|
+
names = local_names
|
|
749
|
+
elsif inspector
|
|
750
|
+
begin
|
|
751
|
+
# Keep the inspector's original index, including native frames.
|
|
752
|
+
current_b = inspector.frame_binding(index)
|
|
753
|
+
iseq = inspector.frame_iseq(index) if current_b
|
|
754
|
+
names = caller_local_names(iseq) if iseq
|
|
755
|
+
current_b = nil unless names
|
|
756
|
+
rescue StandardError, ScriptError, SecurityError
|
|
757
|
+
current_b = nil
|
|
758
|
+
end
|
|
759
|
+
end
|
|
760
|
+
frames << [loc, current_b, names]
|
|
761
|
+
break if frames.length >= depth
|
|
762
|
+
end
|
|
763
|
+
frames.empty? ? [[nil, binding_ctx, local_names]] : frames
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
scan_limit = RUBY_ENGINE == 'ruby' && depth > 1 ? MAX_INSPECTOR_STACK_DEPTH + 1 : 50
|
|
767
|
+
locations = caller_locations(0, scan_limit) || []
|
|
768
|
+
if RUBY_ENGINE == 'ruby' && depth > 1 && locations.length <= MAX_INSPECTOR_STACK_DEPTH
|
|
769
|
+
begin
|
|
770
|
+
return DebugInspector.open { |inspector| collect.call(inspector.backtrace_locations, inspector) }
|
|
771
|
+
rescue StandardError, ScriptError, SecurityError
|
|
772
|
+
# An unavailable inspector must not discard the probe-hit locals.
|
|
773
|
+
end
|
|
774
|
+
end
|
|
775
|
+
collect.call(locations, nil)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def caller_local_names(iseq)
|
|
779
|
+
return @caller_locals_cache[iseq] if @caller_locals_cache.key?(iseq)
|
|
780
|
+
@caller_locals_cache = ObjectSpace::WeakMap.new if @caller_locals_cache.size >= MAX_CALLER_SCOPES
|
|
781
|
+
|
|
782
|
+
# to_a expands nested bytecode, so reject large code trees before dumping
|
|
783
|
+
# them. These admission limits are not a hard native allocation deadline.
|
|
784
|
+
pending = [iseq]
|
|
785
|
+
count = 0
|
|
786
|
+
bytes = 0
|
|
787
|
+
until pending.empty?
|
|
788
|
+
current = pending.pop
|
|
789
|
+
count += 1
|
|
790
|
+
size_before = ISEQ_MEMORY_SIZE.call(current)
|
|
791
|
+
bytes += size_before
|
|
792
|
+
return @caller_locals_cache[iseq] = nil if count > MAX_CALLER_ISEQS || bytes > MAX_CALLER_ISEQ_BYTES
|
|
793
|
+
|
|
794
|
+
ISEQ_EACH_CHILD.bind(current).call do |child|
|
|
795
|
+
pending << child
|
|
796
|
+
return @caller_locals_cache[iseq] = nil if count + pending.length > MAX_CALLER_ISEQS
|
|
797
|
+
end
|
|
798
|
+
# Enumerating children can materialize a lazily loaded binary ISeq.
|
|
799
|
+
bytes += [0, ISEQ_MEMORY_SIZE.call(current) - size_before].max
|
|
800
|
+
return @caller_locals_cache[iseq] = nil if bytes > MAX_CALLER_ISEQ_BYTES
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# The compiled local table excludes enclosing variables. Weak keys avoid
|
|
804
|
+
# retaining reloaded code; only names are cached, never live bindings.
|
|
805
|
+
data = ISEQ_TO_A.bind(iseq).call
|
|
806
|
+
names = if data[0] == 'YARVInstructionSequence/SimpleDataFormat' && data[10].is_a?(Array)
|
|
807
|
+
data[10].grep(Symbol).first(128).freeze
|
|
808
|
+
end
|
|
809
|
+
@caller_locals_cache[iseq] = names
|
|
810
|
+
end
|
|
811
|
+
|
|
640
812
|
def get_probe_positive_int(probe, key, default_val)
|
|
641
813
|
val = get_probe_opt(probe, key)
|
|
642
|
-
return val
|
|
814
|
+
return val if val.is_a?(Integer) && val >= 0
|
|
643
815
|
|
|
644
816
|
global_val = @global_config[key] || @global_config[key.to_s]
|
|
645
|
-
return global_val
|
|
817
|
+
return global_val if global_val.is_a?(Integer) && global_val >= 0
|
|
646
818
|
|
|
647
819
|
default_val
|
|
648
820
|
end
|
|
649
821
|
|
|
650
822
|
def get_probe_opt(probe, key)
|
|
651
823
|
if probe.respond_to?(key)
|
|
824
|
+
presence = "has_#{key}?"
|
|
825
|
+
return nil if probe.respond_to?(presence) && !probe.public_send(presence)
|
|
652
826
|
probe.public_send(key)
|
|
653
827
|
elsif probe.is_a?(Hash)
|
|
654
|
-
probe[key]
|
|
828
|
+
probe.key?(key) ? probe[key] : probe[key.to_s]
|
|
655
829
|
end
|
|
656
830
|
end
|
|
657
831
|
|
|
@@ -667,7 +841,7 @@ module HyperProbe
|
|
|
667
841
|
end
|
|
668
842
|
|
|
669
843
|
def cleanup_durations_unlocked(now)
|
|
670
|
-
return if now < @next_duration_cleanup
|
|
844
|
+
return if now < @next_duration_cleanup && @duration_starts.size < MAX_DURATION_ENTRIES
|
|
671
845
|
|
|
672
846
|
@duration_starts.delete_if do |_key, (_start_time, created_at)|
|
|
673
847
|
now - created_at > DURATION_TTL_SECONDS
|