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.
@@ -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
- begin
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 = Mutex.new
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
- new_active_locations[primary_key] << { probe: probe, is_secondary: false }
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 enable_location_tracepoint_unlocked(loc_key)
181
- resolved_path, lineno = loc_key
182
-
183
- tp = TracePoint.new(:line) do |t|
184
- next if @is_suspended || @closed
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
- @mutex.synchronize do
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
- if contexts && !contexts.empty?
196
- binding_ctx = t.binding
197
- handle_hit(contexts, binding_ctx)
198
- 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
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
- next if mod.singleton_class? || mod.name.nil?
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
- methods_list = mod.send(mtype, false) rescue []
345
+ implementation = mtype == :instance_methods ? INSTANCE_METHODS : METHODS
346
+ methods_list = implementation.bind(mod).call(false)
241
347
  methods_list.each do |sym|
242
- m = (mtype == :instance_methods ? mod.instance_method(sym) : mod.method(sym)) rescue nil
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
- if (m_file == file_path || resolve_filepath(m_file) == file_path) && m_line <= line_number && m_line > best_line
252
- best_match = m
253
- best_line = m_line
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 StandardError => e
302
- # Never crash application thread on probe hit
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
- $stdout.write(out_line) rescue nil
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
- raw_val = binding_ctx.eval(metric_expr)
456
- val = Evaluator.coerce_metric_value(raw_val)
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] = "Metric evaluation failed: #{raw_val.inspect}"
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
- total_frames = [user_locations.length, stack_frame_depth].min
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 ? resolve_filepath(loc.absolute_path || loc.path || 'unknown') : 'unknown'
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 = resolve_filepath(runtime_loc)
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
- current_b.local_variables.each do |var_sym|
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
- locals_hash[var_name] = current_b.local_variable_get(var_sym)
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.to_i if val.is_a?(Numeric) && val.to_i.positive?
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.to_i if global_val.is_a?(Numeric) && global_val.to_i.positive?
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] || probe[key.to_s]
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