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.
@@ -0,0 +1,857 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'json'
5
+ require 'monitor'
6
+ require 'debug_inspector' if RUBY_ENGINE == 'ruby'
7
+ require 'objspace' if RUBY_ENGINE == 'ruby'
8
+ require_relative 'logger'
9
+ require_relative 'evaluator'
10
+ require_relative 'lexical_scope'
11
+
12
+ module HyperProbe
13
+ module Core
14
+ class MonitoringEngine
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)
34
+
35
+ LEVEL_COLORS = {
36
+ 'ERROR' => "\e[31m",
37
+ 'WARN' => "\e[33m",
38
+ 'INFO' => "\e[32m",
39
+ 'DEBUG' => "\e[36m"
40
+ }.freeze
41
+ COLOR_RESET = "\e[0m"
42
+ COLOR_BOLD = "\e[1m"
43
+ COLOR_GRAY = "\e[90m"
44
+ COLOR_MAGENTA = "\e[35m"
45
+
46
+ attr_reader :total_hits, :total_skips, :is_suspended, :is_active
47
+
48
+ def initialize(quota_manager, safety_monitor, on_capture, custom_set_trace_id = nil)
49
+ @quota_manager = quota_manager
50
+ @safety_monitor = safety_monitor
51
+ @on_capture = on_capture
52
+ @custom_set_trace_id = custom_set_trace_id
53
+
54
+ @mutex = Monitor.new
55
+ @path_mutex = Monitor.new
56
+ @active_locations = {} # [resolved_file_path, line] => Array<ActiveProbeContext>
57
+ @probe_map = {} # probe_id => Probe
58
+ @instrumented_files = Set.new
59
+ @path_cache = {}
60
+ @caller_locals_cache = ObjectSpace::WeakMap.new if RUBY_ENGINE == 'ruby'
61
+ @duration_starts = {} # [probe_id, correlation_id] => [start_time_monotonic, created_at_time]
62
+ @next_duration_cleanup = monotonic_time + DURATION_TTL_SECONDS
63
+
64
+ @global_config = {}
65
+ @safe_evaluation = Evaluator.safe_evaluation_enabled?
66
+ @total_hits = 0
67
+ @total_skips = 0
68
+ @is_suspended = false
69
+ @is_active = false
70
+ @closed = false
71
+ @owner_pid = Process.pid
72
+ @generation = -1
73
+
74
+ @log = Logger.get_logger('hyperprobe:inspector')
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'
86
+ end
87
+
88
+ def set_global_config(config)
89
+ @mutex.synchronize do
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)
93
+ end
94
+ end
95
+
96
+ def set_probes(probes, generation: nil)
97
+ @mutex.synchronize do
98
+ return if @closed
99
+ return if generation && generation <= @generation
100
+ @generation = generation if generation
101
+
102
+ new_active_locations = {}
103
+ new_probe_map = {}
104
+ new_instrumented_files = Set.new
105
+
106
+ Array(probes).first(MAX_PROBES).each do |probe|
107
+ probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
108
+ next unless probe_id
109
+
110
+ new_probe_map[probe_id] = probe
111
+
112
+ runtime_loc = probe.respond_to?(:runtime_location) ? probe.runtime_location : probe[:runtime_location]
113
+ runtime_line = (probe.respond_to?(:runtime_line) ? probe.runtime_line : probe[:runtime_line]).to_i
114
+
115
+ resolved_path = resolve_filepath(runtime_loc)
116
+ primary_key = [resolved_path, runtime_line]
117
+
118
+ new_active_locations[primary_key] ||= []
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 }
126
+ new_instrumented_files.add(resolved_path)
127
+
128
+ # Duration secondary location
129
+ is_duration = probe_type == 5 || probe_type == :PROBE_TYPE_DURATION
130
+ if is_duration
131
+ sec_line = (probe.respond_to?(:secondary_runtime_line) ? probe.secondary_runtime_line : probe[:secondary_runtime_line]).to_i
132
+ if sec_line.positive?
133
+ sec_loc = probe.respond_to?(:secondary_runtime_location) ? probe.secondary_runtime_location : probe[:secondary_runtime_location]
134
+ sec_loc = runtime_loc if sec_loc.nil? || sec_loc.empty?
135
+ resolved_sec_path = resolve_filepath(sec_loc)
136
+ secondary_key = [resolved_sec_path, sec_line]
137
+
138
+ new_active_locations[secondary_key] ||= []
139
+ new_active_locations[secondary_key] << { probe: probe, is_secondary: true }
140
+ new_instrumented_files.add(resolved_sec_path)
141
+ end
142
+ end
143
+ end
144
+
145
+ @active_locations = new_active_locations
146
+ @probe_map = new_probe_map
147
+ @instrumented_files = new_instrumented_files
148
+ @duration_starts.delete_if { |(id, _), _entry| !new_probe_map.key?(id) }
149
+
150
+ update_tracepoint_state_unlocked
151
+ end
152
+ end
153
+
154
+ def suspend
155
+ @mutex.synchronize do
156
+ return if @closed || @is_suspended
157
+
158
+ @is_suspended = true
159
+ disable_all_tracepoints_unlocked
160
+ end
161
+ end
162
+
163
+ def resume
164
+ @mutex.synchronize do
165
+ return if @closed || !@is_suspended
166
+
167
+ @is_suspended = false
168
+ update_tracepoint_state_unlocked
169
+ end
170
+ end
171
+
172
+ def get_stats
173
+ @mutex.synchronize do
174
+ cleanup_durations_unlocked(monotonic_time)
175
+ stats = { hits: @total_hits, skips: @total_skips }
176
+ @total_hits = 0
177
+ @total_skips = 0
178
+ stats
179
+ end
180
+ end
181
+
182
+ def close
183
+ @stdout_thread&.kill
184
+ @mutex.synchronize do
185
+ return if @closed
186
+
187
+ @closed = true
188
+ disable_all_tracepoints_unlocked
189
+ @active_locations.clear
190
+ @probe_map.clear
191
+ @instrumented_files.clear
192
+ @duration_starts.clear
193
+ @caller_locals_cache = nil
194
+ end
195
+ end
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
+
208
+ private
209
+
210
+ def update_tracepoint_state_unlocked
211
+ if @closed || @is_suspended || @active_locations.empty?
212
+ disable_all_tracepoints_unlocked
213
+ return
214
+ end
215
+
216
+ @jruby_call_hook&.enable
217
+
218
+ # 1. Remove stale tracepoints
219
+ active_keys = @active_locations.keys.to_set
220
+ @targeted_tracepoints.delete_if do |loc_key, tp|
221
+ unless active_keys.include?(loc_key)
222
+ tp.disable rescue nil
223
+ true
224
+ end
225
+ end
226
+
227
+ # 2. Setup or enable targeted tracepoint for each active location
228
+ @active_locations.each_key do |loc_key|
229
+ next if @targeted_tracepoints.key?(loc_key)
230
+
231
+ enable_location_tracepoint_unlocked(loc_key)
232
+ end
233
+
234
+ @is_active = !@targeted_tracepoints.empty?
235
+ end
236
+
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
242
+
243
+ contexts = nil
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
249
+ contexts = @active_locations[loc_key]
250
+ if contexts && !contexts.empty?
251
+ handle_hit(contexts, t.binding)
252
+ end
253
+ ensure
254
+ @mutex.exit
255
+ end
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
262
+ end
263
+ end
264
+
265
+ def enable_location_tracepoint_unlocked(loc_key)
266
+ resolved_path, lineno = loc_key
267
+
268
+ target_method = find_method_target(resolved_path, lineno)
269
+ targeted_enabled = false
270
+ tp = nil
271
+
272
+ if target_method
273
+ begin
274
+ tp = create_tracepoint_for_location(loc_key, resolved_path, lineno)
275
+ tp.enable(target: target_method, target_line: lineno)
276
+ targeted_enabled = true
277
+ rescue ArgumentError, StandardError
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
284
+ end
285
+ end
286
+
287
+ unless targeted_enabled
288
+ begin
289
+ tp = create_tracepoint_for_location(loc_key, resolved_path, lineno)
290
+ tp.enable
291
+ rescue StandardError
292
+ # TracePoint enable error
293
+ tp = nil
294
+ end
295
+ end
296
+
297
+ @targeted_tracepoints[loc_key] = tp if tp
298
+ end
299
+
300
+ def disable_all_tracepoints_unlocked
301
+ @jruby_call_hook&.disable
302
+ @targeted_tracepoints.each_value do |tp|
303
+ tp.disable rescue nil
304
+ end
305
+ @targeted_tracepoints.clear
306
+ @is_active = false
307
+ end
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
+
334
+ def find_method_target(file_path, line_number)
335
+ return nil unless defined?(RubyVM::InstructionSequence)
336
+ best_match = nil
337
+ best_line = 0
338
+ deadline = monotonic_time + 0.05
339
+
340
+ ObjectSpace.each_object(Module) do |mod|
341
+ break if monotonic_time >= deadline
342
+ next if SINGLETON_CLASS.bind(mod).call || MODULE_NAME.bind(mod).call.nil?
343
+
344
+ [:instance_methods, :methods].each do |mtype|
345
+ implementation = mtype == :instance_methods ? INSTANCE_METHODS : METHODS
346
+ methods_list = implementation.bind(mod).call(false)
347
+ methods_list.each do |sym|
348
+ break if monotonic_time >= deadline
349
+ implementation = mtype == :instance_methods ? INSTANCE_METHOD : METHOD
350
+ m = implementation.bind(mod).call(sym) rescue nil
351
+ next unless m
352
+
353
+ loc = m.source_location
354
+ next unless loc
355
+
356
+ m_file = loc[0]
357
+ m_line = loc[1]
358
+
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)
365
+ end
366
+
367
+ best_match = m
368
+ best_line = m_line
369
+ end
370
+ end
371
+ end
372
+
373
+ best_match
374
+ rescue StandardError
375
+ nil
376
+ end
377
+
378
+ def resolve_filepath(runtime_location)
379
+ return '' if runtime_location.nil? || runtime_location.empty?
380
+
381
+ cached = @path_mutex.synchronize { @path_cache[runtime_location] }
382
+ return cached if cached
383
+
384
+ if File.exist?(runtime_location)
385
+ resolved = File.realpath(runtime_location) rescue File.expand_path(runtime_location)
386
+ @path_mutex.synchronize { @path_cache[runtime_location] = resolved }
387
+ return resolved
388
+ end
389
+
390
+ cwd = Dir.pwd
391
+ parts = runtime_location.tr('\\', '/').split('/').reject(&:empty?)
392
+ (0...parts.length).each do |i|
393
+ suffix = File.join(*parts[i..])
394
+ attempt = File.expand_path(suffix, cwd)
395
+ if File.exist?(attempt)
396
+ resolved = File.realpath(attempt) rescue attempt
397
+ @path_mutex.synchronize { @path_cache[runtime_location] = resolved }
398
+ return resolved
399
+ end
400
+ end
401
+
402
+ fallback = File.expand_path(runtime_location, cwd)
403
+ @path_mutex.synchronize { @path_cache[runtime_location] = fallback }
404
+ fallback
405
+ end
406
+
407
+ def handle_hit(contexts, binding_ctx)
408
+ start_time = monotonic_time
409
+ begin
410
+ contexts.each do |ctx|
411
+ probe = ctx[:probe]
412
+ is_secondary = 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
419
+ end
420
+ rescue SignalException, SystemExit
421
+ raise
422
+ rescue Exception
423
+ nil
424
+ ensure
425
+ elapsed_ms = (monotonic_time - start_time) * 1000.0
426
+ @safety_monitor.report_pause_duration(elapsed_ms) rescue nil
427
+ end
428
+ end
429
+
430
+ def process_single_probe_hit(probe, binding_ctx, is_secondary, local_names = nil)
431
+ probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
432
+ probe_type = probe.respond_to?(:type) ? probe.type : probe[:type]
433
+ probe_type_num = probe_type.is_a?(Integer) ? probe_type : probe_type_to_i(probe_type)
434
+
435
+ # 1. Rate-limiting admission check
436
+ unless probe_type_num == 5 || @quota_manager.can_evaluate?
437
+ @mutex.synchronize { @total_skips += 1 }
438
+ return
439
+ end
440
+
441
+ # 2. Evaluate Condition (if any)
442
+ condition = probe.respond_to?(:condition) ? probe.condition : probe[:condition]
443
+ if condition && !condition.empty?
444
+ begin
445
+ passed = Evaluator.eval_condition(condition, binding_ctx, safe: @safe_evaluation)
446
+ return unless passed
447
+ rescue StandardError, ScriptError, SecurityError => e
448
+ report_error(probe_id, "Condition evaluation failed: #{e.class.name}: #{e.message}")
449
+ return
450
+ end
451
+ end
452
+
453
+ # 3. Dispatch to Probe Type Handler
454
+ case probe_type_num
455
+ when 1 # SNAPSHOT
456
+ handle_snapshot(probe, binding_ctx, local_names)
457
+ when 2 # LOG
458
+ handle_log(probe, binding_ctx)
459
+ when 3 # COUNTER
460
+ handle_counter(probe, binding_ctx)
461
+ when 4 # METRIC
462
+ handle_metric(probe, binding_ctx)
463
+ when 5 # DURATION
464
+ handle_duration(probe, binding_ctx, is_secondary)
465
+ end
466
+ end
467
+
468
+ def handle_snapshot(probe, binding_ctx, local_names = nil)
469
+ probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
470
+
471
+ max_depth = get_probe_positive_int(probe, :max_object_depth, 3)
472
+ max_array_length = get_probe_positive_int(probe, :max_array_length, 3)
473
+ max_object_properties = get_probe_positive_int(probe, :max_object_properties, 50)
474
+ max_string_length = get_probe_positive_int(probe, :max_string_length, 1024)
475
+ stack_frame_depth = [get_probe_positive_int(probe, :stack_frame_depth, 3), MAX_STACK_FRAMES].min
476
+ redact_keys = @global_config[:redact_keys] || @global_config['redact_keys']
477
+ redact_values = @global_config[:redact_values] || @global_config['redact_values']
478
+
479
+ serializer = Serializer.new(
480
+ max_depth: max_depth,
481
+ max_array_length: max_array_length,
482
+ max_object_properties: max_object_properties,
483
+ max_string_length: max_string_length,
484
+ redact_keys: redact_keys,
485
+ redact_values: redact_values
486
+ )
487
+
488
+ visited = {}
489
+
490
+ # 1. Watch Expressions
491
+ watch_expressions = get_probe_opt(probe, :watch_expressions) || []
492
+ serialized_watches = {}
493
+ if watch_expressions && !watch_expressions.empty?
494
+ raw_watches = Evaluator.evaluate_watches(watch_expressions.to_a, binding_ctx, safe: @safe_evaluation)
495
+ raw_watches.each do |expr, val|
496
+ watch_path = "watch[#{JSON.generate(expr)}]"
497
+ serialized_watches[expr] = serializer.serialize(val, watch_path, 0, visited)
498
+ end
499
+ end
500
+
501
+ # 2. Unified Stack Frames & Multi-Frame Local Variables Extraction
502
+ stack_frames, captured_vars = extract_stack_and_variables(
503
+ probe,
504
+ binding_ctx,
505
+ stack_frame_depth,
506
+ serializer,
507
+ visited,
508
+ local_names
509
+ )
510
+
511
+ event = build_base_event(probe_id)
512
+ event[:stack_frames] = stack_frames
513
+ event[:captured_vars_json] = Serializer.safe_dump_json(captured_vars)
514
+ event[:watch_results_json] = Serializer.safe_dump_json(serialized_watches)
515
+
516
+ emit_event(probe, event)
517
+ rescue StandardError, ScriptError, SecurityError => e
518
+ report_error(probe_id, "Snapshot capture failed: #{e.class.name}: #{e.message}")
519
+ ensure
520
+ serializer&.finish_capture
521
+ end
522
+
523
+ def handle_log(probe, binding_ctx)
524
+ probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
525
+ template = get_probe_opt(probe, :template) || ''
526
+ max_string_length = get_probe_positive_int(probe, :max_string_length, 1024)
527
+ redact_values = @global_config[:redact_values] || @global_config['redact_values']
528
+
529
+ evaluated = Evaluator.evaluate_log_template(template, binding_ctx, safe: @safe_evaluation)
530
+
531
+ # Redact values
532
+ if redact_values && !redact_values.empty?
533
+ regex = Serializer.new(redact_values: redact_values).send(:compile_regex, redact_values)
534
+ evaluated = evaluated.gsub(regex, '[REDACTED Value]') if regex
535
+ end
536
+
537
+ # Truncate string
538
+ if evaluated.length > max_string_length
539
+ head = evaluated[0...max_string_length]
540
+ truncated_head = head.sub(/\b\w*$/, '').rstrip
541
+ truncated_head = head if truncated_head.empty?
542
+ evaluated = "#{truncated_head}... [Truncated: +#{evaluated.length - max_string_length} more chars]"
543
+ end
544
+
545
+ # Stdout logging if configured
546
+ should_log_stdout = get_probe_opt(probe, :should_log_to_stdout)
547
+ if should_log_stdout
548
+ log_level = (get_probe_opt(probe, :log_level) || 'INFO').to_s.upcase
549
+ color = LEVEL_COLORS[log_level] || "\e[37m"
550
+ timestamp = Time.now.utc.iso8601(3)
551
+ out_line = "#{COLOR_GRAY}[#{timestamp}]#{COLOR_RESET} " \
552
+ "#{COLOR_BOLD}#{color}[#{log_level}]#{COLOR_RESET} " \
553
+ "#{COLOR_BOLD}#{COLOR_MAGENTA}[HyperProbe LOG]#{COLOR_RESET} " \
554
+ "#{evaluated}\n"
555
+ @stdout_queue.push(out_line, true) rescue nil
556
+ end
557
+
558
+ event = build_base_event(probe_id)
559
+ event[:evaluated_log] = evaluated
560
+ emit_event(probe, event)
561
+ rescue StandardError, ScriptError, SecurityError => e
562
+ report_error(probe_id, "Log evaluation failed: #{e.class.name}: #{e.message}")
563
+ end
564
+
565
+ def handle_counter(probe, _binding_ctx)
566
+ probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
567
+ event = build_base_event(probe_id)
568
+ event[:metric_value] = 1.0
569
+ emit_event(probe, event)
570
+ end
571
+
572
+ def handle_metric(probe, binding_ctx)
573
+ probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
574
+ metric_expr = get_probe_opt(probe, :metric_expression)
575
+ return if metric_expr.nil? || metric_expr.empty?
576
+
577
+ begin
578
+ val = Evaluator.evaluate_metric(metric_expr, binding_ctx, safe: @safe_evaluation)
579
+ rescue StandardError, ScriptError, SecurityError => e
580
+ report_error(probe_id, "Metric evaluation failed: #{e.class.name}: #{e.message}")
581
+ return
582
+ end
583
+
584
+ event = build_base_event(probe_id)
585
+ if val
586
+ event[:metric_value] = val
587
+ else
588
+ event[:capture_error] = 'Metric evaluation failed: expression must return a finite number'
589
+ end
590
+ emit_event(probe, event)
591
+ end
592
+
593
+ def handle_duration(probe, binding_ctx, is_secondary)
594
+ probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
595
+ corr_expr = get_probe_opt(probe, :correlation_expression)
596
+
597
+ corr_id = nil
598
+ begin
599
+ corr_id = Evaluator.evaluate_correlation(corr_expr, binding_ctx, safe: @safe_evaluation)
600
+ rescue StandardError, ScriptError, SecurityError => e
601
+ report_error(probe_id, "Duration correlation failed: #{e.message}")
602
+ return
603
+ end
604
+
605
+ dur_key = [probe_id, corr_id]
606
+
607
+ if !is_secondary
608
+ now_mono = monotonic_time
609
+ @mutex.synchronize do
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
615
+ @duration_starts[dur_key] = [now_mono, now_mono]
616
+ end
617
+ else
618
+ now_mono = monotonic_time
619
+ start_entry = nil
620
+ @mutex.synchronize do
621
+ cleanup_durations_unlocked(now_mono)
622
+ start_entry = @duration_starts.delete(dur_key)
623
+ end
624
+ return unless start_entry
625
+ unless @quota_manager.can_evaluate?
626
+ @mutex.synchronize { @total_skips += 1 }
627
+ return
628
+ end
629
+
630
+ duration_ms = (now_mono - start_entry[0]) * 1000.0
631
+ event = build_base_event(probe_id)
632
+ event[:metric_value] = duration_ms
633
+ emit_event(probe, event)
634
+ end
635
+ end
636
+
637
+ def build_base_event(probe_id)
638
+ event = {
639
+ probe_id: probe_id,
640
+ timestamp_ms: (Time.now.to_f * 1000).to_i,
641
+ stack_frames: [],
642
+ captured_vars_json: '',
643
+ watch_results_json: '',
644
+ evaluated_log: '',
645
+ metric_value: 0.0,
646
+ capture_error: '',
647
+ trace_id: nil
648
+ }
649
+
650
+ probe = @probe_map[probe_id]
651
+ if probe && get_probe_opt(probe, :should_capture_trace_id)
652
+ trace_id = TraceExtractor.extract_trace_context(@custom_set_trace_id)
653
+ event[:trace_id] = trace_id if trace_id && !trace_id.empty?
654
+ end
655
+
656
+ event
657
+ end
658
+
659
+ def emit_event(_probe, event)
660
+ @mutex.synchronize { @total_hits += 1 }
661
+ @on_capture.call(event)
662
+ end
663
+
664
+ def report_error(probe_id, error_message)
665
+ event = build_base_event(probe_id)
666
+ event[:capture_error] = error_message
667
+ emit_event(nil, event)
668
+ end
669
+
670
+ def extract_stack_and_variables(probe, binding_ctx, stack_frame_depth, serializer, visited, local_names)
671
+ stack_frames = []
672
+ captured_vars = []
673
+
674
+ snapshot_frames(binding_ctx, local_names, stack_frame_depth).each_with_index do |(loc, current_b, frame_local_names), frame_idx|
675
+
676
+ func = loc&.label || loc&.base_label || '(anonymous)'
677
+ file = loc ? (loc.absolute_path || loc.path || 'unknown') : 'unknown'
678
+ line_no = loc&.lineno || 0
679
+
680
+ # If frame_idx == 0 and location is unpopulated, fallback to probe location
681
+ if frame_idx == 0 && (file == 'unknown' || line_no == 0)
682
+ runtime_loc = probe.respond_to?(:runtime_location) ? probe.runtime_location : probe[:runtime_location]
683
+ line_no = (probe.respond_to?(:runtime_line) ? probe.runtime_line : probe[:runtime_line]).to_i
684
+ file = runtime_loc
685
+ end
686
+
687
+ stack_frames << {
688
+ function_name: func,
689
+ file_name: file,
690
+ line_number: line_no,
691
+ column_number: 0
692
+ }
693
+
694
+ locals_hash = {}
695
+ closure_hash = {}
696
+ if current_b
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
702
+ var_name = var_sym.to_s
703
+
704
+ begin
705
+ target = is_local ? locals_hash : closure_hash
706
+ target[var_name] = LOCAL_GET.bind(current_b).call(var_sym)
707
+ rescue StandardError, ScriptError => e
708
+ locals_hash[var_name] = "[Error accessing variable: #{e.message}]"
709
+ end
710
+ end
711
+ end
712
+
713
+ serialized_locals = locals_hash.empty? ? {} : serializer.serialize(locals_hash, "frame[#{frame_idx}].scopes[0]", 0, visited)
714
+
715
+ captured_vars << [
716
+ {
717
+ 'type' => 'local',
718
+ 'name' => 'Local',
719
+ 'vars' => serialized_locals
720
+ }
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
728
+ end
729
+
730
+ [stack_frames, captured_vars]
731
+ end
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
+
812
+ def get_probe_positive_int(probe, key, default_val)
813
+ val = get_probe_opt(probe, key)
814
+ return val if val.is_a?(Integer) && val >= 0
815
+
816
+ global_val = @global_config[key] || @global_config[key.to_s]
817
+ return global_val if global_val.is_a?(Integer) && global_val >= 0
818
+
819
+ default_val
820
+ end
821
+
822
+ def get_probe_opt(probe, key)
823
+ if probe.respond_to?(key)
824
+ presence = "has_#{key}?"
825
+ return nil if probe.respond_to?(presence) && !probe.public_send(presence)
826
+ probe.public_send(key)
827
+ elsif probe.is_a?(Hash)
828
+ probe.key?(key) ? probe[key] : probe[key.to_s]
829
+ end
830
+ end
831
+
832
+ def probe_type_to_i(type)
833
+ case type
834
+ when :PROBE_TYPE_SNAPSHOT, 'PROBE_TYPE_SNAPSHOT', 1 then 1
835
+ when :PROBE_TYPE_LOG, 'PROBE_TYPE_LOG', 2 then 2
836
+ when :PROBE_TYPE_COUNTER, 'PROBE_TYPE_COUNTER', 3 then 3
837
+ when :PROBE_TYPE_METRIC, 'PROBE_TYPE_METRIC', 4 then 4
838
+ when :PROBE_TYPE_DURATION, 'PROBE_TYPE_DURATION', 5 then 5
839
+ else 0
840
+ end
841
+ end
842
+
843
+ def cleanup_durations_unlocked(now)
844
+ return if now < @next_duration_cleanup && @duration_starts.size < MAX_DURATION_ENTRIES
845
+
846
+ @duration_starts.delete_if do |_key, (_start_time, created_at)|
847
+ now - created_at > DURATION_TTL_SECONDS
848
+ end
849
+ @next_duration_cleanup = now + DURATION_TTL_SECONDS
850
+ end
851
+
852
+ def monotonic_time
853
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
854
+ end
855
+ end
856
+ end
857
+ end