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
hyperprobe/__init__.py
ADDED
hyperprobe/agent.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from hyperprobe.core.quota import QuotaManager
|
|
11
|
+
from hyperprobe.core.safety import SafetyMonitor, AgentHealth
|
|
12
|
+
from hyperprobe.core.broker import BrokerClient
|
|
13
|
+
from hyperprobe.core.logger import get_logger
|
|
14
|
+
from hyperprobe.core.probe_output import ProbeLogWriter
|
|
15
|
+
|
|
16
|
+
logger = get_logger("hyperprobe:agent")
|
|
17
|
+
from hyperprobe.protos.agent_pb2 import PROBE_TYPE_UNSPECIFIED,PROBE_TYPE_SNAPSHOT,PROBE_TYPE_LOG,PROBE_TYPE_COUNTER,PROBE_TYPE_METRIC,PROBE_TYPE_DURATION
|
|
18
|
+
|
|
19
|
+
UUID_REGEX = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.IGNORECASE)
|
|
20
|
+
|
|
21
|
+
SUPPORTED_PROBE_TYPES = {PROBE_TYPE_UNSPECIFIED,PROBE_TYPE_SNAPSHOT,PROBE_TYPE_LOG,PROBE_TYPE_COUNTER,PROBE_TYPE_METRIC,PROBE_TYPE_DURATION}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _TelemetryItem:
|
|
25
|
+
def __init__(self, event, bandwidth_reservation=None):
|
|
26
|
+
self.event = event
|
|
27
|
+
self.bandwidth_reservation = bandwidth_reservation
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_valid_broker_url(url):
|
|
31
|
+
if not url or not str(url).strip():
|
|
32
|
+
return False
|
|
33
|
+
url = str(url).strip()
|
|
34
|
+
|
|
35
|
+
if url.startswith("http://") or url.startswith("https://"):
|
|
36
|
+
try:
|
|
37
|
+
parsed = urlparse(url)
|
|
38
|
+
return not url.endswith("/") and not parsed.query and bool(parsed.hostname)
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
# Raw gRPC targets should have no schema, no path, and no query parameters
|
|
43
|
+
return "://" not in url and "/" not in url and "?" not in url
|
|
44
|
+
|
|
45
|
+
class HyperProbeAgent:
|
|
46
|
+
_instance = None
|
|
47
|
+
_lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
def __init__(self, options):
|
|
50
|
+
self.options = options
|
|
51
|
+
self.agent_id = str(uuid.uuid4())
|
|
52
|
+
self.is_shutdown = False
|
|
53
|
+
self.stop_event = threading.Event()
|
|
54
|
+
|
|
55
|
+
# Options & Envs Parsing
|
|
56
|
+
self.sync_interval_sec = (options.get("sync_interval_ms") or int(os.getenv("HYPERPROBE_SYNC_INTERVAL_MS", 60000))) / 1000.0
|
|
57
|
+
self.flush_interval_sec = (options.get("flush_interval_ms") or int(os.getenv("HYPERPROBE_FLUSH_INTERVAL_MS", 1000))) / 1000.0
|
|
58
|
+
self.max_queue_size = options.get("max_queue_size") or int(os.getenv("HYPERPROBE_MAX_QUEUE_SIZE", 100))
|
|
59
|
+
self._cooldown_sec = options.get("hyperprobe_cooldown_sec") or int(os.getenv("HYPERPROBE_COOLDOWN_SEC", 10))
|
|
60
|
+
|
|
61
|
+
hits_per_sec = options.get("hits_per_sec") or int(os.getenv("HYPERPROBE_HITS_PER_SEC", 10))
|
|
62
|
+
bandwidth_kb = options.get("bandwidth_kb_per_sec") or int(os.getenv("HYPERPROBE_BANDWIDTH_KB_PER_SEC", 1024))
|
|
63
|
+
rpc_timeout_sec = options.get("hyperprobe_rpc_timeout_sec") or float(os.getenv("HYPERPROBE_RPC_TIMEOUT_SEC", 10.0))
|
|
64
|
+
max_lag_ms = options.get("hyperprobe_max_lag_ms") or float(os.getenv("HYPERPROBE_MAX_LAG_MS", 50.0))
|
|
65
|
+
pause_budget_ms = options.get("hyperprobe_pause_budget_ms") or float(os.getenv("HYPERPROBE_PAUSE_BUDGET_MS", 15.0))
|
|
66
|
+
|
|
67
|
+
self.global_config = {
|
|
68
|
+
"redact_keys": options.get("redact_keys") or os.getenv("HYPERPROBE_REDACT_KEYS", "password,secret,token,authorization,cookie,key,signature").split(","),
|
|
69
|
+
"redact_values": options.get("redact_values") or os.getenv("HYPERPROBE_REDACT_VALUES", "").split(","),
|
|
70
|
+
"max_object_depth": options.get("max_object_depth") or int(os.getenv("HYPERPROBE_MAX_OBJECT_DEPTH", 3)),
|
|
71
|
+
"max_array_length": options.get("max_array_length") or int(os.getenv("HYPERPROBE_MAX_ARRAY_LENGTH", 3)),
|
|
72
|
+
"stack_frame_depth": options.get("stack_frame_depth") or int(os.getenv("HYPERPROBE_STACK_FRAME_DEPTH", 3)),
|
|
73
|
+
"max_object_properties": options.get("max_object_properties") or int(os.getenv("HYPERPROBE_MAX_OBJECT_PROPERTIES", 50)),
|
|
74
|
+
"max_string_length": options.get("max_string_length") or int(os.getenv("HYPERPROBE_MAX_STRING_LENGTH", 1024))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Strip empty redaction strings
|
|
78
|
+
self.global_config["redact_keys"] = [k.strip() for k in self.global_config["redact_keys"] if k.strip()]
|
|
79
|
+
self.global_config["redact_values"] = [v.strip() for v in self.global_config["redact_values"] if v.strip()]
|
|
80
|
+
|
|
81
|
+
# Initialize core telemetry components
|
|
82
|
+
self.quota_manager = QuotaManager(hits_per_sec, bandwidth_kb * 1024)
|
|
83
|
+
self.safety_monitor = SafetyMonitor(
|
|
84
|
+
self._handle_health_change,
|
|
85
|
+
max_lag_ms=max_lag_ms,
|
|
86
|
+
pause_budget_ms=pause_budget_ms,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self.broker_client = BrokerClient(
|
|
90
|
+
broker_url=options["broker_url"],
|
|
91
|
+
service_id=options["service_id"],
|
|
92
|
+
environment=options["environment"],
|
|
93
|
+
commit_sha=options.get("commit_sha") or os.getenv("GIT_COMMIT", "unknown"),
|
|
94
|
+
agent_id=self.agent_id,
|
|
95
|
+
rpc_timeout_sec=rpc_timeout_sec,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Thread-safe queue for telemetry events
|
|
99
|
+
self.telemetry_queue = queue.Queue(maxsize=self.max_queue_size)
|
|
100
|
+
self.probe_log_writer = ProbeLogWriter(self.max_queue_size)
|
|
101
|
+
self.active_probes = {} # probe_id -> Probe
|
|
102
|
+
self.local_hits = {} # probe_id -> count
|
|
103
|
+
self.pending_hits = {} # probe_id -> queued capture reservations
|
|
104
|
+
self.cooldown_timer = None
|
|
105
|
+
self._cooldown_generation = 0
|
|
106
|
+
self._agent_lock = threading.RLock() # Synchronizes state access with re-entrancy safety
|
|
107
|
+
self._inflight_batch = None
|
|
108
|
+
|
|
109
|
+
# Determine and initialize dynamic instrumentation engine
|
|
110
|
+
self.engine = self._init_instrumentation_engine()
|
|
111
|
+
self.engine.set_global_config(self.global_config)
|
|
112
|
+
|
|
113
|
+
# Background worker threads
|
|
114
|
+
self.sync_thread = None
|
|
115
|
+
self.flush_thread = None
|
|
116
|
+
self.stats_thread = None
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def start(cls, options):
|
|
120
|
+
"""Starts the global HyperProbe Agent singleton."""
|
|
121
|
+
with cls._lock:
|
|
122
|
+
if cls._instance is not None:
|
|
123
|
+
logger.forceInfo("[HyperProbe] Agent is already running.")
|
|
124
|
+
return cls._instance
|
|
125
|
+
|
|
126
|
+
# Check kill switch
|
|
127
|
+
if os.getenv("HYPERPROBE_DISABLED", "").strip().upper() == "YES":
|
|
128
|
+
logger.forceInfo("[HyperProbe] Explicitly disabled via HYPERPROBE_DISABLED.")
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
commit_sha = options.get("commit_sha") or os.getenv("HYPERPROBE_COMMIT_SHA") or os.getenv("GIT_COMMIT")
|
|
132
|
+
if not commit_sha or not str(commit_sha).strip() or str(commit_sha).strip().lower() == "unknown":
|
|
133
|
+
logger.forceError('\033[1m\033[33m⚠️ [HyperProbe] CRITICAL: Failed to start agent. A valid "commit_sha" is required via options or the GIT_COMMIT environment variable to ensure accurate source map resolution and prevent cross-deployment collisions.\033[0m')
|
|
134
|
+
return None
|
|
135
|
+
options["commit_sha"] = commit_sha
|
|
136
|
+
|
|
137
|
+
service_id = options.get("service_id")
|
|
138
|
+
if not service_id or not str(service_id).strip():
|
|
139
|
+
logger.forceError('\033[1m\033[33m⚠️ [HyperProbe] CRITICAL: Failed to start agent. HYPERPROBE_SERVICE_ID is required.\033[0m')
|
|
140
|
+
return None
|
|
141
|
+
if not UUID_REGEX.match(str(service_id).strip()):
|
|
142
|
+
logger.forceInfo('\033[1m\033[33m⚠️ [HyperProbe] WARN: service_id is not a valid UUID, please check again.\033[0m')
|
|
143
|
+
|
|
144
|
+
environment = options.get("environment") or os.getenv("HYPERPROBE_ENVIRONMENT")
|
|
145
|
+
if not environment or not str(environment).strip():
|
|
146
|
+
logger.forceError('\033[1m\033[33m⚠️ [HyperProbe] CRITICAL: Failed to start agent. HYPERPROBE_ENVIRONMENT is required.\033[0m')
|
|
147
|
+
return None
|
|
148
|
+
options["environment"] = environment
|
|
149
|
+
|
|
150
|
+
broker_url = options.get("broker_url")
|
|
151
|
+
if not is_valid_broker_url(broker_url):
|
|
152
|
+
logger.forceError(f'\033[1m\033[33m⚠️ [HyperProbe] CRITICAL: Failed to start agent. Invalid broker_url "{broker_url}". It must be a valid URL (http:// or https://) or a raw gRPC target (e.g. localhost:60051), with no query parameters and no trailing slash.\033[0m')
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
cls._instance = cls(options)
|
|
156
|
+
cls._instance._start_background_loops()
|
|
157
|
+
logger.forceInfo(f"[HyperProbe] Python agent successfully started (ID: {cls._instance.agent_id}).")
|
|
158
|
+
return cls._instance
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def shutdown(cls):
|
|
162
|
+
"""Cleanly and safely shuts down the running agent singleton."""
|
|
163
|
+
with cls._lock:
|
|
164
|
+
if cls._instance is None:
|
|
165
|
+
return
|
|
166
|
+
cls._instance._stop()
|
|
167
|
+
cls._instance = None
|
|
168
|
+
print("[HyperProbe] Python agent successfully shut down.")
|
|
169
|
+
|
|
170
|
+
def _init_instrumentation_engine(self):
|
|
171
|
+
from hyperprobe.core.monitoring_engine import MonitoringEngine
|
|
172
|
+
return MonitoringEngine(
|
|
173
|
+
self.quota_manager,
|
|
174
|
+
self.safety_monitor,
|
|
175
|
+
self._handle_capture,
|
|
176
|
+
custom_set_trace_id=self.options.get("set_trace_id")
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _start_background_loops(self):
|
|
180
|
+
self.stop_event.clear()
|
|
181
|
+
self.safety_monitor.start()
|
|
182
|
+
|
|
183
|
+
self.sync_thread = threading.Thread(target=self._sync_loop, name="hyperprobe-sync")
|
|
184
|
+
self.sync_thread.daemon = True
|
|
185
|
+
self.sync_thread.start()
|
|
186
|
+
|
|
187
|
+
self.flush_thread = threading.Thread(target=self._flush_loop, name="hyperprobe-flush")
|
|
188
|
+
self.flush_thread.daemon = True
|
|
189
|
+
self.flush_thread.start()
|
|
190
|
+
|
|
191
|
+
self.stats_thread = threading.Thread(target=self._stats_loop, name="hyperprobe-stats")
|
|
192
|
+
self.stats_thread.daemon = True
|
|
193
|
+
self.stats_thread.start()
|
|
194
|
+
|
|
195
|
+
def _stop(self):
|
|
196
|
+
self.is_shutdown = True
|
|
197
|
+
self.stop_event.set()
|
|
198
|
+
self.safety_monitor.stop()
|
|
199
|
+
|
|
200
|
+
# Stop tracing immediately
|
|
201
|
+
self.engine.set_probes([])
|
|
202
|
+
self.engine.close()
|
|
203
|
+
self.probe_log_writer.stop(timeout=2.0)
|
|
204
|
+
|
|
205
|
+
with self._agent_lock:
|
|
206
|
+
self._cooldown_generation += 1
|
|
207
|
+
cooldown_timer = self.cooldown_timer
|
|
208
|
+
self.cooldown_timer = None
|
|
209
|
+
if cooldown_timer:
|
|
210
|
+
cooldown_timer.cancel()
|
|
211
|
+
|
|
212
|
+
self.broker_client.shutdown()
|
|
213
|
+
|
|
214
|
+
current_thread = threading.current_thread()
|
|
215
|
+
for thread in (self.sync_thread, self.flush_thread, self.stats_thread):
|
|
216
|
+
if thread and thread is not current_thread:
|
|
217
|
+
thread.join(timeout=2.0)
|
|
218
|
+
|
|
219
|
+
def _sync_loop(self):
|
|
220
|
+
while not self.stop_event.is_set():
|
|
221
|
+
try:
|
|
222
|
+
self._sync_with_broker()
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"[HyperProbe] Sync loop encountered error: {str(e)}")
|
|
225
|
+
self.stop_event.wait(self.sync_interval_sec)
|
|
226
|
+
|
|
227
|
+
def _sync_with_broker(self):
|
|
228
|
+
response = self.broker_client.get_probes()
|
|
229
|
+
if not response:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
# Update global config if returned
|
|
233
|
+
if response.HasField("global_config"):
|
|
234
|
+
gc = response.global_config
|
|
235
|
+
with self._agent_lock:
|
|
236
|
+
self.global_config.update({
|
|
237
|
+
"redact_keys": list(gc.redact_keys) if gc.redact_keys else self.global_config["redact_keys"],
|
|
238
|
+
"redact_values": list(gc.redact_values) if gc.redact_values else self.global_config["redact_values"],
|
|
239
|
+
"max_object_depth": gc.max_object_depth if gc.HasField("max_object_depth") else self.global_config["max_object_depth"],
|
|
240
|
+
"max_array_length": gc.max_array_length if gc.HasField("max_array_length") else self.global_config["max_array_length"],
|
|
241
|
+
"stack_frame_depth": gc.stack_frame_depth if gc.HasField("stack_frame_depth") else self.global_config["stack_frame_depth"],
|
|
242
|
+
"max_object_properties": gc.max_object_properties if gc.HasField("max_object_properties") else self.global_config["max_object_properties"],
|
|
243
|
+
"max_string_length": gc.max_string_length if gc.HasField("max_string_length") else self.global_config["max_string_length"],
|
|
244
|
+
})
|
|
245
|
+
self.engine.set_global_config(self.global_config)
|
|
246
|
+
|
|
247
|
+
# Only activate probe types implemented by this SDK.
|
|
248
|
+
server_probes = {}
|
|
249
|
+
for probe in response.probes:
|
|
250
|
+
if probe.type in SUPPORTED_PROBE_TYPES:
|
|
251
|
+
server_probes[probe.id] = probe
|
|
252
|
+
else:
|
|
253
|
+
msg = f"[HyperProbe] Ignoring unsupported probe type {probe.type} for probe {probe.id}."
|
|
254
|
+
logger.error(msg)
|
|
255
|
+
|
|
256
|
+
now_ms = int(time.time() * 1000)
|
|
257
|
+
|
|
258
|
+
with self._agent_lock:
|
|
259
|
+
# Cleanup stale state
|
|
260
|
+
self.active_probes = {pid: p for pid, p in self.active_probes.items() if pid in server_probes}
|
|
261
|
+
self.local_hits = {pid: count for pid, count in self.local_hits.items() if pid in server_probes}
|
|
262
|
+
self.pending_hits = {pid: count for pid, count in self.pending_hits.items() if pid in server_probes}
|
|
263
|
+
|
|
264
|
+
to_apply = []
|
|
265
|
+
for pid, p in server_probes.items():
|
|
266
|
+
hits = self.local_hits.get(pid, 0)
|
|
267
|
+
is_expired = p.expiry_time <= now_ms if p.expiry_time else False
|
|
268
|
+
|
|
269
|
+
if hits < p.hit_limit and not is_expired:
|
|
270
|
+
to_apply.append(p)
|
|
271
|
+
self.active_probes[pid] = p
|
|
272
|
+
else:
|
|
273
|
+
self.active_probes.pop(pid, None)
|
|
274
|
+
|
|
275
|
+
# set_probes updates the configured probes; the engine decides whether
|
|
276
|
+
# instrumentation may run while suspended.
|
|
277
|
+
self.engine.set_probes(to_apply)
|
|
278
|
+
|
|
279
|
+
def _flush_loop(self):
|
|
280
|
+
while not self.stop_event.is_set():
|
|
281
|
+
try:
|
|
282
|
+
self._flush_telemetry()
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.error(f"[HyperProbe] Flush loop encountered error: {str(e)}")
|
|
285
|
+
self.stop_event.wait(self.flush_interval_sec)
|
|
286
|
+
|
|
287
|
+
def _stats_loop(self):
|
|
288
|
+
while not self.stop_event.is_set():
|
|
289
|
+
try:
|
|
290
|
+
stats = self.engine.get_stats()
|
|
291
|
+
if stats["hits"] > 0 or stats["skips"] > 0:
|
|
292
|
+
logger.info(f"[HyperProbe] Probes Hit: {stats['hits']}, Probes Skipped: {stats['skips']}")
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.error(f"[HyperProbe] Stats loop encountered error: {str(e)}")
|
|
295
|
+
self.stop_event.wait(5.0)
|
|
296
|
+
|
|
297
|
+
def _flush_telemetry(self):
|
|
298
|
+
batch = self._inflight_batch
|
|
299
|
+
if batch is None:
|
|
300
|
+
batch = []
|
|
301
|
+
# Pull all currently queued events up to capacity. Keep the batch
|
|
302
|
+
# in-flight until the broker call succeeds so retries cannot lose it.
|
|
303
|
+
while not self.telemetry_queue.empty():
|
|
304
|
+
try:
|
|
305
|
+
item = self.telemetry_queue.get_nowait()
|
|
306
|
+
if not isinstance(item, _TelemetryItem):
|
|
307
|
+
item = _TelemetryItem(item)
|
|
308
|
+
batch.append(item)
|
|
309
|
+
except queue.Empty:
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
if not batch:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
self._inflight_batch = batch
|
|
316
|
+
try:
|
|
317
|
+
finished_probe_ids = self.broker_client.report_telemetry(
|
|
318
|
+
[item.event for item in batch]
|
|
319
|
+
)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.error(
|
|
322
|
+
f"[HyperProbe] Failed to flush telemetry: {str(e)}. "
|
|
323
|
+
f"Retaining {len(batch)} events for retry."
|
|
324
|
+
)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
for item in batch:
|
|
328
|
+
if item.bandwidth_reservation is not None:
|
|
329
|
+
item.bandwidth_reservation.commit()
|
|
330
|
+
self._inflight_batch = None
|
|
331
|
+
|
|
332
|
+
# If broker confirmed certain probes reached global limit, clear them locally
|
|
333
|
+
if finished_probe_ids:
|
|
334
|
+
changed = False
|
|
335
|
+
with self._agent_lock:
|
|
336
|
+
for pid in finished_probe_ids:
|
|
337
|
+
if pid in self.active_probes:
|
|
338
|
+
self.active_probes.pop(pid, None)
|
|
339
|
+
changed = True
|
|
340
|
+
if changed:
|
|
341
|
+
probes_list = list(self.active_probes.values())
|
|
342
|
+
if changed:
|
|
343
|
+
self.engine.set_probes(probes_list)
|
|
344
|
+
|
|
345
|
+
def _reserve_hit_slot(self, probe_id, probe):
|
|
346
|
+
with self._agent_lock:
|
|
347
|
+
if self.active_probes.get(probe_id) is not probe:
|
|
348
|
+
return False
|
|
349
|
+
committed_hits = self.local_hits.get(probe_id, 0)
|
|
350
|
+
pending_hits = self.pending_hits.get(probe_id, 0)
|
|
351
|
+
if committed_hits + pending_hits >= probe.hit_limit:
|
|
352
|
+
return False
|
|
353
|
+
self.pending_hits[probe_id] = pending_hits + 1
|
|
354
|
+
return True
|
|
355
|
+
|
|
356
|
+
def _release_hit_slot(self, probe_id):
|
|
357
|
+
with self._agent_lock:
|
|
358
|
+
pending_hits = self.pending_hits.get(probe_id, 0)
|
|
359
|
+
if pending_hits <= 1:
|
|
360
|
+
self.pending_hits.pop(probe_id, None)
|
|
361
|
+
else:
|
|
362
|
+
self.pending_hits[probe_id] = pending_hits - 1
|
|
363
|
+
|
|
364
|
+
def _commit_hit_slot(self, probe_id, probe):
|
|
365
|
+
with self._agent_lock:
|
|
366
|
+
pending_hits = self.pending_hits.get(probe_id, 0)
|
|
367
|
+
if pending_hits <= 1:
|
|
368
|
+
self.pending_hits.pop(probe_id, None)
|
|
369
|
+
else:
|
|
370
|
+
self.pending_hits[probe_id] = pending_hits - 1
|
|
371
|
+
|
|
372
|
+
hits = self.local_hits.get(probe_id, 0) + 1
|
|
373
|
+
self.local_hits[probe_id] = hits
|
|
374
|
+
if hits < probe.hit_limit:
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
self.active_probes.pop(probe_id, None)
|
|
378
|
+
return list(self.active_probes.values())
|
|
379
|
+
|
|
380
|
+
def _handle_capture(self, event):
|
|
381
|
+
probe_id = event["probe_id"]
|
|
382
|
+
with self._agent_lock:
|
|
383
|
+
probe = self.active_probes.get(probe_id)
|
|
384
|
+
if not probe:
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
if probe.type not in SUPPORTED_PROBE_TYPES:
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
if not self._reserve_hit_slot(probe_id, probe):
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
event_size = self.broker_client.estimate_event_size(event)
|
|
395
|
+
logger.info(f"[HyperProbe] Got event size : {event_size} Bytes")
|
|
396
|
+
except Exception as exc:
|
|
397
|
+
logger.error(
|
|
398
|
+
f"[HyperProbe] Failed to size telemetry for probe {probe_id}: {exc}"
|
|
399
|
+
)
|
|
400
|
+
self._release_hit_slot(probe_id)
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
bandwidth_reservation = self.quota_manager.reserve_bandwidth(event_size)
|
|
404
|
+
if bandwidth_reservation is None:
|
|
405
|
+
msg = f"[HyperProbe] Bandwidth quota exceeded; dropping event for probe {probe_id} , event size was : {event_size} bytes."
|
|
406
|
+
logger.error(msg)
|
|
407
|
+
self._release_hit_slot(probe_id)
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
# Hold the bandwidth reservation with the event until the broker
|
|
411
|
+
# confirms the batch. Release it if local queue admission fails.
|
|
412
|
+
item = _TelemetryItem(event, bandwidth_reservation)
|
|
413
|
+
try:
|
|
414
|
+
self.telemetry_queue.put_nowait(item)
|
|
415
|
+
except queue.Full:
|
|
416
|
+
bandwidth_reservation.release()
|
|
417
|
+
self._release_hit_slot(probe_id)
|
|
418
|
+
return
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
bandwidth_reservation.release()
|
|
421
|
+
self._release_hit_slot(probe_id)
|
|
422
|
+
logger.error(
|
|
423
|
+
f"[HyperProbe] Failed to queue telemetry for probe {probe_id}: {exc}"
|
|
424
|
+
)
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
probes_list = self._commit_hit_slot(probe_id, probe)
|
|
428
|
+
if (
|
|
429
|
+
probe.type == PROBE_TYPE_LOG
|
|
430
|
+
and getattr(probe, "should_log_to_stdout", False) is True
|
|
431
|
+
and not event.get("capture_error")
|
|
432
|
+
):
|
|
433
|
+
try:
|
|
434
|
+
timestamp_ms = event.get("timestamp_ms")
|
|
435
|
+
if timestamp_ms is None:
|
|
436
|
+
timestamp_ms = int(time.time() * 1000)
|
|
437
|
+
self.probe_log_writer.submit(
|
|
438
|
+
timestamp_ms,
|
|
439
|
+
getattr(probe, "log_level", ""),
|
|
440
|
+
event.get("evaluated_log", ""),
|
|
441
|
+
)
|
|
442
|
+
except Exception as exc:
|
|
443
|
+
# Local output must not affect admitted telemetry or hit accounting.
|
|
444
|
+
logger.error(
|
|
445
|
+
f"[HyperProbe] Failed to queue local output for probe "
|
|
446
|
+
f"{probe_id}: {exc}"
|
|
447
|
+
)
|
|
448
|
+
if probes_list is not None:
|
|
449
|
+
self.engine.set_probes(probes_list)
|
|
450
|
+
|
|
451
|
+
def _handle_health_change(self, health, reason):
|
|
452
|
+
if self.is_shutdown:
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
if health == AgentHealth.RED:
|
|
456
|
+
logger.error(f"[HyperProbe] Safety Shield Triggered (RED): {reason or ''}. Suspending tracing.")
|
|
457
|
+
with self._agent_lock:
|
|
458
|
+
self.engine.suspend()
|
|
459
|
+
|
|
460
|
+
if self.cooldown_timer is not None:
|
|
461
|
+
self.cooldown_timer.cancel()
|
|
462
|
+
|
|
463
|
+
self._cooldown_generation += 1
|
|
464
|
+
cooldown_generation = self._cooldown_generation
|
|
465
|
+
cooldown_sec = self._cooldown_sec
|
|
466
|
+
self.cooldown_timer = threading.Timer(
|
|
467
|
+
cooldown_sec,
|
|
468
|
+
lambda: self._cooldown_expired(cooldown_generation),
|
|
469
|
+
)
|
|
470
|
+
self.cooldown_timer.daemon = True
|
|
471
|
+
self.cooldown_timer.start()
|
|
472
|
+
|
|
473
|
+
elif health == AgentHealth.YELLOW:
|
|
474
|
+
logger.error(f"[HyperProbe] Safety Warning (YELLOW): {reason or ''}.")
|
|
475
|
+
elif health == AgentHealth.GREEN:
|
|
476
|
+
with self._agent_lock:
|
|
477
|
+
cooldown_expired = self.cooldown_timer is None
|
|
478
|
+
if cooldown_expired:
|
|
479
|
+
self._resume_instrumentation()
|
|
480
|
+
|
|
481
|
+
def _cooldown_expired(self, cooldown_generation):
|
|
482
|
+
with self._agent_lock:
|
|
483
|
+
if cooldown_generation != self._cooldown_generation:
|
|
484
|
+
return
|
|
485
|
+
self.cooldown_timer = None
|
|
486
|
+
self._resume_instrumentation()
|
|
487
|
+
|
|
488
|
+
def _resume_instrumentation(self):
|
|
489
|
+
with self._agent_lock:
|
|
490
|
+
if (
|
|
491
|
+
self.is_shutdown
|
|
492
|
+
or self.safety_monitor.get_health() != AgentHealth.GREEN
|
|
493
|
+
or self.cooldown_timer is not None
|
|
494
|
+
):
|
|
495
|
+
return
|
|
496
|
+
logger.info("[HyperProbe] Cooldown window expired. Resuming probes from local cache.")
|
|
497
|
+
probes_list = list(self.active_probes.values())
|
|
498
|
+
self.engine.set_probes(probes_list)
|
|
499
|
+
self.engine.resume()
|
hyperprobe/bootstrap.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
def main():
|
|
5
|
+
if len(sys.argv) < 2:
|
|
6
|
+
print("Usage: hyperprobe-run <python_script_or_command> [args...]")
|
|
7
|
+
sys.exit(1)
|
|
8
|
+
|
|
9
|
+
target_args = sys.argv[1:]
|
|
10
|
+
cmd = target_args[0]
|
|
11
|
+
|
|
12
|
+
# Locate the SDK and injection directories
|
|
13
|
+
sdk_path = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
parent_path = os.path.dirname(sdk_path)
|
|
15
|
+
injection_path = os.path.join(sdk_path, "core", "injection")
|
|
16
|
+
|
|
17
|
+
# We inject two things into PYTHONPATH:
|
|
18
|
+
# 1. The injection_path (which contains sitecustomize.py) so Python runs it natively at boot
|
|
19
|
+
# 2. The parent_path so that sitecustomize.py can successfully `import hyperprobe.agent`
|
|
20
|
+
|
|
21
|
+
old_pythonpath = os.environ.get("PYTHONPATH", "")
|
|
22
|
+
new_paths = os.pathsep.join((injection_path, parent_path))
|
|
23
|
+
|
|
24
|
+
if old_pythonpath:
|
|
25
|
+
os.environ["PYTHONPATH"] = os.pathsep.join((new_paths, old_pythonpath))
|
|
26
|
+
else:
|
|
27
|
+
os.environ["PYTHONPATH"] = new_paths
|
|
28
|
+
|
|
29
|
+
# Standard execution replacement
|
|
30
|
+
# The current hyperprobe-run process is completely replaced by the target application.
|
|
31
|
+
# Because we manipulated PYTHONPATH, Python will natively execute our sitecustomize.py
|
|
32
|
+
# before running the user's application, resulting in perfect, zero-code-change auto-instrumentation!
|
|
33
|
+
try:
|
|
34
|
+
os.execvp(cmd, target_args)
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
print(f"[HyperProbe] Error: Executable '{cmd}' not found in PATH.", file=sys.stderr)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# HyperProbe Core Module
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Shaded/vendored fallback
|
|
7
|
+
import _vendor.grpc as grpc
|
|
8
|
+
except ImportError:
|
|
9
|
+
import grpc
|
|
10
|
+
|
|
11
|
+
# Import our generated protos
|
|
12
|
+
from hyperprobe.protos import agent_pb2
|
|
13
|
+
from hyperprobe.protos import agent_pb2_grpc
|
|
14
|
+
from hyperprobe.core.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger("hyperprobe:broker")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BrokerTransportError(RuntimeError):
|
|
20
|
+
"""Raised when a broker RPC cannot be completed."""
|
|
21
|
+
|
|
22
|
+
class BrokerClient:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
broker_url,
|
|
26
|
+
service_id,
|
|
27
|
+
environment,
|
|
28
|
+
commit_sha,
|
|
29
|
+
agent_id,
|
|
30
|
+
agent_version=None,
|
|
31
|
+
rpc_timeout_sec=10.0,
|
|
32
|
+
):
|
|
33
|
+
self.broker_url = broker_url
|
|
34
|
+
self.service_id = service_id
|
|
35
|
+
self.environment = environment
|
|
36
|
+
self.commit_sha = commit_sha
|
|
37
|
+
self.agent_id = agent_id
|
|
38
|
+
if agent_version is None:
|
|
39
|
+
try:
|
|
40
|
+
from importlib.metadata import version
|
|
41
|
+
agent_version = version("hyperprobe-agent")
|
|
42
|
+
except Exception:
|
|
43
|
+
agent_version = "unknown"
|
|
44
|
+
self.agent_version = agent_version
|
|
45
|
+
self.rpc_timeout_sec = max(float(rpc_timeout_sec), 0.1)
|
|
46
|
+
self.metadata = (("x-hp-service-id", str(service_id)),)
|
|
47
|
+
self.hostname = os.uname().nodename if hasattr(os, 'uname') else 'unknown'
|
|
48
|
+
|
|
49
|
+
# Establish channel depending on URL protocol to support both secure (SSL) and insecure brokers
|
|
50
|
+
if broker_url.startswith("https://"):
|
|
51
|
+
clean_url = broker_url[8:]
|
|
52
|
+
self.channel = grpc.secure_channel(clean_url, grpc.ssl_channel_credentials())
|
|
53
|
+
elif broker_url.startswith("http://"):
|
|
54
|
+
clean_url = broker_url[7:]
|
|
55
|
+
self.channel = grpc.insecure_channel(clean_url)
|
|
56
|
+
else:
|
|
57
|
+
self.channel = grpc.insecure_channel(broker_url)
|
|
58
|
+
|
|
59
|
+
self.stub = agent_pb2_grpc.AgentBrokerStub(self.channel)
|
|
60
|
+
|
|
61
|
+
def shutdown(self):
|
|
62
|
+
self.channel.close()
|
|
63
|
+
|
|
64
|
+
def get_probes(self):
|
|
65
|
+
"""Fetches active probes from the HyperProbe Broker."""
|
|
66
|
+
request = agent_pb2.GetProbesRequest(
|
|
67
|
+
agent_id=self.agent_id,
|
|
68
|
+
service_id=self.service_id,
|
|
69
|
+
environment=self.environment,
|
|
70
|
+
commit_sha=self.commit_sha,
|
|
71
|
+
language="python",
|
|
72
|
+
agent_version=self.agent_version,
|
|
73
|
+
hostname=self.hostname
|
|
74
|
+
)
|
|
75
|
+
try:
|
|
76
|
+
response = self.stub.GetProbes(
|
|
77
|
+
request,
|
|
78
|
+
timeout=self.rpc_timeout_sec,
|
|
79
|
+
metadata=self.metadata,
|
|
80
|
+
)
|
|
81
|
+
return response
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise BrokerTransportError("GetProbes failed") from e
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _to_proto_event(event):
|
|
87
|
+
stack_frames = [
|
|
88
|
+
agent_pb2.StackFrame(
|
|
89
|
+
function_name=frame["function_name"],
|
|
90
|
+
file_name=frame["file_name"],
|
|
91
|
+
line_number=frame["line_number"],
|
|
92
|
+
column_number=frame["column_number"],
|
|
93
|
+
)
|
|
94
|
+
for frame in event.get("stack_frames", [])
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
proto_event = agent_pb2.TelemetryEvent(
|
|
98
|
+
probe_id=event["probe_id"],
|
|
99
|
+
timestamp_ms=event["timestamp_ms"],
|
|
100
|
+
stack_frames=stack_frames,
|
|
101
|
+
captured_vars_json=event.get("captured_vars_json", ""),
|
|
102
|
+
watch_results_json=event.get("watch_results_json", ""),
|
|
103
|
+
evaluated_log=event.get("evaluated_log", ""),
|
|
104
|
+
metric_value=event.get("metric_value", 0.0),
|
|
105
|
+
capture_error=event.get("capture_error", ""),
|
|
106
|
+
)
|
|
107
|
+
if event.get("trace_id"):
|
|
108
|
+
proto_event.trace_id = event["trace_id"]
|
|
109
|
+
return proto_event
|
|
110
|
+
|
|
111
|
+
def estimate_event_size(self, event):
|
|
112
|
+
return len(self._to_proto_event(event).SerializeToString())
|
|
113
|
+
|
|
114
|
+
def report_telemetry(self, events):
|
|
115
|
+
"""Sends a batch of captured Telemetry Events to the HyperProbe Broker."""
|
|
116
|
+
batch_events = [self._to_proto_event(event) for event in events]
|
|
117
|
+
|
|
118
|
+
batch = agent_pb2.TelemetryBatch(
|
|
119
|
+
agent_id=self.agent_id,
|
|
120
|
+
events=batch_events
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
response = self.stub.ReportTelemetry(
|
|
125
|
+
batch,
|
|
126
|
+
timeout=self.rpc_timeout_sec,
|
|
127
|
+
metadata=self.metadata,
|
|
128
|
+
)
|
|
129
|
+
return response.finished_probe_ids
|
|
130
|
+
except Exception as e:
|
|
131
|
+
raise BrokerTransportError("ReportTelemetry failed") from e
|