hyperprobe-agent 1.2.27.pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +13 -0
- data/README.md +157 -0
- data/lib/hyperprobe/agent.rb +438 -0
- data/lib/hyperprobe/core/broker.rb +213 -0
- data/lib/hyperprobe/core/evaluator.rb +128 -0
- data/lib/hyperprobe/core/logger.rb +177 -0
- data/lib/hyperprobe/core/monitoring_engine.rb +683 -0
- data/lib/hyperprobe/core/quota.rb +111 -0
- data/lib/hyperprobe/core/safe_ast_validator.rb +91 -0
- data/lib/hyperprobe/core/safety.rb +179 -0
- data/lib/hyperprobe/core/serializer.rb +425 -0
- data/lib/hyperprobe/core/trace_extractor.rb +158 -0
- data/lib/hyperprobe/lambda.rb +90 -0
- data/lib/hyperprobe/protos/agent_pb.rb +33 -0
- data/lib/hyperprobe/protos/agent_services_pb.rb +31 -0
- data/lib/hyperprobe/protos.rb +14 -0
- data/lib/hyperprobe/railtie.rb +16 -0
- data/lib/hyperprobe/version.rb +5 -0
- data/lib/hyperprobe-agent.rb +3 -0
- data/lib/hyperprobe.rb +153 -0
- metadata +149 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'logger'
|
|
6
|
+
|
|
7
|
+
begin
|
|
8
|
+
require 'binding_of_caller'
|
|
9
|
+
rescue LoadError
|
|
10
|
+
# binding_of_caller is optional
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module HyperProbe
|
|
14
|
+
module Core
|
|
15
|
+
class MonitoringEngine
|
|
16
|
+
DURATION_TTL_SECONDS = 60.0
|
|
17
|
+
|
|
18
|
+
LEVEL_COLORS = {
|
|
19
|
+
'ERROR' => "\e[31m",
|
|
20
|
+
'WARN' => "\e[33m",
|
|
21
|
+
'INFO' => "\e[32m",
|
|
22
|
+
'DEBUG' => "\e[36m"
|
|
23
|
+
}.freeze
|
|
24
|
+
COLOR_RESET = "\e[0m"
|
|
25
|
+
COLOR_BOLD = "\e[1m"
|
|
26
|
+
COLOR_GRAY = "\e[90m"
|
|
27
|
+
COLOR_MAGENTA = "\e[35m"
|
|
28
|
+
|
|
29
|
+
attr_reader :total_hits, :total_skips, :is_suspended, :is_active
|
|
30
|
+
|
|
31
|
+
def initialize(quota_manager, safety_monitor, on_capture, custom_set_trace_id = nil)
|
|
32
|
+
@quota_manager = quota_manager
|
|
33
|
+
@safety_monitor = safety_monitor
|
|
34
|
+
@on_capture = on_capture
|
|
35
|
+
@custom_set_trace_id = custom_set_trace_id
|
|
36
|
+
|
|
37
|
+
@mutex = Mutex.new
|
|
38
|
+
@active_locations = {} # [resolved_file_path, line] => Array<ActiveProbeContext>
|
|
39
|
+
@probe_map = {} # probe_id => Probe
|
|
40
|
+
@instrumented_files = Set.new
|
|
41
|
+
@path_cache = {}
|
|
42
|
+
@duration_starts = {} # [probe_id, correlation_id] => [start_time_monotonic, created_at_time]
|
|
43
|
+
@next_duration_cleanup = monotonic_time + DURATION_TTL_SECONDS
|
|
44
|
+
|
|
45
|
+
@global_config = {}
|
|
46
|
+
@total_hits = 0
|
|
47
|
+
@total_skips = 0
|
|
48
|
+
@is_suspended = false
|
|
49
|
+
@is_active = false
|
|
50
|
+
@closed = false
|
|
51
|
+
|
|
52
|
+
@log = Logger.get_logger('hyperprobe:inspector')
|
|
53
|
+
@targeted_tracepoints = {} # location_key => TracePoint instance
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set_global_config(config)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@global_config = (config || {}).dup
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_probes(probes)
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
return if @closed
|
|
65
|
+
|
|
66
|
+
new_active_locations = {}
|
|
67
|
+
new_probe_map = {}
|
|
68
|
+
new_instrumented_files = Set.new
|
|
69
|
+
|
|
70
|
+
Array(probes).each do |probe|
|
|
71
|
+
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
72
|
+
next unless probe_id
|
|
73
|
+
|
|
74
|
+
new_probe_map[probe_id] = probe
|
|
75
|
+
|
|
76
|
+
runtime_loc = probe.respond_to?(:runtime_location) ? probe.runtime_location : probe[:runtime_location]
|
|
77
|
+
runtime_line = (probe.respond_to?(:runtime_line) ? probe.runtime_line : probe[:runtime_line]).to_i
|
|
78
|
+
|
|
79
|
+
resolved_path = resolve_filepath(runtime_loc)
|
|
80
|
+
primary_key = [resolved_path, runtime_line]
|
|
81
|
+
|
|
82
|
+
new_active_locations[primary_key] ||= []
|
|
83
|
+
new_active_locations[primary_key] << { probe: probe, is_secondary: false }
|
|
84
|
+
new_instrumented_files.add(resolved_path)
|
|
85
|
+
|
|
86
|
+
probe_type = probe.respond_to?(:type) ? probe.type : probe[:type]
|
|
87
|
+
# Duration secondary location
|
|
88
|
+
is_duration = probe_type == 5 || probe_type == :PROBE_TYPE_DURATION
|
|
89
|
+
if is_duration
|
|
90
|
+
sec_line = (probe.respond_to?(:secondary_runtime_line) ? probe.secondary_runtime_line : probe[:secondary_runtime_line]).to_i
|
|
91
|
+
if sec_line.positive?
|
|
92
|
+
sec_loc = probe.respond_to?(:secondary_runtime_location) ? probe.secondary_runtime_location : probe[:secondary_runtime_location]
|
|
93
|
+
sec_loc = runtime_loc if sec_loc.nil? || sec_loc.empty?
|
|
94
|
+
resolved_sec_path = resolve_filepath(sec_loc)
|
|
95
|
+
secondary_key = [resolved_sec_path, sec_line]
|
|
96
|
+
|
|
97
|
+
new_active_locations[secondary_key] ||= []
|
|
98
|
+
new_active_locations[secondary_key] << { probe: probe, is_secondary: true }
|
|
99
|
+
new_instrumented_files.add(resolved_sec_path)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@active_locations = new_active_locations
|
|
105
|
+
@probe_map = new_probe_map
|
|
106
|
+
@instrumented_files = new_instrumented_files
|
|
107
|
+
|
|
108
|
+
update_tracepoint_state_unlocked
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def suspend
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
return if @closed || @is_suspended
|
|
115
|
+
|
|
116
|
+
@is_suspended = true
|
|
117
|
+
disable_all_tracepoints_unlocked
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resume
|
|
122
|
+
@mutex.synchronize do
|
|
123
|
+
return if @closed || !@is_suspended
|
|
124
|
+
|
|
125
|
+
@is_suspended = false
|
|
126
|
+
update_tracepoint_state_unlocked
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def get_stats
|
|
131
|
+
@mutex.synchronize do
|
|
132
|
+
cleanup_durations_unlocked(monotonic_time)
|
|
133
|
+
stats = { hits: @total_hits, skips: @total_skips }
|
|
134
|
+
@total_hits = 0
|
|
135
|
+
@total_skips = 0
|
|
136
|
+
stats
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def close
|
|
141
|
+
@mutex.synchronize do
|
|
142
|
+
return if @closed
|
|
143
|
+
|
|
144
|
+
@closed = true
|
|
145
|
+
disable_all_tracepoints_unlocked
|
|
146
|
+
@active_locations.clear
|
|
147
|
+
@probe_map.clear
|
|
148
|
+
@instrumented_files.clear
|
|
149
|
+
@duration_starts.clear
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def update_tracepoint_state_unlocked
|
|
156
|
+
if @closed || @is_suspended || @active_locations.empty?
|
|
157
|
+
disable_all_tracepoints_unlocked
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# 1. Remove stale tracepoints
|
|
162
|
+
active_keys = @active_locations.keys.to_set
|
|
163
|
+
@targeted_tracepoints.delete_if do |loc_key, tp|
|
|
164
|
+
unless active_keys.include?(loc_key)
|
|
165
|
+
tp.disable rescue nil
|
|
166
|
+
true
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# 2. Setup or enable targeted tracepoint for each active location
|
|
171
|
+
@active_locations.each_key do |loc_key|
|
|
172
|
+
next if @targeted_tracepoints.key?(loc_key)
|
|
173
|
+
|
|
174
|
+
enable_location_tracepoint_unlocked(loc_key)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@is_active = !@targeted_tracepoints.empty?
|
|
178
|
+
end
|
|
179
|
+
|
|
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
|
|
189
|
+
|
|
190
|
+
contexts = nil
|
|
191
|
+
@mutex.synchronize do
|
|
192
|
+
contexts = @active_locations[loc_key]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if contexts && !contexts.empty?
|
|
196
|
+
binding_ctx = t.binding
|
|
197
|
+
handle_hit(contexts, binding_ctx)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
target_method = find_method_target(resolved_path, lineno)
|
|
202
|
+
targeted_enabled = false
|
|
203
|
+
|
|
204
|
+
if target_method
|
|
205
|
+
begin
|
|
206
|
+
tp.enable(target: target_method, target_line: lineno)
|
|
207
|
+
targeted_enabled = true
|
|
208
|
+
rescue ArgumentError, StandardError
|
|
209
|
+
# Target option not accepted by interpreter
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
unless targeted_enabled
|
|
214
|
+
begin
|
|
215
|
+
tp.enable
|
|
216
|
+
rescue StandardError
|
|
217
|
+
# TracePoint enable error
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
@targeted_tracepoints[loc_key] = tp
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def disable_all_tracepoints_unlocked
|
|
225
|
+
@targeted_tracepoints.each_value do |tp|
|
|
226
|
+
tp.disable rescue nil
|
|
227
|
+
end
|
|
228
|
+
@targeted_tracepoints.clear
|
|
229
|
+
@is_active = false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def find_method_target(file_path, line_number)
|
|
233
|
+
best_match = nil
|
|
234
|
+
best_line = 0
|
|
235
|
+
|
|
236
|
+
ObjectSpace.each_object(Module) do |mod|
|
|
237
|
+
next if mod.singleton_class? || mod.name.nil?
|
|
238
|
+
|
|
239
|
+
[:instance_methods, :methods].each do |mtype|
|
|
240
|
+
methods_list = mod.send(mtype, false) rescue []
|
|
241
|
+
methods_list.each do |sym|
|
|
242
|
+
m = (mtype == :instance_methods ? mod.instance_method(sym) : mod.method(sym)) rescue nil
|
|
243
|
+
next unless m
|
|
244
|
+
|
|
245
|
+
loc = m.source_location
|
|
246
|
+
next unless loc
|
|
247
|
+
|
|
248
|
+
m_file = loc[0]
|
|
249
|
+
m_line = loc[1]
|
|
250
|
+
|
|
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
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
best_match
|
|
260
|
+
rescue StandardError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def resolve_filepath(runtime_location)
|
|
265
|
+
return '' if runtime_location.nil? || runtime_location.empty?
|
|
266
|
+
|
|
267
|
+
cached = @path_cache[runtime_location]
|
|
268
|
+
return cached if cached
|
|
269
|
+
|
|
270
|
+
if File.exist?(runtime_location)
|
|
271
|
+
resolved = File.realpath(runtime_location) rescue File.expand_path(runtime_location)
|
|
272
|
+
@path_cache[runtime_location] = resolved
|
|
273
|
+
return resolved
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
cwd = Dir.pwd
|
|
277
|
+
parts = runtime_location.tr('\\', '/').split('/').reject(&:empty?)
|
|
278
|
+
(0...parts.length).each do |i|
|
|
279
|
+
suffix = File.join(*parts[i..])
|
|
280
|
+
attempt = File.expand_path(suffix, cwd)
|
|
281
|
+
if File.exist?(attempt)
|
|
282
|
+
resolved = File.realpath(attempt) rescue attempt
|
|
283
|
+
@path_cache[runtime_location] = resolved
|
|
284
|
+
return resolved
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
fallback = File.expand_path(runtime_location, cwd)
|
|
289
|
+
@path_cache[runtime_location] = fallback
|
|
290
|
+
fallback
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def handle_hit(contexts, binding_ctx)
|
|
294
|
+
start_time = monotonic_time
|
|
295
|
+
begin
|
|
296
|
+
contexts.each do |ctx|
|
|
297
|
+
probe = ctx[:probe]
|
|
298
|
+
is_secondary = ctx[:is_secondary]
|
|
299
|
+
process_single_probe_hit(probe, binding_ctx, is_secondary)
|
|
300
|
+
end
|
|
301
|
+
rescue StandardError => e
|
|
302
|
+
# Never crash application thread on probe hit
|
|
303
|
+
ensure
|
|
304
|
+
elapsed_ms = (monotonic_time - start_time) * 1000.0
|
|
305
|
+
@safety_monitor.report_pause_duration(elapsed_ms) rescue nil
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def process_single_probe_hit(probe, binding_ctx, is_secondary)
|
|
310
|
+
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
311
|
+
probe_type = probe.respond_to?(:type) ? probe.type : probe[:type]
|
|
312
|
+
probe_type_num = probe_type.is_a?(Integer) ? probe_type : probe_type_to_i(probe_type)
|
|
313
|
+
|
|
314
|
+
# 1. Rate-limiting admission check
|
|
315
|
+
unless @quota_manager.can_evaluate?
|
|
316
|
+
@mutex.synchronize { @total_skips += 1 }
|
|
317
|
+
return
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# 2. Evaluate Condition (if any)
|
|
321
|
+
condition = probe.respond_to?(:condition) ? probe.condition : probe[:condition]
|
|
322
|
+
if condition && !condition.empty?
|
|
323
|
+
begin
|
|
324
|
+
passed = Evaluator.eval_condition(condition, binding_ctx)
|
|
325
|
+
return unless passed
|
|
326
|
+
rescue StandardError, ScriptError => e
|
|
327
|
+
report_error(probe_id, "Condition evaluation failed: #{e.class.name}: #{e.message}")
|
|
328
|
+
return
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# 3. Dispatch to Probe Type Handler
|
|
333
|
+
case probe_type_num
|
|
334
|
+
when 1 # SNAPSHOT
|
|
335
|
+
handle_snapshot(probe, binding_ctx)
|
|
336
|
+
when 2 # LOG
|
|
337
|
+
handle_log(probe, binding_ctx)
|
|
338
|
+
when 3 # COUNTER
|
|
339
|
+
handle_counter(probe, binding_ctx)
|
|
340
|
+
when 4 # METRIC
|
|
341
|
+
handle_metric(probe, binding_ctx)
|
|
342
|
+
when 5 # DURATION
|
|
343
|
+
handle_duration(probe, binding_ctx, is_secondary)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def handle_snapshot(probe, binding_ctx)
|
|
348
|
+
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
349
|
+
|
|
350
|
+
max_depth = get_probe_positive_int(probe, :max_object_depth, 3)
|
|
351
|
+
max_array_length = get_probe_positive_int(probe, :max_array_length, 3)
|
|
352
|
+
max_object_properties = get_probe_positive_int(probe, :max_object_properties, 50)
|
|
353
|
+
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)
|
|
355
|
+
redact_keys = @global_config[:redact_keys] || @global_config['redact_keys']
|
|
356
|
+
redact_values = @global_config[:redact_values] || @global_config['redact_values']
|
|
357
|
+
|
|
358
|
+
serializer = Serializer.new(
|
|
359
|
+
max_depth: max_depth,
|
|
360
|
+
max_array_length: max_array_length,
|
|
361
|
+
max_object_properties: max_object_properties,
|
|
362
|
+
max_string_length: max_string_length,
|
|
363
|
+
redact_keys: redact_keys,
|
|
364
|
+
redact_values: redact_values
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
visited = {}
|
|
368
|
+
|
|
369
|
+
# 1. Watch Expressions
|
|
370
|
+
watch_expressions = get_probe_opt(probe, :watch_expressions) || []
|
|
371
|
+
serialized_watches = {}
|
|
372
|
+
if watch_expressions && !watch_expressions.empty?
|
|
373
|
+
raw_watches = Evaluator.evaluate_watches(watch_expressions, binding_ctx)
|
|
374
|
+
raw_watches.each do |expr, val|
|
|
375
|
+
watch_path = "watch[#{JSON.generate(expr)}]"
|
|
376
|
+
serialized_watches[expr] = serializer.serialize(val, watch_path, 0, visited)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# 2. Unified Stack Frames & Multi-Frame Local Variables Extraction
|
|
381
|
+
stack_frames, captured_vars = extract_stack_and_variables(
|
|
382
|
+
probe,
|
|
383
|
+
binding_ctx,
|
|
384
|
+
stack_frame_depth,
|
|
385
|
+
serializer,
|
|
386
|
+
visited
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
event = build_base_event(probe_id)
|
|
390
|
+
event[:stack_frames] = stack_frames
|
|
391
|
+
event[:captured_vars_json] = Serializer.safe_dump_json(captured_vars)
|
|
392
|
+
event[:watch_results_json] = Serializer.safe_dump_json(serialized_watches)
|
|
393
|
+
|
|
394
|
+
emit_event(probe, event)
|
|
395
|
+
rescue StandardError => e
|
|
396
|
+
report_error(probe_id, "Snapshot capture failed: #{e.class.name}: #{e.message}")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def handle_log(probe, binding_ctx)
|
|
400
|
+
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
401
|
+
template = get_probe_opt(probe, :template) || ''
|
|
402
|
+
max_string_length = get_probe_positive_int(probe, :max_string_length, 1024)
|
|
403
|
+
redact_values = @global_config[:redact_values] || @global_config['redact_values']
|
|
404
|
+
|
|
405
|
+
evaluated = Evaluator.evaluate_log_template(template, binding_ctx)
|
|
406
|
+
|
|
407
|
+
# Redact values
|
|
408
|
+
if redact_values && !redact_values.empty?
|
|
409
|
+
regex = Serializer.new(redact_values: redact_values).send(:compile_regex, redact_values)
|
|
410
|
+
evaluated = evaluated.gsub(regex, '[REDACTED Value]') if regex
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Truncate string
|
|
414
|
+
if evaluated.length > max_string_length
|
|
415
|
+
head = evaluated[0...max_string_length]
|
|
416
|
+
truncated_head = head.sub(/\b\w*$/, '').rstrip
|
|
417
|
+
truncated_head = head if truncated_head.empty?
|
|
418
|
+
evaluated = "#{truncated_head}... [Truncated: +#{evaluated.length - max_string_length} more chars]"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Stdout logging if configured
|
|
422
|
+
should_log_stdout = get_probe_opt(probe, :should_log_to_stdout)
|
|
423
|
+
if should_log_stdout
|
|
424
|
+
log_level = (get_probe_opt(probe, :log_level) || 'INFO').to_s.upcase
|
|
425
|
+
color = LEVEL_COLORS[log_level] || "\e[37m"
|
|
426
|
+
timestamp = Time.now.utc.iso8601(3)
|
|
427
|
+
out_line = "#{COLOR_GRAY}[#{timestamp}]#{COLOR_RESET} " \
|
|
428
|
+
"#{COLOR_BOLD}#{color}[#{log_level}]#{COLOR_RESET} " \
|
|
429
|
+
"#{COLOR_BOLD}#{COLOR_MAGENTA}[HyperProbe LOG]#{COLOR_RESET} " \
|
|
430
|
+
"#{evaluated}\n"
|
|
431
|
+
$stdout.write(out_line) rescue nil
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
event = build_base_event(probe_id)
|
|
435
|
+
event[:evaluated_log] = evaluated
|
|
436
|
+
emit_event(probe, event)
|
|
437
|
+
rescue StandardError => e
|
|
438
|
+
report_error(probe_id, "Log evaluation failed: #{e.class.name}: #{e.message}")
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def handle_counter(probe, _binding_ctx)
|
|
442
|
+
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
443
|
+
event = build_base_event(probe_id)
|
|
444
|
+
event[:metric_value] = 1.0
|
|
445
|
+
emit_event(probe, event)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def handle_metric(probe, binding_ctx)
|
|
449
|
+
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
450
|
+
metric_expr = get_probe_opt(probe, :metric_expression)
|
|
451
|
+
return if metric_expr.nil? || metric_expr.empty?
|
|
452
|
+
|
|
453
|
+
raw_val = nil
|
|
454
|
+
begin
|
|
455
|
+
raw_val = binding_ctx.eval(metric_expr)
|
|
456
|
+
val = Evaluator.coerce_metric_value(raw_val)
|
|
457
|
+
rescue StandardError, ScriptError => e
|
|
458
|
+
report_error(probe_id, "Metric evaluation failed: #{e.class.name}: #{e.message}")
|
|
459
|
+
return
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
event = build_base_event(probe_id)
|
|
463
|
+
if val
|
|
464
|
+
event[:metric_value] = val
|
|
465
|
+
else
|
|
466
|
+
event[:capture_error] = "Metric evaluation failed: #{raw_val.inspect}"
|
|
467
|
+
end
|
|
468
|
+
emit_event(probe, event)
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def handle_duration(probe, binding_ctx, is_secondary)
|
|
472
|
+
probe_id = probe.respond_to?(:id) ? probe.id : probe[:id]
|
|
473
|
+
corr_expr = get_probe_opt(probe, :correlation_expression)
|
|
474
|
+
|
|
475
|
+
corr_id = nil
|
|
476
|
+
begin
|
|
477
|
+
corr_id = Evaluator.evaluate_correlation(corr_expr, binding_ctx)
|
|
478
|
+
rescue StandardError, ScriptError => e
|
|
479
|
+
report_error(probe_id, "Duration correlation failed: #{e.message}")
|
|
480
|
+
return
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
dur_key = [probe_id, corr_id]
|
|
484
|
+
|
|
485
|
+
if !is_secondary
|
|
486
|
+
now_mono = monotonic_time
|
|
487
|
+
@mutex.synchronize do
|
|
488
|
+
cleanup_durations_unlocked(now_mono)
|
|
489
|
+
@duration_starts[dur_key] = [now_mono, now_mono]
|
|
490
|
+
end
|
|
491
|
+
else
|
|
492
|
+
now_mono = monotonic_time
|
|
493
|
+
start_entry = nil
|
|
494
|
+
@mutex.synchronize do
|
|
495
|
+
cleanup_durations_unlocked(now_mono)
|
|
496
|
+
start_entry = @duration_starts.delete(dur_key)
|
|
497
|
+
end
|
|
498
|
+
return unless start_entry
|
|
499
|
+
|
|
500
|
+
duration_ms = (now_mono - start_entry[0]) * 1000.0
|
|
501
|
+
event = build_base_event(probe_id)
|
|
502
|
+
event[:metric_value] = duration_ms
|
|
503
|
+
emit_event(probe, event)
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def build_base_event(probe_id)
|
|
508
|
+
event = {
|
|
509
|
+
probe_id: probe_id,
|
|
510
|
+
timestamp_ms: (Time.now.to_f * 1000).to_i,
|
|
511
|
+
stack_frames: [],
|
|
512
|
+
captured_vars_json: '',
|
|
513
|
+
watch_results_json: '',
|
|
514
|
+
evaluated_log: '',
|
|
515
|
+
metric_value: 0.0,
|
|
516
|
+
capture_error: '',
|
|
517
|
+
trace_id: nil
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
probe = @probe_map[probe_id]
|
|
521
|
+
if probe && get_probe_opt(probe, :should_capture_trace_id)
|
|
522
|
+
trace_id = TraceExtractor.extract_trace_context(@custom_set_trace_id)
|
|
523
|
+
event[:trace_id] = trace_id if trace_id && !trace_id.empty?
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
event
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def emit_event(_probe, event)
|
|
530
|
+
@mutex.synchronize { @total_hits += 1 }
|
|
531
|
+
@on_capture.call(event)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def report_error(probe_id, error_message)
|
|
535
|
+
event = build_base_event(probe_id)
|
|
536
|
+
event[:capture_error] = error_message
|
|
537
|
+
emit_event(nil, event)
|
|
538
|
+
end
|
|
539
|
+
|
|
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
|
+
|
|
582
|
+
stack_frames = []
|
|
583
|
+
captured_vars = []
|
|
584
|
+
|
|
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]
|
|
590
|
+
|
|
591
|
+
func = loc&.label || loc&.base_label || '(anonymous)'
|
|
592
|
+
file = loc ? resolve_filepath(loc.absolute_path || loc.path || 'unknown') : 'unknown'
|
|
593
|
+
line_no = loc&.lineno || 0
|
|
594
|
+
|
|
595
|
+
# If frame_idx == 0 and location is unpopulated, fallback to probe location
|
|
596
|
+
if frame_idx == 0 && (file == 'unknown' || line_no == 0)
|
|
597
|
+
runtime_loc = probe.respond_to?(:runtime_location) ? probe.runtime_location : probe[:runtime_location]
|
|
598
|
+
line_no = (probe.respond_to?(:runtime_line) ? probe.runtime_line : probe[:runtime_line]).to_i
|
|
599
|
+
file = resolve_filepath(runtime_loc)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
stack_frames << {
|
|
603
|
+
function_name: func,
|
|
604
|
+
file_name: file,
|
|
605
|
+
line_number: line_no,
|
|
606
|
+
column_number: 0
|
|
607
|
+
}
|
|
608
|
+
|
|
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
|
+
locals_hash = {}
|
|
613
|
+
if current_b
|
|
614
|
+
current_b.local_variables.each do |var_sym|
|
|
615
|
+
var_name = var_sym.to_s
|
|
616
|
+
next if var_name.start_with?('_') && var_name != '_x'
|
|
617
|
+
|
|
618
|
+
begin
|
|
619
|
+
locals_hash[var_name] = current_b.local_variable_get(var_sym)
|
|
620
|
+
rescue StandardError, ScriptError => e
|
|
621
|
+
locals_hash[var_name] = "[Error accessing variable: #{e.message}]"
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
serialized_locals = locals_hash.empty? ? {} : serializer.serialize(locals_hash, "frame[#{frame_idx}].scopes[0]", 0, visited)
|
|
627
|
+
|
|
628
|
+
captured_vars << [
|
|
629
|
+
{
|
|
630
|
+
'type' => 'local',
|
|
631
|
+
'name' => 'Local',
|
|
632
|
+
'vars' => serialized_locals
|
|
633
|
+
}
|
|
634
|
+
]
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
[stack_frames, captured_vars]
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def get_probe_positive_int(probe, key, default_val)
|
|
641
|
+
val = get_probe_opt(probe, key)
|
|
642
|
+
return val.to_i if val.is_a?(Numeric) && val.to_i.positive?
|
|
643
|
+
|
|
644
|
+
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?
|
|
646
|
+
|
|
647
|
+
default_val
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def get_probe_opt(probe, key)
|
|
651
|
+
if probe.respond_to?(key)
|
|
652
|
+
probe.public_send(key)
|
|
653
|
+
elsif probe.is_a?(Hash)
|
|
654
|
+
probe[key] || probe[key.to_s]
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def probe_type_to_i(type)
|
|
659
|
+
case type
|
|
660
|
+
when :PROBE_TYPE_SNAPSHOT, 'PROBE_TYPE_SNAPSHOT', 1 then 1
|
|
661
|
+
when :PROBE_TYPE_LOG, 'PROBE_TYPE_LOG', 2 then 2
|
|
662
|
+
when :PROBE_TYPE_COUNTER, 'PROBE_TYPE_COUNTER', 3 then 3
|
|
663
|
+
when :PROBE_TYPE_METRIC, 'PROBE_TYPE_METRIC', 4 then 4
|
|
664
|
+
when :PROBE_TYPE_DURATION, 'PROBE_TYPE_DURATION', 5 then 5
|
|
665
|
+
else 0
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def cleanup_durations_unlocked(now)
|
|
670
|
+
return if now < @next_duration_cleanup
|
|
671
|
+
|
|
672
|
+
@duration_starts.delete_if do |_key, (_start_time, created_at)|
|
|
673
|
+
now - created_at > DURATION_TTL_SECONDS
|
|
674
|
+
end
|
|
675
|
+
@next_duration_cleanup = now + DURATION_TTL_SECONDS
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def monotonic_time
|
|
679
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
end
|