hyperprobe-agent 1.2.24__py3-none-any.whl

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,788 @@
1
+ import json
2
+ import math
3
+ import os
4
+ import re
5
+ import sys
6
+ import threading
7
+ import time
8
+ import weakref
9
+
10
+ from hyperprobe.core.evaluator import ProbeEvaluator
11
+ from hyperprobe.core.logger import get_logger
12
+ from hyperprobe.core.serializer import serialize
13
+ from hyperprobe.core.trace_extractor import extract_trace_context
14
+ from hyperprobe.protos import agent_pb2
15
+
16
+ logger = get_logger("hyperprobe:monitor")
17
+
18
+ class MonitoringEngine:
19
+ TOOL_ID_CANDIDATES = ( 3, 4)
20
+ PENDING_POLL_INTERVAL_SEC = 0.5
21
+ DURATION_TTL_SECONDS = 60.0
22
+ _JS_FLOAT_PREFIX_RE = re.compile(
23
+ r"^\s*([+-]?(?:(?:[0-9]+\.?[0-9]*)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?)"
24
+ )
25
+
26
+ def __init__(
27
+ self,
28
+ quota_manager,
29
+ safety_monitor,
30
+ on_capture,
31
+ custom_set_trace_id=None
32
+ ):
33
+ self.quota_manager = quota_manager
34
+ self.safety_monitor = safety_monitor
35
+ self.on_capture = on_capture
36
+ self.custom_set_trace_id = custom_set_trace_id
37
+ self.tool_id = None
38
+ self._closed = False
39
+
40
+ self.lock = threading.RLock() # Reentrant Lock for safety during callbacks
41
+
42
+ # Memory-safe weak references to pivoted code objects
43
+ self.pivoted_codes = weakref.WeakSet()
44
+ self.pivoted_locations = set() # (filepath, line_number)
45
+ self.pending_pivots = set() # (filepath, line_number)
46
+
47
+ self.active_probes = {}
48
+ self.secondary_duration_probes = {}
49
+ self.instrumented_files = set()
50
+ self.is_active = False
51
+ self.is_suspended = False
52
+ self.global_config = {}
53
+ self._redact_keys_re = None
54
+ self._redact_values_re = None
55
+ self.total_hits = 0
56
+ self.total_skips = 0
57
+
58
+ self.duration_starts = {}
59
+ self._next_duration_cleanup = time.monotonic() + self.DURATION_TTL_SECONDS
60
+
61
+ # Cached resolved file paths
62
+ self._file_cache = {}
63
+ self._line_start_cache = weakref.WeakKeyDictionary()
64
+
65
+ self._claim_tool()
66
+
67
+ def _claim_tool(self):
68
+ for tool_id in self.TOOL_ID_CANDIDATES:
69
+ claimed = False
70
+ try:
71
+ if sys.monitoring.get_tool(tool_id) is not None:
72
+ continue
73
+ sys.monitoring.use_tool_id(tool_id, "hyperprobe")
74
+ claimed = True
75
+ sys.monitoring.register_callback(
76
+ tool_id,
77
+ sys.monitoring.events.LINE,
78
+ self._line_callback,
79
+ )
80
+ self.tool_id = tool_id
81
+ return
82
+ except ValueError:
83
+ continue
84
+ except Exception as e:
85
+ logger.forceError(f"\033[1m\033[33m⚠️ [HyperProbe] CRITICAL FAIL ON TOOL ID {tool_id}: {type(e).__name__} - {str(e)}")
86
+ if claimed:
87
+ try:
88
+ sys.monitoring.free_tool_id(tool_id)
89
+ except Exception:
90
+ pass
91
+ self.tool_id = None
92
+ continue
93
+
94
+ logger.forceError("\033[1m\033[33m⚠️ [HyperProbe] No available sys.monitoring tool ID for agent. Running application uninstrumented.")
95
+
96
+ def set_global_config(self, config):
97
+ with self.lock:
98
+ candidate = dict(config or {})
99
+ try:
100
+ redact_keys, redact_keys_re = self._compile_redaction_patterns(
101
+ candidate.get("redact_keys", [])
102
+ )
103
+ redact_values, redact_values_re = self._compile_redaction_patterns(
104
+ candidate.get("redact_values", [])
105
+ )
106
+ except (re.error, TypeError) as e:
107
+ logger.error(f'[HyperProbe] error while compiling regex : {type(e).__name__}: {e}')
108
+ return False
109
+
110
+ candidate["redact_keys"] = redact_keys
111
+ candidate["redact_values"] = redact_values
112
+ self.global_config = candidate
113
+ self._redact_keys_re = redact_keys_re
114
+ self._redact_values_re = redact_values_re
115
+ return True
116
+
117
+ @staticmethod
118
+ def _compile_redaction_patterns(patterns):
119
+ if patterns is None:
120
+ patterns = ()
121
+ elif isinstance(patterns, str):
122
+ raise TypeError("Redaction patterns must be a collection of strings")
123
+
124
+ normalized = []
125
+ for pattern in patterns:
126
+ if not isinstance(pattern, str):
127
+ raise TypeError("Redaction patterns must be strings")
128
+ pattern = pattern.strip()
129
+ if not pattern:
130
+ continue
131
+ re.compile(pattern, re.IGNORECASE)
132
+ normalized.append(pattern)
133
+
134
+ combined = (
135
+ re.compile(
136
+ "|".join(f"(?:{pattern})" for pattern in normalized),
137
+ re.IGNORECASE,
138
+ )
139
+ if normalized
140
+ else None
141
+ )
142
+ return normalized, combined
143
+
144
+ def suspend(self):
145
+ """Disable instrumentation without discarding the configured probes."""
146
+ with self.lock:
147
+ if self._closed or self.is_suspended:
148
+ return
149
+ self.is_suspended = True
150
+ if self.tool_id is not None:
151
+ for code in list(self.pivoted_codes):
152
+ try:
153
+ sys.monitoring.set_local_events(self.tool_id, code, 0)
154
+ except (RuntimeError, ValueError):
155
+ pass
156
+ self._stop_monitoring()
157
+
158
+ def resume(self):
159
+ """Re-enable instrumentation after safety recovery."""
160
+ with self.lock:
161
+ if self._closed:
162
+ return
163
+ self.pending_pivots.update(self.pivoted_locations)
164
+ self.is_suspended = False
165
+ self._update_monitoring_state()
166
+
167
+ def get_stats(self):
168
+ with self.lock:
169
+ self._cleanup_durations_locked(time.monotonic())
170
+ stats = {"hits": self.total_hits, "skips": self.total_skips}
171
+ self.total_hits = 0
172
+ self.total_skips = 0
173
+ return stats
174
+
175
+ def _resolve_filename_cached(self, filename):
176
+ resolved = self._file_cache.get(filename)
177
+ if resolved is None:
178
+ resolved = os.path.abspath(os.path.realpath(filename))
179
+ self._file_cache[filename] = resolved
180
+ return resolved
181
+
182
+ def _is_first_event_for_line(self, code, line_number, instruction_offset):
183
+ line_starts = self._line_start_cache.get(code)
184
+ if line_starts is None:
185
+ line_starts = {}
186
+ for start_offset, _, source_line in code.co_lines():
187
+ if source_line is not None and source_line not in line_starts:
188
+ line_starts[source_line] = start_offset
189
+ self._line_start_cache[code] = line_starts
190
+ return line_starts.get(line_number) == instruction_offset
191
+
192
+ def set_probes(self, probes):
193
+ with self.lock:
194
+ if self._closed:
195
+ return
196
+
197
+ new_active_probes = {}
198
+ new_secondary_duration_probes = {}
199
+ new_instrumented_files = set()
200
+ new_pending_pivots = set()
201
+ now = time.monotonic()
202
+
203
+ for probe in probes:
204
+ filepath = self._resolve_filepath(probe.runtime_location)
205
+ loc_key = (filepath, probe.runtime_line)
206
+ if loc_key not in new_active_probes:
207
+ new_active_probes[loc_key] = []
208
+ new_active_probes[loc_key].append(probe)
209
+ new_instrumented_files.add(filepath)
210
+
211
+ # Only search for files we haven't pivoted yet
212
+ if loc_key not in self.pivoted_locations:
213
+ new_pending_pivots.add(loc_key)
214
+
215
+ if (
216
+ getattr(probe, "type", agent_pb2.PROBE_TYPE_UNSPECIFIED)
217
+ == agent_pb2.PROBE_TYPE_DURATION
218
+ ):
219
+ secondary_line = getattr(probe, "secondary_runtime_line", 0)
220
+ if secondary_line:
221
+ secondary_location = (
222
+ getattr(probe, "secondary_runtime_location", "")
223
+ or probe.runtime_location
224
+ )
225
+ secondary_filepath = self._resolve_filepath(secondary_location)
226
+ secondary_key = (secondary_filepath, secondary_line)
227
+ if secondary_key not in new_secondary_duration_probes:
228
+ new_secondary_duration_probes[secondary_key] = []
229
+ new_secondary_duration_probes[secondary_key].append(probe)
230
+ new_instrumented_files.add(secondary_filepath)
231
+ if secondary_key not in self.pivoted_locations:
232
+ new_pending_pivots.add(secondary_key)
233
+
234
+ target_locations = set(new_active_probes) | set(new_secondary_duration_probes)
235
+
236
+ # Clean stale pivoted locations
237
+ self.pivoted_locations = {
238
+ loc for loc in self.pivoted_locations if loc in target_locations
239
+ }
240
+
241
+ # Atomic swap to guarantee thread safety
242
+ self.active_probes = new_active_probes
243
+ self.secondary_duration_probes = new_secondary_duration_probes
244
+ self.instrumented_files = new_instrumented_files
245
+ self.pending_pivots = new_pending_pivots
246
+ logger.info(
247
+ f"[HyperProbe] set_probes count={len(probes)} "
248
+ f"pending={len(self.pending_pivots)} files={len(self.instrumented_files)}"
249
+ )
250
+
251
+ self._update_monitoring_state()
252
+
253
+ def _update_monitoring_state(self):
254
+ # Must be called under a lock
255
+ if self._closed or self.tool_id is None:
256
+ return
257
+
258
+ # Clean local events for code objects from untargeted files
259
+ for code in list(self.pivoted_codes):
260
+ resolved_filename = self._resolve_filename_cached(code.co_filename)
261
+ if resolved_filename not in self.instrumented_files:
262
+ try:
263
+ sys.monitoring.set_local_events(self.tool_id, code, 0)
264
+ except (RuntimeError, ValueError):
265
+ pass
266
+ self.pivoted_codes.discard(code)
267
+
268
+ if self.is_suspended:
269
+ return
270
+
271
+ # Update global searchlight line callbacks (Keep the callback registered)
272
+ if self.pending_pivots:
273
+ logger.info(f"[HyperProbe] pending pivots remaining : {len(self.pending_pivots)}. starting global search.")
274
+ sys.monitoring.set_events(self.tool_id, sys.monitoring.events.LINE)
275
+ self.is_active = True
276
+ else:
277
+ logger.info(f"[HyperProbe] pending pivots remaining : {len(self.pending_pivots)}. stopped global search.")
278
+ sys.monitoring.set_events(self.tool_id, 0)
279
+ self.is_active = False
280
+
281
+ def _stop_monitoring(self):
282
+ # Disable global search events.
283
+ if self.tool_id is None:
284
+ return
285
+ try:
286
+ sys.monitoring.set_events(self.tool_id, 0)
287
+ except (RuntimeError, ValueError) as e:
288
+ logger.error(
289
+ f"[HyperProbe] Failed to disable monitoring: {type(e).__name__}: {e}"
290
+ )
291
+ else:
292
+ logger.info("[HyperProbe] Monitoring disabled.")
293
+ self.is_active = False
294
+
295
+ def close(self):
296
+ with self.lock:
297
+ if self._closed:
298
+ return
299
+ self._closed = True
300
+ self._stop_monitoring()
301
+
302
+ for code in list(self.pivoted_codes):
303
+ try:
304
+ sys.monitoring.set_local_events(self.tool_id, code, 0)
305
+ except (RuntimeError, ValueError):
306
+ pass
307
+
308
+ self.pivoted_codes.clear()
309
+ self.pivoted_locations.clear()
310
+ self.pending_pivots.clear()
311
+
312
+ if self.tool_id is not None:
313
+ try:
314
+ sys.monitoring.register_callback(
315
+ self.tool_id,
316
+ sys.monitoring.events.LINE,
317
+ None,
318
+ )
319
+ except Exception:
320
+ pass
321
+ try:
322
+ sys.monitoring.free_tool_id(self.tool_id)
323
+ except Exception:
324
+ pass
325
+ self.tool_id = None
326
+
327
+ def _line_callback(self, code, line_number):
328
+ with self.lock:
329
+ if self._closed or self.is_suspended:
330
+ return None
331
+
332
+ filepath = self._resolve_filename_cached(code.co_filename)
333
+ if filepath not in self.instrumented_files:
334
+ return None
335
+
336
+ loc_key = (filepath, line_number)
337
+
338
+ # Catch and Pivot Searchlight logic
339
+ if loc_key in self.pending_pivots:
340
+ try:
341
+ sys.monitoring.set_local_events(self.tool_id, code, sys.monitoring.events.LINE)
342
+ self.pivoted_codes.add(code)
343
+ self.pivoted_locations.add(loc_key)
344
+ except ValueError:
345
+ pass
346
+ self.pending_pivots.discard(loc_key)
347
+ self._update_monitoring_state()
348
+
349
+ probes_list = self.active_probes.get(loc_key)
350
+ secondary_probes_list = self.secondary_duration_probes.get(loc_key)
351
+ if not probes_list and not secondary_probes_list:
352
+ return None
353
+ try:
354
+ frame = sys._getframe(1)
355
+ except ValueError:
356
+ return None
357
+ # A multiline statement can leave and re-enter its opening source line.
358
+ if not self._is_first_event_for_line(code, line_number, frame.f_lasti):
359
+ return None
360
+
361
+ # Execute hit capture outside the self.lock to avoid serialization contention
362
+ try:
363
+ if frame:
364
+ for probe in probes_list or ():
365
+ self._handle_probe_hit(probe, frame, is_secondary=False)
366
+ for probe in secondary_probes_list or ():
367
+ self._handle_probe_hit(probe, frame, is_secondary=True)
368
+ except Exception:
369
+ pass
370
+
371
+ return None
372
+
373
+ def _handle_probe_hit(self, probe, frame, is_secondary=False):
374
+ probe_type = getattr(probe, "type", agent_pb2.PROBE_TYPE_UNSPECIFIED)
375
+ if probe_type in (
376
+ agent_pb2.PROBE_TYPE_COUNTER,
377
+ agent_pb2.PROBE_TYPE_METRIC,
378
+ agent_pb2.PROBE_TYPE_DURATION,
379
+ ):
380
+ self._handle_metric_probe_hit(probe, frame, is_secondary)
381
+ return
382
+
383
+ start_time = time.perf_counter()
384
+ # Check admission before touching the frame or evaluating user code.
385
+ if not self.quota_manager.can_evaluate():
386
+ with self.lock:
387
+ self.total_skips += 1
388
+ logger.error(f"[HyperProbe] Probe ID={probe.id} hit rate-limited by QuotaManager.")
389
+ return
390
+
391
+ globals_dict = frame.f_globals
392
+ locals_dict = frame.f_locals
393
+
394
+ # Get config snapshot under lock
395
+ with self.lock:
396
+ config_snapshot = self.global_config.copy() if self.global_config else {}
397
+ redact_keys_re = self._redact_keys_re
398
+ redact_values_re = self._redact_values_re
399
+
400
+ if probe.condition:
401
+ try:
402
+ cond_val = ProbeEvaluator.safe_eval(probe.condition, globals_dict, locals_dict)
403
+ if not cond_val:
404
+ return
405
+ except Exception as e:
406
+ self._report_error(probe.id, f"Condition evaluation failed: {type(e).__name__}: {str(e)}")
407
+ return
408
+
409
+ logger.info(f"[HyperProbe] Hit probe ID={probe.id}! Capturing telemetry...")
410
+
411
+ # Prepare parameters from snapshot
412
+ max_depth = getattr(probe, 'max_object_depth', None) or config_snapshot.get('max_object_depth', 3)
413
+ max_array_length = getattr(probe, 'max_array_length', None) or config_snapshot.get('max_array_length', 3)
414
+ max_object_properties = getattr(probe, 'max_object_properties', None) or config_snapshot.get('max_object_properties', 50)
415
+ max_string_length = getattr(probe, 'max_string_length', None) or config_snapshot.get('max_string_length', 1024)
416
+ stack_frame_depth = getattr(probe, 'stack_frame_depth', None) or config_snapshot.get('stack_frame_depth', 3)
417
+
418
+ # Build telemetry structure
419
+ event = {
420
+ "probe_id": probe.id,
421
+ "timestamp_ms": int(time.time() * 1000),
422
+ "stack_frames": [],
423
+ "captured_vars_json": "",
424
+ "watch_results_json": "",
425
+ "evaluated_log": "",
426
+ "metric_value": 0.0,
427
+ "capture_error": "",
428
+ "trace_id": None
429
+ }
430
+
431
+ if getattr(probe, 'should_capture_trace_id', False):
432
+ trace_id = extract_trace_context(self.custom_set_trace_id)
433
+ if trace_id:
434
+ event["trace_id"] = trace_id
435
+ logger.info(f"[HyperProbe] trace id found : {trace_id}")
436
+
437
+ try:
438
+ # SNAPSHOT CAPTURE
439
+ if probe.type == 1:
440
+ serialization_context = {}
441
+
442
+ if getattr(probe, 'watch_expressions', None):
443
+ watches_result = ProbeEvaluator.evaluate_watches(probe.watch_expressions, globals_dict, locals_dict)
444
+ serialized_watches = {}
445
+ for watch_name, watch_value in watches_result.items():
446
+ watch_path = f"watch[{json.dumps(str(watch_name))}]"
447
+ serialized_watches[watch_name] = serialize(
448
+ watch_value,
449
+ max_depth=max_depth,
450
+ max_array_length=max_array_length,
451
+ max_object_properties=max_object_properties,
452
+ max_string_length=max_string_length,
453
+ redact_keys_re=redact_keys_re,
454
+ redact_values_re=redact_values_re,
455
+ visited=serialization_context,
456
+ path=watch_path,
457
+ )
458
+ event["watch_results_json"] = json.dumps(serialized_watches)
459
+
460
+ curr_frame = frame
461
+ depth = 0
462
+ wrapped_vars = []
463
+
464
+ while curr_frame and depth < stack_frame_depth:
465
+ event["stack_frames"].append({
466
+ "function_name": curr_frame.f_code.co_name,
467
+ "file_name": curr_frame.f_code.co_filename,
468
+ "line_number": curr_frame.f_lineno,
469
+ "column_number": 0
470
+ })
471
+
472
+ clean_locals = {k: v for k, v in curr_frame.f_locals.items() if not k.startswith('_') and k != 'hyperprobe'}
473
+ locals_path = f"frame[{depth}].scopes[0]"
474
+ serialized_locals = serialize(
475
+ clean_locals,
476
+ max_depth=max_depth,
477
+ max_array_length=max_array_length,
478
+ max_object_properties=max_object_properties,
479
+ max_string_length=max_string_length,
480
+ redact_keys_re=redact_keys_re,
481
+ redact_values_re=redact_values_re,
482
+ visited=serialization_context,
483
+ path=locals_path,
484
+ )
485
+
486
+ wrapped_vars.append([
487
+ {
488
+ "type": "local",
489
+ "name": "Local",
490
+ "vars": serialized_locals
491
+ }
492
+ ])
493
+
494
+ curr_frame = curr_frame.f_back
495
+ depth += 1
496
+
497
+ event["captured_vars_json"] = json.dumps(wrapped_vars)
498
+
499
+ # LOG TEMPLATE CAPTURE
500
+ elif probe.type == 2:
501
+ evaluated = ProbeEvaluator.evaluate_log_template(probe.template, globals_dict, locals_dict)
502
+ if redact_values_re and redact_values_re.search(evaluated):
503
+ evaluated = redact_values_re.sub("[REDACTED Value]", evaluated)
504
+ if len(evaluated) > max_string_length:
505
+ evaluated = evaluated[:max_string_length] + f"... [Truncated: +{len(evaluated) - max_string_length} more chars]"
506
+ event["evaluated_log"] = evaluated
507
+
508
+ # Safety budget calculation
509
+ # Fire callback
510
+ with self.lock:
511
+ self.total_hits += 1
512
+ self.on_capture(event)
513
+ logger.info(f"[HyperProbe] Successfully captured and queued telemetry for probe ID={probe.id}.")
514
+
515
+ except Exception as err:
516
+ err_msg = f"Capture failed: {type(err).__name__}: {str(err)}"
517
+ logger.error(f"[HyperProbe] Error during capture: {err_msg}")
518
+ event["capture_error"] = err_msg
519
+ with self.lock:
520
+ self.total_hits += 1
521
+ self.on_capture(event)
522
+ finally:
523
+ duration_ms = (time.perf_counter() - start_time) * 1000.0
524
+ self.safety_monitor.report_pause_duration(duration_ms)
525
+
526
+ def _handle_metric_probe_hit(self, probe, frame, is_secondary):
527
+ """Handle counter, metric, and duration probes without snapshot capture."""
528
+ handler_started_at = time.perf_counter()
529
+ globals_dict = frame.f_globals
530
+ locals_dict = frame.f_locals
531
+
532
+ try:
533
+ if probe.condition:
534
+ try:
535
+ if not ProbeEvaluator.safe_eval(
536
+ probe.condition, globals_dict, locals_dict
537
+ ):
538
+ return
539
+ except Exception as error:
540
+ self._report_metric_error(probe, str(error))
541
+ return
542
+
543
+ if probe.type == agent_pb2.PROBE_TYPE_COUNTER:
544
+ if not self._consume_evaluation_quota(probe.id):
545
+ return
546
+ self._emit_metric_event(probe, metric_value=1.0)
547
+ return
548
+
549
+ if probe.type == agent_pb2.PROBE_TYPE_METRIC:
550
+ metric_expression = getattr(probe, "metric_expression", "")
551
+ if not metric_expression:
552
+ # The backend validates this field. Node also emits nothing when
553
+ # an empty expression reaches the SDK.
554
+ return
555
+ try:
556
+ raw_value = ProbeEvaluator.safe_eval(
557
+ metric_expression, globals_dict, locals_dict
558
+ )
559
+ except Exception as error:
560
+ self._report_metric_error(probe, str(error))
561
+ return
562
+
563
+ if not self._consume_evaluation_quota(probe.id):
564
+ return
565
+
566
+ metric_value = self._coerce_metric_value(raw_value)
567
+ if metric_value is None:
568
+ self._emit_metric_event(
569
+ probe,
570
+ capture_error=f"Metric evaluation failed: {raw_value}",
571
+ )
572
+ else:
573
+ self._emit_metric_event(probe, metric_value=metric_value)
574
+ return
575
+
576
+ correlation_expression = getattr(
577
+ probe, "correlation_expression", ""
578
+ )
579
+ if correlation_expression:
580
+ try:
581
+ correlation_value = ProbeEvaluator.safe_eval(
582
+ correlation_expression, globals_dict, locals_dict
583
+ )
584
+ except Exception as error:
585
+ self._report_metric_error(probe, f"Error: {error}")
586
+ return
587
+ else:
588
+ correlation_value = None
589
+
590
+ try:
591
+ correlation_key = self._normalize_correlation_key(correlation_value)
592
+ except (TypeError, ValueError) as error:
593
+ self._report_metric_error(probe, str(error))
594
+ return
595
+
596
+ if not is_secondary:
597
+ self._start_duration(probe.id, correlation_key)
598
+ return
599
+
600
+ duration_ms = self._finish_duration(probe.id, correlation_key)
601
+ if duration_ms is None:
602
+ return
603
+ if not self._consume_evaluation_quota(probe.id):
604
+ return
605
+ self._emit_metric_event(probe, metric_value=duration_ms)
606
+ finally:
607
+ try:
608
+ self.safety_monitor.report_pause_duration(
609
+ (time.perf_counter() - handler_started_at) * 1000.0
610
+ )
611
+ except Exception:
612
+ # Safety accounting must never affect application execution.
613
+ pass
614
+
615
+ def _consume_evaluation_quota(self, probe_id):
616
+ if self.quota_manager.can_evaluate():
617
+ return True
618
+ with self.lock:
619
+ self.total_skips += 1
620
+ logger.error(
621
+ f"[HyperProbe] Probe ID={probe_id} hit rate-limited by QuotaManager."
622
+ )
623
+ return False
624
+
625
+ def _build_metric_event(self, probe, metric_value=0.0, capture_error=""):
626
+ event = {
627
+ "probe_id": probe.id,
628
+ "timestamp_ms": int(time.time() * 1000),
629
+ "stack_frames": [],
630
+ "captured_vars_json": "",
631
+ "watch_results_json": "",
632
+ "evaluated_log": "",
633
+ "metric_value": float(metric_value),
634
+ "capture_error": capture_error,
635
+ "trace_id": None,
636
+ }
637
+ if getattr(probe, "should_capture_trace_id", False):
638
+ event["trace_id"] = extract_trace_context(self.custom_set_trace_id)
639
+ return event
640
+
641
+ def _emit_metric_event(self, probe, metric_value=0.0, capture_error=""):
642
+ event = self._build_metric_event(probe, metric_value, capture_error)
643
+
644
+ # no need to check for bandwidth here.
645
+ # bandwidth check is owned by on_capture()
646
+
647
+ # payload_size = len(
648
+ # json.dumps(event, separators=(",", ":"), ensure_ascii=False).encode(
649
+ # "utf-8"
650
+ # )
651
+ # )
652
+
653
+ # if not self.quota_manager.can_send(payload_size):
654
+ # with self.lock:
655
+ # self.total_skips += 1
656
+ # return False
657
+
658
+ with self.lock:
659
+ self.total_hits += 1
660
+ self.on_capture(event)
661
+
662
+ def _report_metric_error(self, probe, error_message):
663
+ self._emit_metric_event(probe, capture_error=error_message)
664
+
665
+ @classmethod
666
+ def _coerce_metric_value(cls, value):
667
+ """Mirror Node's finite-number/parseFloat behavior for common values."""
668
+ if isinstance(value, bool):
669
+ return None
670
+
671
+ if isinstance(value, (int, float)):
672
+ try:
673
+ parsed = float(value)
674
+ except (OverflowError, TypeError, ValueError):
675
+ return None
676
+ return parsed if math.isfinite(parsed) else None
677
+
678
+ if isinstance(value, str):
679
+ match = cls._JS_FLOAT_PREFIX_RE.match(value)
680
+ if not match:
681
+ return None
682
+ try:
683
+ parsed = float(match.group(1))
684
+ except (OverflowError, ValueError):
685
+ return None
686
+ return parsed if math.isfinite(parsed) else None
687
+
688
+ return None
689
+
690
+ @staticmethod
691
+ def _node_type_name(value):
692
+ if isinstance(value, bool):
693
+ return "boolean"
694
+ if isinstance(value, str):
695
+ return "string"
696
+ if isinstance(value, (int, float)):
697
+ return "number"
698
+ if value is None:
699
+ return "object"
700
+ return "object"
701
+
702
+ @classmethod
703
+ def _normalize_correlation_key(cls, value):
704
+ if value is None:
705
+ normalized = "static-singleton"
706
+ elif isinstance(value, bool):
707
+ raise TypeError(
708
+ "Correlation expression must evaluate to a string or number, "
709
+ f"got {cls._node_type_name(value)}"
710
+ )
711
+ elif isinstance(value, str):
712
+ if value.startswith("Error: "):
713
+ raise ValueError(value)
714
+ normalized = value
715
+ elif isinstance(value, int):
716
+ normalized = str(value)
717
+ elif isinstance(value, float):
718
+ if math.isnan(value):
719
+ normalized = "NaN"
720
+ elif math.isinf(value):
721
+ normalized = "Infinity" if value > 0 else "-Infinity"
722
+ elif value.is_integer():
723
+ normalized = str(int(value))
724
+ else:
725
+ normalized = str(value)
726
+ else:
727
+ raise TypeError(
728
+ "Correlation expression must evaluate to a string or number, "
729
+ f"got {cls._node_type_name(value)}"
730
+ )
731
+
732
+ return normalized
733
+
734
+ def _start_duration(self, probe_id, correlation_key):
735
+ start_time = time.perf_counter()
736
+ created_at = time.monotonic()
737
+ duration_key = (probe_id, correlation_key)
738
+ with self.lock:
739
+ self._cleanup_durations_locked(created_at)
740
+ self.duration_starts[duration_key] = (start_time, created_at)
741
+
742
+ def _finish_duration(self, probe_id, correlation_key):
743
+ end_time = time.perf_counter()
744
+ duration_key = (probe_id, correlation_key)
745
+ with self.lock:
746
+ self._cleanup_durations_locked(time.monotonic())
747
+ entry = self.duration_starts.pop(duration_key, None)
748
+ if entry is None:
749
+ return None
750
+ return (end_time - entry[0]) * 1000.0
751
+
752
+ def _cleanup_durations_locked(self, now):
753
+ if now < self._next_duration_cleanup:
754
+ return
755
+
756
+ for duration_key, (_, created_at) in list(self.duration_starts.items()):
757
+ if now - created_at > self.DURATION_TTL_SECONDS:
758
+ del self.duration_starts[duration_key]
759
+
760
+ self._next_duration_cleanup = now + self.DURATION_TTL_SECONDS
761
+
762
+ def _report_error(self, probe_id, err_msg):
763
+ event = {
764
+ "probe_id": probe_id,
765
+ "timestamp_ms": int(time.time() * 1000),
766
+ "stack_frames": [],
767
+ "captured_vars_json": "",
768
+ "watch_results_json": "",
769
+ "evaluated_log": "",
770
+ "metric_value": 0.0,
771
+ "capture_error": err_msg,
772
+ "trace_id": None
773
+ }
774
+ with self.lock:
775
+ self.total_hits += 1
776
+ self.on_capture(event)
777
+
778
+ def _resolve_filepath(self, runtime_location):
779
+ if os.path.isabs(runtime_location):
780
+ return os.path.abspath(os.path.realpath(runtime_location))
781
+ cwd = os.getcwd()
782
+ parts = runtime_location.split('/')
783
+ for i in range(len(parts)):
784
+ suffix = os.path.join(*parts[i:])
785
+ attempt = os.path.abspath(os.path.realpath(os.path.join(cwd, suffix)))
786
+ if os.path.exists(attempt):
787
+ return attempt
788
+ return os.path.abspath(os.path.realpath(os.path.join(cwd, runtime_location)))