logtap 0.3.0__py3-none-any.whl → 0.4.1__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.
@@ -0,0 +1,107 @@
1
+ """Collector command for logtap CLI."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ console = Console()
10
+
11
+
12
+ def collect(
13
+ port: int = typer.Option(
14
+ 8000,
15
+ "--port",
16
+ "-p",
17
+ help="Port to listen on.",
18
+ ),
19
+ host: str = typer.Option(
20
+ "0.0.0.0",
21
+ "--host",
22
+ "-H",
23
+ help="Host to bind to.",
24
+ ),
25
+ api_key: Optional[str] = typer.Option(
26
+ None,
27
+ "--api-key",
28
+ "-k",
29
+ help="API key for authentication.",
30
+ envvar="LOGTAP_API_KEY",
31
+ ),
32
+ data_dir: Path = typer.Option(
33
+ Path("~/.logtap/runs").expanduser(),
34
+ "--data-dir",
35
+ "-d",
36
+ help="Directory for run storage.",
37
+ ),
38
+ buffer_lines: int = typer.Option(
39
+ 100_000,
40
+ "--buffer-lines",
41
+ help="In-memory cache size per run.",
42
+ ),
43
+ max_disk_mb: int = typer.Option(
44
+ 1000,
45
+ "--max-disk-mb",
46
+ help="Maximum disk usage across all runs (MB).",
47
+ ),
48
+ retention_hours: int = typer.Option(
49
+ 72,
50
+ "--retention-hours",
51
+ help="Hours to retain runs before cleanup.",
52
+ ),
53
+ reload: bool = typer.Option(
54
+ False,
55
+ "--reload",
56
+ "-r",
57
+ help="Enable auto-reload for development.",
58
+ ),
59
+ ) -> None:
60
+ """
61
+ Start the logtap collector server.
62
+
63
+ Accepts ingested log streams over HTTP and serves them for tailing.
64
+ This is the recommended mode for ML training logs.
65
+
66
+ Example:
67
+ logtap collect
68
+ logtap collect --port 9000 --api-key secret
69
+ logtap collect --data-dir /mnt/logs --max-disk-mb 5000
70
+ """
71
+ import os
72
+
73
+ import uvicorn
74
+
75
+ # Expand data_dir
76
+ data_dir = Path(data_dir).expanduser()
77
+
78
+ # Set environment variables for the app
79
+ os.environ["LOGTAP_HOST"] = host
80
+ os.environ["LOGTAP_PORT"] = str(port)
81
+ os.environ["LOGTAP_MODE"] = "collect"
82
+ os.environ["LOGTAP_DATA_DIR"] = str(data_dir)
83
+ os.environ["LOGTAP_BUFFER_LINES"] = str(buffer_lines)
84
+ os.environ["LOGTAP_MAX_DISK_MB"] = str(max_disk_mb)
85
+ os.environ["LOGTAP_RETENTION_HOURS"] = str(retention_hours)
86
+ if api_key:
87
+ os.environ["LOGTAP_API_KEY"] = api_key
88
+
89
+ console.print("[bold green]Starting logtap collector[/bold green]")
90
+ console.print(f" [dim]Host:[/dim] {host}")
91
+ console.print(f" [dim]Port:[/dim] {port}")
92
+ console.print(f" [dim]Data directory:[/dim] {data_dir}")
93
+ console.print(f" [dim]Buffer lines:[/dim] {buffer_lines:,}")
94
+ console.print(f" [dim]Max disk:[/dim] {max_disk_mb} MB")
95
+ console.print(f" [dim]Retention:[/dim] {retention_hours} hours")
96
+ console.print(f" [dim]Auth:[/dim] {'enabled' if api_key else 'disabled'}")
97
+ console.print()
98
+ console.print(f"[dim]API docs available at[/dim] http://{host}:{port}/docs")
99
+ console.print()
100
+
101
+ uvicorn.run(
102
+ "logtap.api.app:create_collector_app",
103
+ host=host,
104
+ port=port,
105
+ reload=reload,
106
+ factory=True,
107
+ )
@@ -0,0 +1,127 @@
1
+ """Doctor command for logtap CLI - diagnose connection issues."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ console = Console()
9
+
10
+
11
+ def doctor(
12
+ server: str = typer.Option(
13
+ "http://localhost:8000",
14
+ "--server",
15
+ "-s",
16
+ help="Server URL to check.",
17
+ envvar="LOGTAP_SERVER",
18
+ ),
19
+ api_key: Optional[str] = typer.Option(
20
+ None,
21
+ "--api-key",
22
+ "-k",
23
+ help="API key for authentication.",
24
+ envvar="LOGTAP_API_KEY",
25
+ ),
26
+ ) -> None:
27
+ """
28
+ Check server connectivity and diagnose issues.
29
+
30
+ Verifies the server is reachable, auth works, and reports capabilities.
31
+
32
+ Example:
33
+ logtap doctor
34
+ logtap doctor --server http://gpu-box:8000
35
+ """
36
+ import httpx
37
+
38
+ console.print(f"[bold]Checking {server}[/bold]\n")
39
+
40
+ # Build headers
41
+ headers = {}
42
+ if api_key:
43
+ headers["X-API-Key"] = api_key
44
+
45
+ # Step 1: Check if server is reachable
46
+ console.print("[dim]1. Server reachable?[/dim]", end=" ")
47
+ try:
48
+ with httpx.Client(timeout=10) as client:
49
+ response = client.get(f"{server}/health", headers=headers)
50
+ except httpx.ConnectError:
51
+ console.print("[red]NO[/red]")
52
+ console.print(f"\n[red]Could not connect to {server}[/red]")
53
+ console.print("\n[dim]Possible causes:[/dim]")
54
+ console.print(" - Server not running (start with: logtap collect)")
55
+ console.print(" - Wrong host/port")
56
+ console.print(" - Firewall blocking connection")
57
+ console.print(f"\n[dim]Try:[/dim] curl {server}/health")
58
+ raise typer.Exit(1)
59
+ except httpx.TimeoutException:
60
+ console.print("[red]TIMEOUT[/red]")
61
+ console.print(f"\n[red]Connection timed out to {server}[/red]")
62
+ raise typer.Exit(1)
63
+
64
+ console.print("[green]YES[/green]")
65
+
66
+ # Step 2: Check auth
67
+ console.print("[dim]2. Auth working?[/dim]", end=" ")
68
+ if response.status_code == 401:
69
+ console.print("[red]NO (401 Unauthorized)[/red]")
70
+ console.print("\n[red]API key required or invalid[/red]")
71
+ if not api_key:
72
+ console.print("\n[dim]Try:[/dim] logtap doctor --api-key YOUR_KEY")
73
+ console.print("[dim]Or:[/dim] export LOGTAP_API_KEY=YOUR_KEY")
74
+ else:
75
+ console.print("\n[dim]Check that your API key matches the server's --api-key[/dim]")
76
+ raise typer.Exit(1)
77
+ elif response.status_code >= 400:
78
+ console.print(f"[red]NO ({response.status_code})[/red]")
79
+ raise typer.Exit(1)
80
+
81
+ if api_key:
82
+ console.print("[green]YES (key accepted)[/green]")
83
+ else:
84
+ console.print("[green]YES (no auth required)[/green]")
85
+
86
+ # Step 3: Parse health response
87
+ try:
88
+ health = response.json()
89
+ except Exception:
90
+ console.print("\n[yellow]Warning: Could not parse health response[/yellow]")
91
+ health = {}
92
+
93
+ # Step 4: Check features
94
+ console.print("[dim]3. Features?[/dim]", end=" ")
95
+ mode = health.get("mode", "unknown")
96
+ features = health.get("features", [])
97
+ version = health.get("version", "unknown")
98
+
99
+ if "runs" in features:
100
+ console.print("[green]runs[/green] (collector mode)")
101
+ elif "files" in features:
102
+ console.print("[cyan]files[/cyan] (legacy serve mode)")
103
+ else:
104
+ console.print(f"[yellow]{mode}[/yellow]")
105
+
106
+ # Step 5: Show summary
107
+ console.print("\n[bold green]Server OK[/bold green]")
108
+ console.print(f" Version: {version}")
109
+ console.print(f" Mode: {mode}")
110
+ console.print(f" Features: {', '.join(features) if features else 'unknown'}")
111
+
112
+ if "runs" in features:
113
+ runs_count = health.get("runs", 0)
114
+ console.print(f" Active runs: {runs_count}")
115
+
116
+ # Show curl command for debugging
117
+ console.print("\n[dim]Debug command:[/dim]")
118
+ if api_key:
119
+ console.print(f' curl -H "X-API-Key: {api_key}" {server}/health')
120
+ else:
121
+ console.print(f" curl {server}/health")
122
+
123
+ # Show quick start if collector mode
124
+ if "runs" in features:
125
+ console.print("\n[dim]Quick start:[/dim]")
126
+ console.print(" python train.py 2>&1 | logtap ingest run1")
127
+ console.print(" logtap tail run1 --follow")
@@ -0,0 +1,123 @@
1
+ """Ingest command for logtap CLI."""
2
+
3
+ import sys
4
+ from datetime import datetime
5
+ from typing import List, Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ console = Console(stderr=True) # Output status to stderr so it doesn't mix with piped data
11
+
12
+
13
+ def ingest(
14
+ name: Optional[str] = typer.Argument(
15
+ None,
16
+ help="Run name. If omitted, generates: run-YYYYMMDD-HHMMSS",
17
+ ),
18
+ server: str = typer.Option(
19
+ "http://localhost:8000",
20
+ "--server",
21
+ "-s",
22
+ help="Collector server URL.",
23
+ envvar="LOGTAP_SERVER",
24
+ ),
25
+ api_key: Optional[str] = typer.Option(
26
+ None,
27
+ "--api-key",
28
+ "-k",
29
+ help="API key for authentication.",
30
+ envvar="LOGTAP_API_KEY",
31
+ ),
32
+ tag: Optional[List[str]] = typer.Option(
33
+ None,
34
+ "--tag",
35
+ "-t",
36
+ help="Tags as key=value (repeatable).",
37
+ ),
38
+ quiet: bool = typer.Option(
39
+ False,
40
+ "--quiet",
41
+ "-q",
42
+ help="Suppress status messages.",
43
+ ),
44
+ ) -> None:
45
+ """
46
+ Pipe stdin to collector as a named run.
47
+
48
+ Reads from stdin and streams lines to the collector server.
49
+ Designed to be used with pipes for training logs.
50
+
51
+ Example:
52
+ python train.py 2>&1 | logtap ingest run1
53
+ python train.py 2>&1 | logtap ingest --tag node=gpu1
54
+ cat training.log | logtap ingest my-run --server http://collector:8000
55
+ """
56
+ import httpx
57
+
58
+ # Generate run name if not provided
59
+ if name is None:
60
+ name = datetime.now().strftime("run-%Y%m%d-%H%M%S")
61
+
62
+ # Build headers as list of tuples (supports duplicate keys)
63
+ headers: List[tuple] = [("Content-Type", "text/plain")]
64
+ if api_key:
65
+ headers.append(("X-API-Key", api_key))
66
+ if tag:
67
+ for t in tag:
68
+ headers.append(("X-Logtap-Tag", t))
69
+
70
+ url = f"{server.rstrip('/')}/runs/{name}/ingest"
71
+
72
+ if not quiet:
73
+ console.print(f"[dim]Ingesting to[/dim] {name} [dim]@[/dim] {server}")
74
+
75
+ try:
76
+ # Read all stdin
77
+ content = sys.stdin.read()
78
+
79
+ if not quiet:
80
+ line_count = content.count("\n") + (1 if content and not content.endswith("\n") else 0)
81
+ console.print(f"[dim]Sending {line_count} lines...[/dim]")
82
+
83
+ # Use httpx
84
+ with httpx.Client(timeout=None) as client:
85
+ response = client.post(url, content=content, headers=headers)
86
+
87
+ if response.status_code == 401:
88
+ console.print("[red]Error: Unauthorized. Check your API key.[/red]")
89
+ raise typer.Exit(1)
90
+ elif response.status_code == 409:
91
+ try:
92
+ data = response.json()
93
+ msg = data.get("detail", {}).get("message", "Tag conflict")
94
+ except Exception:
95
+ msg = "Tag conflict"
96
+ console.print(f"[red]Error: {msg}[/red]")
97
+ raise typer.Exit(1)
98
+ elif response.status_code == 507:
99
+ console.print("[red]Error: Server storage limit exceeded.[/red]")
100
+ raise typer.Exit(1)
101
+ elif response.status_code >= 400:
102
+ console.print(f"[red]Error: Server returned {response.status_code}[/red]")
103
+ try:
104
+ console.print(f"[dim]{response.text}[/dim]")
105
+ except Exception:
106
+ pass
107
+ raise typer.Exit(1)
108
+
109
+ data = response.json()
110
+ if not quiet:
111
+ console.print(
112
+ f"[green]Done.[/green] "
113
+ f"Ingested {data['lines_ingested']} lines to [bold]{data['run_id']}[/bold] "
114
+ f"(cursor: {data['cursor_end']})"
115
+ )
116
+
117
+ except httpx.ConnectError:
118
+ console.print(f"[red]Error: Could not connect to {server}[/red]")
119
+ console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
120
+ raise typer.Exit(1)
121
+ except httpx.TimeoutException:
122
+ console.print("[red]Error: Connection timed out[/red]")
123
+ raise typer.Exit(1)
@@ -0,0 +1,116 @@
1
+ """Runs command for logtap CLI."""
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ console = Console()
10
+
11
+
12
+ def runs(
13
+ server: str = typer.Option(
14
+ "http://localhost:8000",
15
+ "--server",
16
+ "-s",
17
+ help="Collector server URL.",
18
+ envvar="LOGTAP_SERVER",
19
+ ),
20
+ api_key: Optional[str] = typer.Option(
21
+ None,
22
+ "--api-key",
23
+ "-k",
24
+ help="API key for authentication.",
25
+ envvar="LOGTAP_API_KEY",
26
+ ),
27
+ since_hours: Optional[int] = typer.Option(
28
+ 24,
29
+ "--since-hours",
30
+ help="Show runs active within N hours.",
31
+ ),
32
+ ) -> None:
33
+ """
34
+ List runs on a collector.
35
+
36
+ Shows all active and recent runs with their line counts and tags.
37
+
38
+ Example:
39
+ logtap runs
40
+ logtap runs --server http://gpu-box:8000
41
+ logtap runs --since-hours 48
42
+ """
43
+ from datetime import datetime, timezone
44
+
45
+ import httpx
46
+
47
+ # Build headers
48
+ headers = {}
49
+ if api_key:
50
+ headers["X-API-Key"] = api_key
51
+
52
+ url = f"{server.rstrip('/')}/runs"
53
+ params = {}
54
+ if since_hours is not None:
55
+ params["since_hours"] = since_hours
56
+
57
+ try:
58
+ with httpx.Client(timeout=30) as client:
59
+ response = client.get(url, headers=headers, params=params)
60
+
61
+ if response.status_code == 401:
62
+ console.print("[red]Error: Unauthorized. Check your API key.[/red]")
63
+ raise typer.Exit(1)
64
+ elif response.status_code >= 400:
65
+ console.print(f"[red]Error: Server returned {response.status_code}[/red]")
66
+ raise typer.Exit(1)
67
+
68
+ data = response.json()
69
+ runs_list = data.get("runs", [])
70
+
71
+ if not runs_list:
72
+ console.print("[dim]No runs found.[/dim]")
73
+ return
74
+
75
+ # Create table
76
+ table = Table(show_header=True, header_style="bold")
77
+ table.add_column("RUN", style="cyan")
78
+ table.add_column("LINES", justify="right")
79
+ table.add_column("LAST ACTIVITY", style="dim")
80
+ table.add_column("TAGS")
81
+
82
+ now = datetime.now(timezone.utc)
83
+
84
+ for run in runs_list:
85
+ # Format last activity as relative time
86
+ last_activity = datetime.fromisoformat(run["last_activity"].replace("Z", "+00:00"))
87
+ delta = now - last_activity
88
+ if delta.total_seconds() < 60:
89
+ time_str = f"{int(delta.total_seconds())}s ago"
90
+ elif delta.total_seconds() < 3600:
91
+ time_str = f"{int(delta.total_seconds() / 60)}m ago"
92
+ elif delta.total_seconds() < 86400:
93
+ time_str = f"{int(delta.total_seconds() / 3600)}h ago"
94
+ else:
95
+ time_str = f"{int(delta.total_seconds() / 86400)}d ago"
96
+
97
+ # Format tags
98
+ tags = run.get("tags", {})
99
+ tags_str = ", ".join(f"{k}={v}" for k, v in tags.items()) if tags else ""
100
+
101
+ table.add_row(
102
+ run["id"],
103
+ f"{run['lines']:,}",
104
+ time_str,
105
+ tags_str,
106
+ )
107
+
108
+ console.print(table)
109
+
110
+ except httpx.ConnectError:
111
+ console.print(f"[red]Error: Could not connect to {server}[/red]")
112
+ console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
113
+ raise typer.Exit(1)
114
+ except httpx.TimeoutException:
115
+ console.print("[red]Error: Connection timed out[/red]")
116
+ raise typer.Exit(1)