#!/usr/bin/env python3 """ S3 Log Monitoring Script - Real-time training log streaming Monitors ML training runs on RunPod with S3 log tailing. Supports both pod-based and run-based monitoring with rich output. Features: - List recent pods and runs - Stream training logs in real-time - Monitor hyperopt trials.json updates - Follow mode for continuous streaming - Configurable timeout - Color-coded output (errors, warnings, success) Usage: # List recent activity python3 scripts/monitor_logs.py # Monitor specific run (by run ID) python3 scripts/monitor_logs.py --run-id run_20251029_145151_hyperopt --follow # Monitor specific pod (by pod ID) python3 scripts/monitor_logs.py --pod-id w4srx0tgm5hfgu --follow # With timeout python3 scripts/monitor_logs.py --run-id run_20251029_145151_hyperopt --follow --timeout 30m Requirements: - .venv activation (source .venv/bin/activate) - Dependencies: rich, boto3, pydantic-settings - .env.runpod with S3 credentials """ import os import sys import re import time import argparse from pathlib import Path from typing import Optional from datetime import datetime, timedelta # Check for .venv activation is_venv = hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) if not is_venv: print("=" * 70) print("ERROR: Not running in a virtual environment (.venv)") print("=" * 70) print("This script requires .venv activation to ensure correct dependencies.") print() print("To fix this:") print(" 1. Activate .venv: source .venv/bin/activate") print(" 2. Install deps: pip install -r runpod/requirements.txt") print(" 3. Re-run script: python3 scripts/monitor_logs.py --help") print("=" * 70) sys.exit(1) # Get project root for later use script_dir = Path(__file__).parent project_root = script_dir.parent try: # Import from runpod module (clean architecture - no sys.path hacks) from runpod import PodMonitor, S3Client from runpod.config import get_config from runpod.errors import S3ObjectNotFoundError from rich.console import Console from rich.table import Table from rich.panel import Panel from rich.text import Text from rich import box except ImportError as e: print(f"ERROR: Could not import required modules: {e}") print() print("To fix this:") print(" 1. Ensure .venv is activated: source .venv/bin/activate") print(" 2. Install dependencies: pip install -r runpod/requirements.txt") print() sys.exit(1) # Change to project root for config loading os.chdir(project_root) # Set environment variable to help config find .env.runpod os.environ.setdefault('RUNPOD_CONFIG_PATH', str(project_root / '.env.runpod')) console = Console() def parse_timeout(timeout_str: str) -> Optional[int]: """ Parse timeout string to seconds. Args: timeout_str: Timeout string (e.g., '30m', '2h', '45s') Returns: Timeout in seconds, or None if invalid """ match = re.match(r'(\d+)([smh]?)$', timeout_str.lower()) if not match: return None value = int(match.group(1)) unit = match.group(2) or 's' if unit == 'h': return value * 3600 elif unit == 'm': return value * 60 else: # 's' return value def list_recent_runs(s3_client: S3Client, limit: int = 20) -> list[dict]: """ List recent training runs from S3. Args: s3_client: S3Client instance limit: Maximum number of runs to return Returns: List of run metadata dicts """ try: # List all training run directories response = s3_client.s3_client.list_objects_v2( Bucket=s3_client.bucket, Prefix="ml_training/training_runs/", Delimiter="/" ) runs = [] # Iterate through model types (mamba2, dqn, ppo, tft) for prefix_obj in response.get('CommonPrefixes', []): model_prefix = prefix_obj['Prefix'] # List runs for this model type model_response = s3_client.s3_client.list_objects_v2( Bucket=s3_client.bucket, Prefix=model_prefix, Delimiter="/" ) for run_prefix_obj in model_response.get('CommonPrefixes', []): run_path = run_prefix_obj['Prefix'] run_id = run_path.rstrip('/').split('/')[-1] # Extract model type model_type = model_prefix.rstrip('/').split('/')[-1] # Try to get log file metadata for last modified time log_key = f"{run_path}logs/training.log" try: log_obj = s3_client.s3_client.head_object( Bucket=s3_client.bucket, Key=log_key ) last_modified = log_obj['LastModified'] log_size = log_obj['ContentLength'] except: last_modified = None log_size = 0 # Check for trials.json (hyperopt indicator) trials_key = f"{run_path}hyperopt/trials.json" has_trials = s3_client.object_exists(trials_key) runs.append({ 'run_id': run_id, 'model_type': model_type, 'last_modified': last_modified, 'log_size': log_size, 'has_trials': has_trials, 'log_key': log_key, 'trials_key': trials_key if has_trials else None }) # Sort by last modified (newest first) # Use min datetime with UTC timezone for consistency from datetime import timezone min_dt = datetime.min.replace(tzinfo=timezone.utc) runs.sort(key=lambda x: x['last_modified'] or min_dt, reverse=True) return runs[:limit] except Exception as e: console.print(f"[red]Error listing runs: {e}[/red]") return [] def display_recent_runs(runs: list[dict]) -> None: """Display recent runs in a formatted table.""" if not runs: console.print("[yellow]No recent runs found[/yellow]") return table = Table(title="Recent Training Runs", box=box.ROUNDED) table.add_column("Run ID", style="cyan", no_wrap=True) table.add_column("Model", style="magenta") table.add_column("Last Modified", style="green") table.add_column("Log Size", justify="right", style="blue") table.add_column("Trials", justify="center") for run in runs: run_id = run['run_id'] model_type = run['model_type'].upper() if run['last_modified']: # Format as relative time now = datetime.now(run['last_modified'].tzinfo) delta = now - run['last_modified'] if delta < timedelta(minutes=1): time_str = "just now" elif delta < timedelta(hours=1): time_str = f"{int(delta.total_seconds() / 60)}m ago" elif delta < timedelta(days=1): time_str = f"{int(delta.total_seconds() / 3600)}h ago" else: time_str = f"{delta.days}d ago" else: time_str = "unknown" # Format log size if run['log_size'] > 0: if run['log_size'] < 1024: size_str = f"{run['log_size']}B" elif run['log_size'] < 1024 * 1024: size_str = f"{run['log_size'] / 1024:.1f}KB" else: size_str = f"{run['log_size'] / (1024 * 1024):.2f}MB" else: size_str = "-" trials_str = "✓" if run['has_trials'] else "-" table.add_row(run_id, model_type, time_str, size_str, trials_str) console.print() console.print(table) console.print() console.print("[dim]Use --run-id to monitor a specific run[/dim]") console.print() def stream_run_logs( s3_client: S3Client, run_id: str, follow: bool = True, timeout: Optional[int] = None, poll_interval: int = 5 ) -> None: """ Stream logs for a specific run. Args: s3_client: S3Client instance run_id: Run ID to monitor follow: Continue streaming until completion timeout: Maximum monitoring time in seconds poll_interval: Polling interval in seconds """ # Find the run's log path try: # Search for run across all model types for model_type in ['mamba2', 'dqn', 'ppo', 'tft']: log_key = f"ml_training/training_runs/{model_type}/{run_id}/logs/training.log" if s3_client.object_exists(log_key): trials_key = f"ml_training/training_runs/{model_type}/{run_id}/hyperopt/trials.json" has_trials = s3_client.object_exists(trials_key) break else: console.print(f"[red]Run not found: {run_id}[/red]") console.print("[dim]Use 'python3 scripts/monitor_logs.py' to list available runs[/dim]") return except Exception as e: console.print(f"[red]Error finding run: {e}[/red]") return # Display header console.print() console.print(Panel.fit( f"[bold cyan]Monitoring Run: {run_id}[/bold cyan]\n" f"Model: [magenta]{model_type.upper()}[/magenta]\n" f"Log: [dim]{log_key}[/dim]\n" f"Hyperopt: [green]Yes[/green]" if has_trials else "[dim]No[/dim]", title="Log Monitor", border_style="cyan" )) console.print() # Stream logs log_position = 0 start_time = time.time() last_trials_check = 0 # Pattern detection completion_patterns = [ "Training complete", "Model saved to", "✓ Training finished", "SUCCESS:", "Hyperparameter optimization complete" ] error_patterns = [ "CUDA out of memory", "RuntimeError:", "AssertionError:", "FAILED:", "ERROR:", "panic!" ] training_complete = False error_detected = False try: console.print("[dim]Streaming logs... (Press Ctrl+C to stop)[/dim]") console.print("─" * 70) while True: # Check timeout if timeout and (time.time() - start_time) > timeout: console.print() console.print("[yellow]⏱️ Timeout reached[/yellow]") break # Check for new log content try: content, log_position = s3_client.tail_log_file(log_key, start_byte=log_position) if content: text = content.decode('utf-8', errors='ignore') lines = text.splitlines() for line in lines: # Color-code output based on content if any(pattern in line for pattern in error_patterns): console.print(f"[red]{line}[/red]") error_detected = True elif "WARN" in line.upper(): console.print(f"[yellow]{line}[/yellow]") elif "SUCCESS" in line.upper() or "✓" in line: console.print(f"[green]{line}[/green]") elif any(pattern in line for pattern in completion_patterns): console.print(f"[bold green]{line}[/bold green]") training_complete = True else: console.print(line) except S3ObjectNotFoundError: if not follow: console.print("[yellow]⚠ Log file not found[/yellow]") return # Still waiting for log file to appear pass # Check trials.json updates (every 30 seconds) if has_trials and (time.time() - last_trials_check) > 30: last_trials_check = time.time() try: trials_obj = s3_client.s3_client.get_object( Bucket=s3_client.bucket, Key=trials_key ) trials_content = trials_obj['Body'].read().decode('utf-8') # Parse trial count import json trials_data = json.loads(trials_content) trial_count = len(trials_data) console.print(f"[dim]📊 Hyperopt trials: {trial_count}[/dim]") except: pass # Check completion if training_complete or error_detected: console.print() console.print("─" * 70) if training_complete: console.print("[bold green]✅ Training completed![/bold green]") if error_detected: console.print("[bold red]❌ Error detected in logs[/bold red]") break if not follow: break time.sleep(poll_interval) except KeyboardInterrupt: console.print() console.print() console.print("[yellow]Streaming stopped by user[/yellow]") def stream_pod_logs( pod_id: str, config, follow: bool = True, timeout: Optional[int] = None, poll_interval: int = 5 ) -> None: """ Stream logs for a specific pod using PodMonitor. Args: pod_id: Pod ID to monitor config: RunPodConfig instance follow: Continue streaming until completion timeout: Maximum monitoring time in seconds poll_interval: Polling interval in seconds """ try: monitor = PodMonitor(pod_id=pod_id, config=config) # Display pod info monitor.display_pod_info() # Stream logs max_lines = None if timeout: # Estimate max lines based on timeout max_lines = timeout * 10 # Rough estimate monitor.stream_s3_logs( follow=follow, poll_interval=poll_interval, max_lines=max_lines ) except Exception as e: console.print(f"[red]Error monitoring pod: {e}[/red]") def main(): """Main execution function.""" parser = argparse.ArgumentParser( description="Monitor ML training logs on RunPod S3", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # List recent runs python3 scripts/monitor_logs.py # Monitor specific run (continuous streaming) python3 scripts/monitor_logs.py --run-id run_20251029_145151_hyperopt --follow # Monitor specific pod python3 scripts/monitor_logs.py --pod-id w4srx0tgm5hfgu --follow # With timeout (30 minutes) python3 scripts/monitor_logs.py --run-id run_20251029_145151_hyperopt --follow --timeout 30m # One-time log snapshot (no streaming) python3 scripts/monitor_logs.py --run-id run_20251029_145151_hyperopt Requirements: - .venv activation (source .venv/bin/activate) - .env.runpod with S3 credentials - Dependencies: rich, boto3, pydantic-settings """ ) parser.add_argument( '--run-id', help='Run ID to monitor (e.g., run_20251029_145151_hyperopt)' ) parser.add_argument( '--pod-id', help='Pod ID to monitor (e.g., w4srx0tgm5hfgu)' ) parser.add_argument( '--follow', action='store_true', help='Continuously stream logs until completion' ) parser.add_argument( '--timeout', default=None, help='Maximum monitoring time (e.g., 30m, 2h). Default: no timeout' ) parser.add_argument( '--interval', type=int, default=5, help='Log polling interval in seconds (default: 5)' ) parser.add_argument( '--limit', type=int, default=20, help='Maximum number of runs to list (default: 20)' ) args = parser.parse_args() # Parse timeout timeout_seconds = None if args.timeout: timeout_seconds = parse_timeout(args.timeout) if timeout_seconds is None: console.print(f"[red]Invalid timeout format: {args.timeout}[/red]") console.print("[dim]Use format like '30m', '2h', or '45s'[/dim]") sys.exit(1) # Validate arguments if args.run_id and args.pod_id: console.print("[red]Error: Cannot specify both --run-id and --pod-id[/red]") sys.exit(1) try: # Load config from explicit path from runpod.config import RunPodConfig config = RunPodConfig.load_from_file(project_root / '.env.runpod') s3_client = S3Client(config) # Monitor specific run if args.run_id: stream_run_logs( s3_client=s3_client, run_id=args.run_id, follow=args.follow, timeout=timeout_seconds, poll_interval=args.interval ) # Monitor specific pod elif args.pod_id: stream_pod_logs( pod_id=args.pod_id, config=config, follow=args.follow, timeout=timeout_seconds, poll_interval=args.interval ) # List recent runs else: runs = list_recent_runs(s3_client, limit=args.limit) display_recent_runs(runs) except Exception as e: console.print(f"[red]Error: {e}[/red]") sys.exit(1) if __name__ == "__main__": main()