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,177 @@
|
|
|
1
|
+
import queue
|
|
2
|
+
import sys
|
|
3
|
+
import threading
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_COLOR_RESET = "\x1b[0m"
|
|
8
|
+
_COLOR_BOLD = "\x1b[1m"
|
|
9
|
+
_COLOR_GRAY = "\x1b[90m"
|
|
10
|
+
_COLOR_MAGENTA = "\x1b[35m"
|
|
11
|
+
_LEVEL_COLORS = {
|
|
12
|
+
"ERROR": "\x1b[31m",
|
|
13
|
+
"INFO": "\x1b[32m",
|
|
14
|
+
}
|
|
15
|
+
_STOP = object()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_probe_log_level(value):
|
|
19
|
+
candidate = value.strip().upper() if isinstance(value, str) else ""
|
|
20
|
+
return candidate if candidate in _LEVEL_COLORS else "INFO"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sanitize_probe_log_message(value):
|
|
24
|
+
message = value if isinstance(value, str) else str(value)
|
|
25
|
+
sanitized = []
|
|
26
|
+
escapes = {
|
|
27
|
+
"\n": r"\n",
|
|
28
|
+
"\r": r"\r",
|
|
29
|
+
"\t": r"\t",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for character in message:
|
|
33
|
+
if character in escapes:
|
|
34
|
+
sanitized.append(escapes[character])
|
|
35
|
+
elif character.isprintable():
|
|
36
|
+
sanitized.append(character)
|
|
37
|
+
else:
|
|
38
|
+
codepoint = ord(character)
|
|
39
|
+
if codepoint <= 0xFF:
|
|
40
|
+
sanitized.append(f"\\x{codepoint:02x}")
|
|
41
|
+
elif codepoint <= 0xFFFF:
|
|
42
|
+
sanitized.append(f"\\u{codepoint:04x}")
|
|
43
|
+
else:
|
|
44
|
+
sanitized.append(f"\\U{codepoint:08x}")
|
|
45
|
+
|
|
46
|
+
return "".join(sanitized)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_probe_log(timestamp_ms, level, message, use_color=False):
|
|
50
|
+
normalized_level = normalize_probe_log_level(level)
|
|
51
|
+
timestamp = datetime.fromtimestamp(
|
|
52
|
+
timestamp_ms / 1000,
|
|
53
|
+
tz=timezone.utc,
|
|
54
|
+
).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
55
|
+
safe_message = sanitize_probe_log_message(message)
|
|
56
|
+
|
|
57
|
+
if not use_color:
|
|
58
|
+
return f"[{timestamp}] [{normalized_level}] [HyperProbe LOG] {safe_message}\n"
|
|
59
|
+
|
|
60
|
+
level_color = _LEVEL_COLORS[normalized_level]
|
|
61
|
+
return (
|
|
62
|
+
f"{_COLOR_GRAY}[{timestamp}]{_COLOR_RESET} "
|
|
63
|
+
f"{_COLOR_BOLD}{level_color}[{normalized_level}]{_COLOR_RESET} "
|
|
64
|
+
f"{_COLOR_BOLD}{_COLOR_MAGENTA}[HyperProbe LOG]{_COLOR_RESET} "
|
|
65
|
+
f"{safe_message}\n"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProbeLogWriter:
|
|
70
|
+
"""Best-effort probe output that never blocks capture threads."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, max_queue_size, stdout=None, stderr=None):
|
|
73
|
+
self._queue = queue.Queue(maxsize=max_queue_size)
|
|
74
|
+
self._stdout = stdout
|
|
75
|
+
self._stderr = stderr
|
|
76
|
+
self._lifecycle_lock = threading.RLock()
|
|
77
|
+
self._thread = None
|
|
78
|
+
self._closed = False
|
|
79
|
+
self.dropped_count = 0
|
|
80
|
+
self.error_count = 0
|
|
81
|
+
|
|
82
|
+
def start(self):
|
|
83
|
+
with self._lifecycle_lock:
|
|
84
|
+
return self._start_locked()
|
|
85
|
+
|
|
86
|
+
def _start_locked(self):
|
|
87
|
+
if self._closed:
|
|
88
|
+
return False
|
|
89
|
+
if self._thread and self._thread.is_alive():
|
|
90
|
+
return True
|
|
91
|
+
self._thread = threading.Thread(
|
|
92
|
+
target=self._run,
|
|
93
|
+
name="hyperprobe-probe-output",
|
|
94
|
+
daemon=True,
|
|
95
|
+
)
|
|
96
|
+
self._thread.start()
|
|
97
|
+
return True
|
|
98
|
+
|
|
99
|
+
def is_running(self):
|
|
100
|
+
with self._lifecycle_lock:
|
|
101
|
+
return bool(self._thread and self._thread.is_alive())
|
|
102
|
+
|
|
103
|
+
def submit(self, timestamp_ms, level, message):
|
|
104
|
+
record = (timestamp_ms, normalize_probe_log_level(level), message)
|
|
105
|
+
with self._lifecycle_lock:
|
|
106
|
+
if self._closed:
|
|
107
|
+
return False
|
|
108
|
+
try:
|
|
109
|
+
self._queue.put_nowait(record)
|
|
110
|
+
self._start_locked()
|
|
111
|
+
return True
|
|
112
|
+
except queue.Full:
|
|
113
|
+
self.dropped_count += 1
|
|
114
|
+
return False
|
|
115
|
+
except Exception:
|
|
116
|
+
self.error_count += 1
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
def stop(self, timeout=2.0):
|
|
120
|
+
with self._lifecycle_lock:
|
|
121
|
+
self._closed = True
|
|
122
|
+
thread = self._thread
|
|
123
|
+
if thread:
|
|
124
|
+
try:
|
|
125
|
+
self._queue.put_nowait(_STOP)
|
|
126
|
+
except queue.Full:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
if thread and thread is not threading.current_thread():
|
|
130
|
+
thread.join(timeout=timeout)
|
|
131
|
+
|
|
132
|
+
def _run(self):
|
|
133
|
+
while True:
|
|
134
|
+
try:
|
|
135
|
+
record = self._queue.get(timeout=0.1)
|
|
136
|
+
except queue.Empty:
|
|
137
|
+
if self._retire_if_empty():
|
|
138
|
+
return
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
if record is not _STOP:
|
|
143
|
+
self._write(record)
|
|
144
|
+
finally:
|
|
145
|
+
self._queue.task_done()
|
|
146
|
+
|
|
147
|
+
if self._retire_if_empty():
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
def _retire_if_empty(self):
|
|
151
|
+
with self._lifecycle_lock:
|
|
152
|
+
if not self._queue.empty():
|
|
153
|
+
return False
|
|
154
|
+
if self._thread is threading.current_thread():
|
|
155
|
+
self._thread = None
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def _write(self, record):
|
|
159
|
+
timestamp_ms, level, message = record
|
|
160
|
+
stream = (
|
|
161
|
+
self._stderr if self._stderr is not None else sys.stderr
|
|
162
|
+
) if level == "ERROR" else (
|
|
163
|
+
self._stdout if self._stdout is not None else sys.stdout
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
use_color = bool(getattr(stream, "isatty", lambda: False)())
|
|
168
|
+
output = format_probe_log(timestamp_ms, level, message, use_color)
|
|
169
|
+
written = stream.write(output)
|
|
170
|
+
if written is not None and written != len(output):
|
|
171
|
+
with self._lifecycle_lock:
|
|
172
|
+
self.error_count += 1
|
|
173
|
+
return
|
|
174
|
+
stream.flush()
|
|
175
|
+
except Exception:
|
|
176
|
+
with self._lifecycle_lock:
|
|
177
|
+
self.error_count += 1
|
hyperprobe/core/quota.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class QuotaReservation:
|
|
6
|
+
def __init__(self, bucket, count: float):
|
|
7
|
+
self._bucket = bucket
|
|
8
|
+
self._count = count
|
|
9
|
+
self._closed = False
|
|
10
|
+
self._lock = threading.Lock()
|
|
11
|
+
|
|
12
|
+
def commit(self):
|
|
13
|
+
with self._lock:
|
|
14
|
+
self._closed = True
|
|
15
|
+
|
|
16
|
+
def release(self):
|
|
17
|
+
with self._lock:
|
|
18
|
+
if self._closed:
|
|
19
|
+
return
|
|
20
|
+
self._closed = True
|
|
21
|
+
self._bucket.refund(self._count)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TokenBucket:
|
|
25
|
+
def __init__(self, capacity: float, refill_rate_per_sec: float):
|
|
26
|
+
self.capacity = capacity
|
|
27
|
+
self.refill_rate = refill_rate_per_sec # tokens per second
|
|
28
|
+
self.tokens = capacity
|
|
29
|
+
self.last_refill = time.monotonic()
|
|
30
|
+
self.lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def try_consume(self, count: float = 1.0) -> bool:
|
|
33
|
+
reservation = self.reserve(count)
|
|
34
|
+
if reservation is None:
|
|
35
|
+
return False
|
|
36
|
+
reservation.commit()
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
def reserve(self, count: float = 1.0):
|
|
40
|
+
with self.lock:
|
|
41
|
+
self._refill()
|
|
42
|
+
if self.tokens >= count:
|
|
43
|
+
self.tokens -= count
|
|
44
|
+
return QuotaReservation(self, count)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def refund(self, count: float):
|
|
48
|
+
with self.lock:
|
|
49
|
+
self._refill()
|
|
50
|
+
self.tokens = min(self.capacity, self.tokens + count)
|
|
51
|
+
|
|
52
|
+
def _refill(self):
|
|
53
|
+
now = time.monotonic()
|
|
54
|
+
delta = now - self.last_refill
|
|
55
|
+
amount = delta * self.refill_rate
|
|
56
|
+
self.tokens = min(self.capacity, self.tokens + amount)
|
|
57
|
+
self.last_refill = now
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class QuotaManager:
|
|
61
|
+
def __init__(self, hits_per_sec: float = 10.0, bytes_per_sec: float = 200.0 * 1024.0):
|
|
62
|
+
# Burst capacity of hits_per_sec and bytes_per_sec (1 second worth)
|
|
63
|
+
self.eval_bucket = TokenBucket(hits_per_sec, hits_per_sec)
|
|
64
|
+
self.bandwidth_bucket = TokenBucket(bytes_per_sec, bytes_per_sec)
|
|
65
|
+
|
|
66
|
+
def can_evaluate(self) -> bool:
|
|
67
|
+
return self.eval_bucket.try_consume(1.0)
|
|
68
|
+
|
|
69
|
+
def can_send(self, bytes_count: float) -> bool:
|
|
70
|
+
return self.bandwidth_bucket.try_consume(bytes_count)
|
|
71
|
+
|
|
72
|
+
def reserve_bandwidth(self, bytes_count: float):
|
|
73
|
+
return self.bandwidth_bucket.reserve(bytes_count)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
class AgentHealth:
|
|
5
|
+
GREEN = 'GREEN' # All good
|
|
6
|
+
YELLOW = 'YELLOW' # Rate limited (Reactive Shedding)
|
|
7
|
+
RED = 'RED' # Critical Lag (Proactive Shedding - Detach)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SafetyMonitor:
|
|
11
|
+
def __init__(self, on_state_change, max_lag_ms=50.0, pause_budget_ms=15.0):
|
|
12
|
+
self.on_state_change = on_state_change
|
|
13
|
+
self.max_lag_ms = max_lag_ms
|
|
14
|
+
self.pause_budget_ms = pause_budget_ms
|
|
15
|
+
|
|
16
|
+
self.health = AgentHealth.GREEN
|
|
17
|
+
self.cumulative_pause_time = 0.0
|
|
18
|
+
self.last_window_reset = time.monotonic()
|
|
19
|
+
self.last_thread_lag_ms = 0.0
|
|
20
|
+
|
|
21
|
+
self.lock = threading.RLock()
|
|
22
|
+
self._notification_lock = threading.RLock()
|
|
23
|
+
|
|
24
|
+
self.is_running = False
|
|
25
|
+
self.thread = None
|
|
26
|
+
self.stop_event = threading.Event()
|
|
27
|
+
|
|
28
|
+
def start(self):
|
|
29
|
+
self.stop_event.clear()
|
|
30
|
+
self.is_running = True
|
|
31
|
+
self.thread = threading.Thread(target=self._run_loop, name="hyperprobe-safety")
|
|
32
|
+
self.thread.daemon = True
|
|
33
|
+
self.thread.start()
|
|
34
|
+
|
|
35
|
+
def stop(self):
|
|
36
|
+
self.is_running = False
|
|
37
|
+
self.stop_event.set()
|
|
38
|
+
if self.thread and self.thread is not threading.current_thread():
|
|
39
|
+
self.thread.join(timeout=2.0)
|
|
40
|
+
self.thread = None
|
|
41
|
+
|
|
42
|
+
def report_pause_duration(self, ms: float):
|
|
43
|
+
with self.lock:
|
|
44
|
+
self.cumulative_pause_time += ms
|
|
45
|
+
transition = self._evaluate_health_locked(self.last_thread_lag_ms)
|
|
46
|
+
|
|
47
|
+
self._notify_state_change(transition)
|
|
48
|
+
|
|
49
|
+
def _run_loop(self):
|
|
50
|
+
last_heartbeat = time.monotonic()
|
|
51
|
+
|
|
52
|
+
while self.is_running and not self.stop_event.wait(0.1):
|
|
53
|
+
now = time.monotonic()
|
|
54
|
+
thread_lag_ms = (now - last_heartbeat - 0.1) * 1000.0
|
|
55
|
+
last_heartbeat = now
|
|
56
|
+
|
|
57
|
+
with self.lock:
|
|
58
|
+
self.last_thread_lag_ms = thread_lag_ms
|
|
59
|
+
if now - self.last_window_reset > 1.0:
|
|
60
|
+
self.cumulative_pause_time = 0.0
|
|
61
|
+
self.last_window_reset = now
|
|
62
|
+
|
|
63
|
+
transition = self._evaluate_health_locked(thread_lag_ms)
|
|
64
|
+
|
|
65
|
+
self._notify_state_change(transition)
|
|
66
|
+
|
|
67
|
+
def _check_health(self, thread_lag_ms: float = 0.0):
|
|
68
|
+
"""
|
|
69
|
+
Preserves the existing method signature while ensuring that
|
|
70
|
+
callbacks execute outside the state lock.
|
|
71
|
+
"""
|
|
72
|
+
with self.lock:
|
|
73
|
+
transition = self._evaluate_health_locked(thread_lag_ms)
|
|
74
|
+
|
|
75
|
+
self._notify_state_change(transition)
|
|
76
|
+
|
|
77
|
+
def _evaluate_health_locked(self, thread_lag_ms: float = 0.0):
|
|
78
|
+
previous_health = self.health
|
|
79
|
+
reason = None
|
|
80
|
+
|
|
81
|
+
if thread_lag_ms > self.max_lag_ms:
|
|
82
|
+
self.health = AgentHealth.RED
|
|
83
|
+
reason = (
|
|
84
|
+
f"Execution Thread Lag ({thread_lag_ms:.1f}ms) exceeded "
|
|
85
|
+
f"limit ({self.max_lag_ms}ms)"
|
|
86
|
+
)
|
|
87
|
+
elif self.cumulative_pause_time > self.pause_budget_ms:
|
|
88
|
+
self.health = AgentHealth.RED
|
|
89
|
+
reason = (
|
|
90
|
+
f"Cumulative Pause Budget ({self.cumulative_pause_time:.1f}ms) "
|
|
91
|
+
f"exceeded limit ({self.pause_budget_ms}ms)"
|
|
92
|
+
)
|
|
93
|
+
elif thread_lag_ms > self.max_lag_ms / 2.0:
|
|
94
|
+
self.health = AgentHealth.YELLOW
|
|
95
|
+
reason = (
|
|
96
|
+
f"Moderate impact: Execution Thread Lag "
|
|
97
|
+
f"({thread_lag_ms:.1f}ms) reached 50% of limit "
|
|
98
|
+
f"({self.max_lag_ms}ms)"
|
|
99
|
+
)
|
|
100
|
+
elif self.cumulative_pause_time > self.pause_budget_ms / 2.0:
|
|
101
|
+
self.health = AgentHealth.YELLOW
|
|
102
|
+
reason = (
|
|
103
|
+
f"Moderate impact: Cumulative Pause Budget "
|
|
104
|
+
f"({self.cumulative_pause_time:.1f}ms) reached 50% of limit "
|
|
105
|
+
f"({self.pause_budget_ms}ms)"
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
self.health = AgentHealth.GREEN
|
|
109
|
+
if previous_health != AgentHealth.GREEN:
|
|
110
|
+
reason = "System stabilized."
|
|
111
|
+
|
|
112
|
+
if self.health != previous_health:
|
|
113
|
+
return self.health, reason
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def _notify_state_change(self, transition):
|
|
118
|
+
if transition is None:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
health, reason = transition
|
|
122
|
+
|
|
123
|
+
with self._notification_lock:
|
|
124
|
+
with self.lock:
|
|
125
|
+
if self.health != health:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
self.on_state_change(health, reason)
|
|
130
|
+
except Exception:
|
|
131
|
+
# Safety callbacks must never break application execution.
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def get_health(self) -> str:
|
|
135
|
+
with self.lock:
|
|
136
|
+
return self.health
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _type_summary(obj):
|
|
5
|
+
type_name = type(obj).__name__
|
|
6
|
+
if isinstance(obj, (bytes, bytearray, memoryview)):
|
|
7
|
+
try:
|
|
8
|
+
return f"[{type_name}: {len(obj)} bytes]"
|
|
9
|
+
except Exception:
|
|
10
|
+
return f"[{type_name}]"
|
|
11
|
+
if isinstance(obj, dict):
|
|
12
|
+
try:
|
|
13
|
+
return f"[Dict: {len(obj)} keys]"
|
|
14
|
+
except Exception:
|
|
15
|
+
return "[Dict]"
|
|
16
|
+
if isinstance(obj, (list, tuple, set)):
|
|
17
|
+
try:
|
|
18
|
+
return f"[List: {len(obj)} items]"
|
|
19
|
+
except Exception:
|
|
20
|
+
return "[List]"
|
|
21
|
+
return f"[Obj: {type_name}]"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_key(key):
|
|
25
|
+
if isinstance(key, (str, int, float, bool)) or key is None:
|
|
26
|
+
return str(key)
|
|
27
|
+
return f"[{type(key).__name__} key]"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_length(value):
|
|
31
|
+
try:
|
|
32
|
+
return len(value)
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serialize(
|
|
38
|
+
obj,
|
|
39
|
+
max_depth=3,
|
|
40
|
+
max_array_length=3,
|
|
41
|
+
max_object_properties=50,
|
|
42
|
+
max_string_length=1024,
|
|
43
|
+
redact_keys_re=None,
|
|
44
|
+
redact_values_re=None,
|
|
45
|
+
visited=None,
|
|
46
|
+
depth=0,
|
|
47
|
+
path="$",
|
|
48
|
+
):
|
|
49
|
+
"""Serialize a bounded object graph without invoking arbitrary repr()."""
|
|
50
|
+
if visited is None:
|
|
51
|
+
visited = {}
|
|
52
|
+
|
|
53
|
+
if obj is None:
|
|
54
|
+
return None
|
|
55
|
+
if isinstance(obj, bool):
|
|
56
|
+
return obj
|
|
57
|
+
if isinstance(obj, int):
|
|
58
|
+
return obj
|
|
59
|
+
if isinstance(obj, float):
|
|
60
|
+
if not math.isfinite(obj):
|
|
61
|
+
return str(obj)
|
|
62
|
+
return obj
|
|
63
|
+
if isinstance(obj, str):
|
|
64
|
+
value = obj
|
|
65
|
+
if len(value) > max_string_length:
|
|
66
|
+
value = (
|
|
67
|
+
value[:max_string_length]
|
|
68
|
+
+ f"... [Truncated: +{len(value) - max_string_length} more chars]"
|
|
69
|
+
)
|
|
70
|
+
if redact_values_re and redact_values_re.search(value):
|
|
71
|
+
value = redact_values_re.sub("[REDACTED Value]", value)
|
|
72
|
+
return value
|
|
73
|
+
if isinstance(obj, (bytes, bytearray, memoryview)):
|
|
74
|
+
return _type_summary(obj)
|
|
75
|
+
|
|
76
|
+
obj_id = id(obj)
|
|
77
|
+
previous = visited.get(obj_id)
|
|
78
|
+
if previous is not None:
|
|
79
|
+
previous_obj, previous_path = previous
|
|
80
|
+
if previous_obj is obj:
|
|
81
|
+
return f"[REF - {previous_path}]"
|
|
82
|
+
|
|
83
|
+
if depth > max_depth:
|
|
84
|
+
return _type_summary(obj)
|
|
85
|
+
|
|
86
|
+
# Retain the object for the whole serialization operation. This preserves
|
|
87
|
+
# aliases and prevents stale id() reuse while traversing generators.
|
|
88
|
+
visited[obj_id] = (obj, path)
|
|
89
|
+
|
|
90
|
+
if isinstance(obj, dict):
|
|
91
|
+
result = {}
|
|
92
|
+
processed = 0
|
|
93
|
+
try:
|
|
94
|
+
iterator = iter(obj.items())
|
|
95
|
+
while processed < max_object_properties:
|
|
96
|
+
try:
|
|
97
|
+
key, value = next(iterator)
|
|
98
|
+
except StopIteration:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
key_string = _safe_key(key)
|
|
102
|
+
if redact_keys_re and redact_keys_re.search(key_string):
|
|
103
|
+
result[key_string] = "[REDACTED Key]"
|
|
104
|
+
else:
|
|
105
|
+
child_path = f"{path}.{key_string}" if path else key_string
|
|
106
|
+
result[key_string] = serialize(
|
|
107
|
+
value,
|
|
108
|
+
max_depth=max_depth,
|
|
109
|
+
max_array_length=max_array_length,
|
|
110
|
+
max_object_properties=max_object_properties,
|
|
111
|
+
max_string_length=max_string_length,
|
|
112
|
+
redact_keys_re=redact_keys_re,
|
|
113
|
+
redact_values_re=redact_values_re,
|
|
114
|
+
visited=visited,
|
|
115
|
+
depth=depth + 1,
|
|
116
|
+
path=child_path,
|
|
117
|
+
)
|
|
118
|
+
processed += 1
|
|
119
|
+
|
|
120
|
+
total = _safe_length(obj)
|
|
121
|
+
if total is not None and total > processed:
|
|
122
|
+
result["__probe_meta"] = (
|
|
123
|
+
f"+ {total - processed} more properties truncated"
|
|
124
|
+
)
|
|
125
|
+
elif total is None:
|
|
126
|
+
try:
|
|
127
|
+
next(iterator)
|
|
128
|
+
except StopIteration:
|
|
129
|
+
pass
|
|
130
|
+
else:
|
|
131
|
+
result["__probe_meta"] = "+ more properties truncated"
|
|
132
|
+
except Exception:
|
|
133
|
+
result["__probe_meta"] = "[Dictionary traversal failed]"
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
if isinstance(obj, (list, tuple, set)):
|
|
137
|
+
result = []
|
|
138
|
+
processed = 0
|
|
139
|
+
try:
|
|
140
|
+
iterator = iter(obj)
|
|
141
|
+
while processed < max_array_length:
|
|
142
|
+
try:
|
|
143
|
+
value = next(iterator)
|
|
144
|
+
except StopIteration:
|
|
145
|
+
break
|
|
146
|
+
result.append(
|
|
147
|
+
serialize(
|
|
148
|
+
value,
|
|
149
|
+
max_depth=max_depth,
|
|
150
|
+
max_array_length=max_array_length,
|
|
151
|
+
max_object_properties=max_object_properties,
|
|
152
|
+
max_string_length=max_string_length,
|
|
153
|
+
redact_keys_re=redact_keys_re,
|
|
154
|
+
redact_values_re=redact_values_re,
|
|
155
|
+
visited=visited,
|
|
156
|
+
depth=depth + 1,
|
|
157
|
+
path=f"{path}[{processed}]",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
processed += 1
|
|
161
|
+
|
|
162
|
+
total = _safe_length(obj)
|
|
163
|
+
if total is not None and total > processed:
|
|
164
|
+
result.append(f"[+ {total - processed} more items truncated]")
|
|
165
|
+
elif total is None:
|
|
166
|
+
try:
|
|
167
|
+
next(iterator)
|
|
168
|
+
except StopIteration:
|
|
169
|
+
pass
|
|
170
|
+
else:
|
|
171
|
+
result.append("[+ more items truncated]")
|
|
172
|
+
except Exception:
|
|
173
|
+
result.append("[Iterable traversal failed]")
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
properties = vars(obj)
|
|
178
|
+
except Exception:
|
|
179
|
+
return _type_summary(obj)
|
|
180
|
+
|
|
181
|
+
result = {}
|
|
182
|
+
try:
|
|
183
|
+
keys = [key for key in properties if not key.startswith("_")]
|
|
184
|
+
except Exception:
|
|
185
|
+
return _type_summary(obj)
|
|
186
|
+
|
|
187
|
+
for index, key in enumerate(keys[:max_object_properties]):
|
|
188
|
+
if redact_keys_re and redact_keys_re.search(key):
|
|
189
|
+
result[key] = "[REDACTED Key]"
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
child_path = f"{path}.{key}" if path else key
|
|
193
|
+
try:
|
|
194
|
+
value = properties[key]
|
|
195
|
+
result[key] = serialize(
|
|
196
|
+
value,
|
|
197
|
+
max_depth=max_depth,
|
|
198
|
+
max_array_length=max_array_length,
|
|
199
|
+
max_object_properties=max_object_properties,
|
|
200
|
+
max_string_length=max_string_length,
|
|
201
|
+
redact_keys_re=redact_keys_re,
|
|
202
|
+
redact_values_re=redact_values_re,
|
|
203
|
+
visited=visited,
|
|
204
|
+
depth=depth + 1,
|
|
205
|
+
path=child_path,
|
|
206
|
+
)
|
|
207
|
+
except Exception:
|
|
208
|
+
result[key] = "[Inaccessible]"
|
|
209
|
+
|
|
210
|
+
if len(keys) > max_object_properties:
|
|
211
|
+
result["__probe_meta"] = (
|
|
212
|
+
f"+ {len(keys) - max_object_properties} more properties truncated"
|
|
213
|
+
)
|
|
214
|
+
return result
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import importlib
|
|
4
|
+
from hyperprobe.core.logger import get_logger
|
|
5
|
+
|
|
6
|
+
logger = get_logger("hyperprobe:trace")
|
|
7
|
+
|
|
8
|
+
def extract_trace_context(custom_get_trace_id=None):
|
|
9
|
+
"""
|
|
10
|
+
Safely extract the active trace ID from standard APMs via sys.modules reflection
|
|
11
|
+
or utilize a custom user-defined hook.
|
|
12
|
+
"""
|
|
13
|
+
# 1. User-defined custom hook (Highest Priority - passed via code)
|
|
14
|
+
if custom_get_trace_id and callable(custom_get_trace_id):
|
|
15
|
+
try:
|
|
16
|
+
trace_id = custom_get_trace_id()
|
|
17
|
+
if trace_id:
|
|
18
|
+
return str(trace_id)
|
|
19
|
+
except Exception as e:
|
|
20
|
+
logger.info(f"[HyperProbe] Custom trace extractor failed: {e}")
|
|
21
|
+
|
|
22
|
+
# 1.5. Environment variable hook
|
|
23
|
+
hook_path = os.getenv("HYPERPROBE_TRACE_HOOK")
|
|
24
|
+
if hook_path and ":" in hook_path:
|
|
25
|
+
try:
|
|
26
|
+
module_name, func_name = hook_path.split(":", 1)
|
|
27
|
+
# Safely ensure CWD is in the import path
|
|
28
|
+
cwd = os.getcwd()
|
|
29
|
+
added_to_path = False
|
|
30
|
+
|
|
31
|
+
if cwd not in sys.path:
|
|
32
|
+
sys.path.insert(0, cwd)
|
|
33
|
+
added_to_path = True
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
user_module = importlib.import_module(module_name)
|
|
37
|
+
user_func = getattr(user_module, func_name)
|
|
38
|
+
trace_id = user_func()
|
|
39
|
+
if trace_id:
|
|
40
|
+
return str(trace_id)
|
|
41
|
+
finally:
|
|
42
|
+
# Clean up if we modified sys.path
|
|
43
|
+
if added_to_path:
|
|
44
|
+
sys.path.remove(cwd)
|
|
45
|
+
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.info(f"[HyperProbe] Failed to execute HYPERPROBE_TRACE_HOOK: {e}")
|
|
48
|
+
|
|
49
|
+
# 2. OpenTelemetry
|
|
50
|
+
try:
|
|
51
|
+
if 'opentelemetry.trace' in sys.modules:
|
|
52
|
+
otel_trace = sys.modules['opentelemetry.trace']
|
|
53
|
+
span = otel_trace.get_current_span()
|
|
54
|
+
if span:
|
|
55
|
+
ctx = span.get_span_context()
|
|
56
|
+
if ctx and ctx.is_valid:
|
|
57
|
+
return format(ctx.trace_id, '032x')
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
# 3. Datadog (ddtrace)
|
|
62
|
+
try:
|
|
63
|
+
if 'ddtrace' in sys.modules:
|
|
64
|
+
ddtrace = sys.modules['ddtrace']
|
|
65
|
+
span = ddtrace.tracer.current_span()
|
|
66
|
+
if span and span.trace_id:
|
|
67
|
+
return str(span.trace_id)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
# 4. New Relic
|
|
72
|
+
try:
|
|
73
|
+
if 'newrelic.agent' in sys.modules:
|
|
74
|
+
nr = sys.modules['newrelic.agent']
|
|
75
|
+
if hasattr(nr, 'current_trace_id'):
|
|
76
|
+
trace_id = nr.current_trace_id()
|
|
77
|
+
if trace_id:
|
|
78
|
+
return str(trace_id)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# 5. Elastic APM
|
|
83
|
+
try:
|
|
84
|
+
if 'elasticapm' in sys.modules:
|
|
85
|
+
elasticapm = sys.modules['elasticapm']
|
|
86
|
+
trace_id = elasticapm.get_trace_id()
|
|
87
|
+
if trace_id:
|
|
88
|
+
return str(trace_id)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
logger.info("[HyperProbe] trace id could not be extracted.")
|
|
93
|
+
return None
|