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 ADDED
@@ -0,0 +1,7 @@
1
+ __all__ = ["HyperProbe"]
2
+
3
+ def __getattr__(name):
4
+ if name == "HyperProbe":
5
+ from hyperprobe.agent import HyperProbeAgent
6
+ return HyperProbeAgent
7
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
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()
@@ -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