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.
- hyperprobe/__init__.py +7 -0
- hyperprobe/agent.py +499 -0
- hyperprobe/bootstrap.py +40 -0
- hyperprobe/core/__init__.py +1 -0
- hyperprobe/core/broker.py +131 -0
- hyperprobe/core/evaluator.py +119 -0
- hyperprobe/core/injection/__init__.py +0 -0
- hyperprobe/core/injection/sitecustomize.py +18 -0
- hyperprobe/core/logger.py +121 -0
- hyperprobe/core/monitoring_engine.py +788 -0
- hyperprobe/core/probe_output.py +177 -0
- hyperprobe/core/quota.py +73 -0
- hyperprobe/core/safety.py +136 -0
- hyperprobe/core/serializer.py +214 -0
- hyperprobe/core/trace_extractor.py +93 -0
- hyperprobe/protos/__init__.py +7 -0
- hyperprobe/protos/agent_pb2.py +57 -0
- hyperprobe/protos/agent_pb2_grpc.py +199 -0
- hyperprobe_agent-1.2.24.dist-info/METADATA +154 -0
- hyperprobe_agent-1.2.24.dist-info/RECORD +24 -0
- hyperprobe_agent-1.2.24.dist-info/WHEEL +5 -0
- hyperprobe_agent-1.2.24.dist-info/entry_points.txt +2 -0
- hyperprobe_agent-1.2.24.dist-info/licenses/LICENSE +13 -0
- hyperprobe_agent-1.2.24.dist-info/top_level.txt +1 -0
|
@@ -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)))
|