logtap 0.2.2__py3-none-any.whl → 0.4.0__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.
- logtap/__init__.py +1 -1
- logtap/api/app.py +69 -3
- logtap/api/routes/health.py +26 -4
- logtap/api/routes/logs.py +2 -2
- logtap/api/routes/runs.py +351 -0
- logtap/cli/commands/collect.py +107 -0
- logtap/cli/commands/ingest.py +123 -0
- logtap/cli/commands/runs.py +116 -0
- logtap/cli/commands/tail.py +212 -23
- logtap/cli/main.py +11 -5
- logtap/core/parsers/base.py +3 -1
- logtap/core/parsers/json_parser.py +11 -0
- logtap/core/reader.py +3 -5
- logtap/core/runs.py +393 -0
- logtap/core/search.py +15 -11
- logtap/models/responses.py +54 -1
- {logtap-0.2.2.dist-info → logtap-0.4.0.dist-info}/METADATA +23 -21
- {logtap-0.2.2.dist-info → logtap-0.4.0.dist-info}/RECORD +22 -17
- {logtap-0.2.2.dist-info → logtap-0.4.0.dist-info}/WHEEL +1 -1
- logtap-0.4.0.dist-info/entry_points.txt +2 -0
- logtap-0.2.2.dist-info/entry_points.txt +0 -3
- {logtap-0.2.2.dist-info → logtap-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|
logtap/cli/commands/tail.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Tail command for logtap CLI - real-time log streaming."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import json
|
|
4
|
+
from typing import List, Optional
|
|
4
5
|
|
|
5
6
|
import typer
|
|
6
7
|
from rich.console import Console
|
|
@@ -9,12 +10,12 @@ console = Console()
|
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def tail(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
help="
|
|
13
|
+
target: str = typer.Argument(
|
|
14
|
+
...,
|
|
15
|
+
help="Run name or file path to tail.",
|
|
15
16
|
),
|
|
16
|
-
server: str = typer.Option(
|
|
17
|
-
|
|
17
|
+
server: Optional[str] = typer.Option(
|
|
18
|
+
None,
|
|
18
19
|
"--server",
|
|
19
20
|
"-s",
|
|
20
21
|
help="URL of the logtap server.",
|
|
@@ -24,10 +25,10 @@ def tail(
|
|
|
24
25
|
False,
|
|
25
26
|
"--follow",
|
|
26
27
|
"-f",
|
|
27
|
-
help="Follow log output (like tail -f).
|
|
28
|
+
help="Follow log output (like tail -f).",
|
|
28
29
|
),
|
|
29
30
|
lines: int = typer.Option(
|
|
30
|
-
|
|
31
|
+
50,
|
|
31
32
|
"--lines",
|
|
32
33
|
"-n",
|
|
33
34
|
help="Number of lines to show initially.",
|
|
@@ -39,22 +40,198 @@ def tail(
|
|
|
39
40
|
help="API key for authentication.",
|
|
40
41
|
envvar="LOGTAP_API_KEY",
|
|
41
42
|
),
|
|
43
|
+
mode: str = typer.Option(
|
|
44
|
+
"auto",
|
|
45
|
+
"--mode",
|
|
46
|
+
"-m",
|
|
47
|
+
help="Resolution mode: auto, runs, or files.",
|
|
48
|
+
),
|
|
49
|
+
since: Optional[int] = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--since",
|
|
52
|
+
help="Resume from cursor (exclusive). For runs mode only.",
|
|
53
|
+
),
|
|
54
|
+
tag: Optional[List[str]] = typer.Option(
|
|
55
|
+
None,
|
|
56
|
+
"--tag",
|
|
57
|
+
"-t",
|
|
58
|
+
help="Filter by tag (key=value, repeatable). For runs mode only.",
|
|
59
|
+
),
|
|
60
|
+
output: str = typer.Option(
|
|
61
|
+
"pretty",
|
|
62
|
+
"--output",
|
|
63
|
+
"-o",
|
|
64
|
+
help="Output format: pretty, plain, or jsonl.",
|
|
65
|
+
),
|
|
42
66
|
) -> None:
|
|
43
67
|
"""
|
|
44
|
-
Tail a
|
|
68
|
+
Tail a run or file, optionally following new entries.
|
|
69
|
+
|
|
70
|
+
For ML training logs (runs mode):
|
|
71
|
+
logtap tail run1 -f
|
|
72
|
+
logtap tail run1 --since 5000 -f
|
|
73
|
+
logtap tail run1 --tag node=gpu1
|
|
45
74
|
|
|
46
|
-
|
|
47
|
-
logtap tail syslog
|
|
75
|
+
For static files (legacy mode):
|
|
76
|
+
logtap tail syslog --server http://mybox:8000
|
|
48
77
|
logtap tail auth.log -f
|
|
49
|
-
logtap tail syslog --lines 100
|
|
50
78
|
"""
|
|
51
|
-
import httpx
|
|
52
79
|
|
|
53
|
-
#
|
|
80
|
+
# If no server specified and target looks like a path, use local file mode
|
|
81
|
+
if server is None:
|
|
82
|
+
if "/" in target or "\\" in target or target.startswith("."):
|
|
83
|
+
console.print("[red]Error: Local file tailing requires --server[/red]")
|
|
84
|
+
console.print("[dim]For runs: logtap tail run1 --server http://collector:8000[/dim]")
|
|
85
|
+
console.print(
|
|
86
|
+
"[dim]For files: logtap tail syslog --server http://fileserver:8000[/dim]"
|
|
87
|
+
)
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
# Default to localhost
|
|
90
|
+
server = "http://localhost:8000"
|
|
91
|
+
|
|
54
92
|
headers = {}
|
|
55
93
|
if api_key:
|
|
56
94
|
headers["X-API-Key"] = api_key
|
|
57
95
|
|
|
96
|
+
# Determine mode
|
|
97
|
+
use_runs_mode = False
|
|
98
|
+
if mode == "runs":
|
|
99
|
+
use_runs_mode = True
|
|
100
|
+
elif mode == "files":
|
|
101
|
+
use_runs_mode = False
|
|
102
|
+
else:
|
|
103
|
+
# Auto mode: check server capabilities and run existence
|
|
104
|
+
use_runs_mode = _detect_runs_mode(server, target, headers)
|
|
105
|
+
|
|
106
|
+
if use_runs_mode:
|
|
107
|
+
_tail_run(server, target, headers, follow, lines, since, tag, output)
|
|
108
|
+
else:
|
|
109
|
+
_tail_file(server, target, headers, follow, lines, output)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _detect_runs_mode(server: str, target: str, headers: dict) -> bool:
|
|
113
|
+
"""Detect if server supports runs and target exists as a run."""
|
|
114
|
+
import httpx
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
with httpx.Client(timeout=5) as client:
|
|
118
|
+
# Check health endpoint for capabilities
|
|
119
|
+
response = client.get(f"{server}/health", headers=headers)
|
|
120
|
+
if response.status_code == 200:
|
|
121
|
+
data = response.json()
|
|
122
|
+
features = data.get("features", [])
|
|
123
|
+
if "runs" in features:
|
|
124
|
+
# Check if run exists
|
|
125
|
+
run_response = client.get(f"{server}/runs/{target}", headers=headers)
|
|
126
|
+
if run_response.status_code == 200:
|
|
127
|
+
return True
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _tail_run(
|
|
135
|
+
server: str,
|
|
136
|
+
run_id: str,
|
|
137
|
+
headers: dict,
|
|
138
|
+
follow: bool,
|
|
139
|
+
lines: int,
|
|
140
|
+
since: Optional[int],
|
|
141
|
+
tags: Optional[List[str]],
|
|
142
|
+
output: str,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Tail a run using SSE."""
|
|
145
|
+
import httpx
|
|
146
|
+
|
|
147
|
+
# Build URL with query params
|
|
148
|
+
params = {"tail": lines, "follow": str(follow).lower()}
|
|
149
|
+
if since is not None:
|
|
150
|
+
params["since"] = since
|
|
151
|
+
if tags:
|
|
152
|
+
params["tag"] = tags
|
|
153
|
+
|
|
154
|
+
url = f"{server}/runs/{run_id}/stream"
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
with httpx.Client(timeout=None) as client:
|
|
158
|
+
with client.stream("GET", url, headers=headers, params=params) as response:
|
|
159
|
+
if response.status_code == 401:
|
|
160
|
+
console.print("[red]Error: Unauthorized. Check your API key.[/red]")
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
elif response.status_code == 404:
|
|
163
|
+
console.print(f"[red]Error: Run '{run_id}' not found.[/red]")
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
elif response.status_code >= 400:
|
|
166
|
+
console.print(f"[red]Error: Server returned {response.status_code}[/red]")
|
|
167
|
+
raise typer.Exit(1)
|
|
168
|
+
|
|
169
|
+
# Track cursor for gap detection message
|
|
170
|
+
last_cursor = since
|
|
171
|
+
|
|
172
|
+
# Parse SSE stream
|
|
173
|
+
buffer = ""
|
|
174
|
+
for chunk in response.iter_text():
|
|
175
|
+
buffer += chunk
|
|
176
|
+
while "\n\n" in buffer:
|
|
177
|
+
event_str, buffer = buffer.split("\n\n", 1)
|
|
178
|
+
_process_sse_event(event_str, output, last_cursor)
|
|
179
|
+
|
|
180
|
+
except httpx.ConnectError:
|
|
181
|
+
console.print(f"[red]Error: Could not connect to {server}[/red]")
|
|
182
|
+
console.print("[dim]Is the collector running? Start with: logtap collect[/dim]")
|
|
183
|
+
raise typer.Exit(1)
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
console.print("\n[dim]Stopped.[/dim]")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _process_sse_event(event_str: str, output: str, last_cursor: Optional[int]) -> None:
|
|
189
|
+
"""Process a single SSE event."""
|
|
190
|
+
event_type = None
|
|
191
|
+
data = None
|
|
192
|
+
|
|
193
|
+
for line in event_str.split("\n"):
|
|
194
|
+
if line.startswith(":"):
|
|
195
|
+
# Comment (heartbeat)
|
|
196
|
+
continue
|
|
197
|
+
elif line.startswith("event: "):
|
|
198
|
+
event_type = line[7:]
|
|
199
|
+
elif line.startswith("data: "):
|
|
200
|
+
data = line[6:]
|
|
201
|
+
# Note: id: lines are parsed by SSE but not used for now
|
|
202
|
+
|
|
203
|
+
if event_type == "meta" and data:
|
|
204
|
+
try:
|
|
205
|
+
meta = json.loads(data)
|
|
206
|
+
if meta.get("gap") and meta.get("missed"):
|
|
207
|
+
console.print(f"[yellow]reconnected (missed {meta['missed']} lines)[/yellow]")
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
elif event_type == "line" and data:
|
|
211
|
+
try:
|
|
212
|
+
line_data = json.loads(data)
|
|
213
|
+
if output == "jsonl":
|
|
214
|
+
console.print(data)
|
|
215
|
+
elif output == "plain":
|
|
216
|
+
console.print(line_data["line"])
|
|
217
|
+
else:
|
|
218
|
+
# Pretty output - just the line
|
|
219
|
+
console.print(line_data["line"])
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _tail_file(
|
|
225
|
+
server: str,
|
|
226
|
+
filename: str,
|
|
227
|
+
headers: dict,
|
|
228
|
+
follow: bool,
|
|
229
|
+
lines: int,
|
|
230
|
+
output: str,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Tail a static file (legacy mode)."""
|
|
233
|
+
import httpx
|
|
234
|
+
|
|
58
235
|
params = {
|
|
59
236
|
"filename": filename,
|
|
60
237
|
"limit": lines,
|
|
@@ -64,9 +241,15 @@ def tail(
|
|
|
64
241
|
with httpx.Client(timeout=30.0) as client:
|
|
65
242
|
response = client.get(f"{server}/logs", params=params, headers=headers)
|
|
66
243
|
|
|
67
|
-
if response.status_code
|
|
68
|
-
|
|
69
|
-
|
|
244
|
+
if response.status_code == 401:
|
|
245
|
+
console.print("[red]Error: Unauthorized. Check your API key.[/red]")
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
elif response.status_code != 200:
|
|
248
|
+
try:
|
|
249
|
+
error_detail = response.json().get("detail", response.text)
|
|
250
|
+
except Exception:
|
|
251
|
+
error_detail = response.text
|
|
252
|
+
console.print(f"[red]Error:[/red] {error_detail}")
|
|
70
253
|
raise typer.Exit(1)
|
|
71
254
|
|
|
72
255
|
data = response.json()
|
|
@@ -74,7 +257,10 @@ def tail(
|
|
|
74
257
|
|
|
75
258
|
# Print initial lines
|
|
76
259
|
for line in log_lines:
|
|
77
|
-
|
|
260
|
+
if output == "jsonl":
|
|
261
|
+
console.print(json.dumps({"line": line}))
|
|
262
|
+
else:
|
|
263
|
+
console.print(line)
|
|
78
264
|
|
|
79
265
|
if follow:
|
|
80
266
|
console.print()
|
|
@@ -91,13 +277,16 @@ def tail(
|
|
|
91
277
|
ws_url = f"{ws_url}/logs/stream?filename={filename}"
|
|
92
278
|
|
|
93
279
|
extra_headers = {}
|
|
94
|
-
if
|
|
95
|
-
extra_headers["X-API-Key"] =
|
|
280
|
+
if headers.get("X-API-Key"):
|
|
281
|
+
extra_headers["X-API-Key"] = headers["X-API-Key"]
|
|
96
282
|
|
|
97
283
|
try:
|
|
98
284
|
async with websockets.connect(ws_url, extra_headers=extra_headers) as ws:
|
|
99
285
|
async for message in ws:
|
|
100
|
-
|
|
286
|
+
if output == "jsonl":
|
|
287
|
+
console.print(json.dumps({"line": message}))
|
|
288
|
+
else:
|
|
289
|
+
console.print(message)
|
|
101
290
|
except websockets.exceptions.InvalidStatusCode as e:
|
|
102
291
|
if e.status_code == 404:
|
|
103
292
|
console.print("[yellow]Streaming not available.[/yellow]")
|
|
@@ -113,9 +302,9 @@ def tail(
|
|
|
113
302
|
console.print("\n[dim]Stopped.[/dim]")
|
|
114
303
|
|
|
115
304
|
except httpx.ConnectError:
|
|
116
|
-
console.print(f"[
|
|
305
|
+
console.print(f"[red]Error:[/red] Could not connect to {server}")
|
|
117
306
|
console.print("[dim]Is the logtap server running? Start it with 'logtap serve'[/dim]")
|
|
118
307
|
raise typer.Exit(1)
|
|
119
308
|
except Exception as e:
|
|
120
|
-
console.print(f"[
|
|
309
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
121
310
|
raise typer.Exit(1)
|
logtap/cli/main.py
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import typer
|
|
4
4
|
|
|
5
5
|
from logtap import __version__
|
|
6
|
-
from logtap.cli.commands import files, query, serve, tail
|
|
6
|
+
from logtap.cli.commands import collect, files, ingest, query, runs, serve, tail
|
|
7
7
|
|
|
8
8
|
app = typer.Typer(
|
|
9
9
|
name="logtap",
|
|
10
|
-
help="
|
|
10
|
+
help="tail -f for GPU clouds. Survives disconnects, aggregates multi-node.",
|
|
11
11
|
rich_markup_mode="rich",
|
|
12
12
|
no_args_is_help=True,
|
|
13
13
|
)
|
|
@@ -32,14 +32,20 @@ def main(
|
|
|
32
32
|
),
|
|
33
33
|
) -> None:
|
|
34
34
|
"""
|
|
35
|
-
logtap -
|
|
35
|
+
logtap - tail -f for GPU clouds.
|
|
36
36
|
|
|
37
|
-
Start a
|
|
37
|
+
Start a collector with 'logtap collect', pipe logs with 'logtap ingest',
|
|
38
|
+
and tail from anywhere with 'logtap tail'.
|
|
38
39
|
"""
|
|
39
40
|
pass
|
|
40
41
|
|
|
41
42
|
|
|
42
|
-
#
|
|
43
|
+
# Primary commands (new ML workflow)
|
|
44
|
+
app.command()(collect.collect)
|
|
45
|
+
app.command()(ingest.ingest)
|
|
46
|
+
app.command()(runs.runs)
|
|
47
|
+
|
|
48
|
+
# Legacy commands (file-based workflow)
|
|
43
49
|
app.command()(serve.serve)
|
|
44
50
|
app.command()(query.query)
|
|
45
51
|
app.command()(tail.tail)
|
logtap/core/parsers/base.py
CHANGED
|
@@ -136,12 +136,14 @@ class LogParser(ABC):
|
|
|
136
136
|
"""
|
|
137
137
|
return [self.parse(line) for line in lines]
|
|
138
138
|
|
|
139
|
-
def _detect_level_from_content(self, content:
|
|
139
|
+
def _detect_level_from_content(self, content: Any) -> Optional[LogLevel]:
|
|
140
140
|
"""
|
|
141
141
|
Detect log level from message content.
|
|
142
142
|
|
|
143
143
|
Common patterns like "ERROR:", "[error]", etc.
|
|
144
144
|
"""
|
|
145
|
+
if not isinstance(content, str):
|
|
146
|
+
return None
|
|
145
147
|
content_lower = content.lower()
|
|
146
148
|
|
|
147
149
|
# Check for explicit level indicators
|
|
@@ -50,8 +50,19 @@ class JsonLogParser(LogParser):
|
|
|
50
50
|
level=self._detect_level_from_content(line),
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
+
# Only handle JSON objects (dicts), not arrays or primitives
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
return ParsedLogEntry(
|
|
56
|
+
raw=line,
|
|
57
|
+
message=line,
|
|
58
|
+
level=self._detect_level_from_content(line),
|
|
59
|
+
)
|
|
60
|
+
|
|
53
61
|
# Extract message
|
|
54
62
|
message = self._get_field(data, self.MESSAGE_FIELDS, line)
|
|
63
|
+
# Ensure message is a string for level detection
|
|
64
|
+
if not isinstance(message, str):
|
|
65
|
+
message = str(message) if message is not None else line
|
|
55
66
|
|
|
56
67
|
# Extract level
|
|
57
68
|
level_str = self._get_field(data, self.LEVEL_FIELDS)
|
logtap/core/reader.py
CHANGED
|
@@ -27,7 +27,7 @@ def tail(filename: str, lines_limit: int = 50, block_size: int = 1024) -> List[s
|
|
|
27
27
|
A list of the last 'lines_limit' lines in the file.
|
|
28
28
|
"""
|
|
29
29
|
lines: List[str] = []
|
|
30
|
-
with open(filename, "r", encoding="utf-8") as f:
|
|
30
|
+
with open(filename, "r", encoding="utf-8", errors="replace") as f:
|
|
31
31
|
# Seek to the end of the file.
|
|
32
32
|
f.seek(0, SEEK_END)
|
|
33
33
|
# Get the current position in the file.
|
|
@@ -70,9 +70,7 @@ def read_block(file: IO, block_end_byte: int, block_size: int) -> Tuple[List[str
|
|
|
70
70
|
return lines, block_end_byte
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
async def tail_async(
|
|
74
|
-
filename: str, lines_limit: int = 50, block_size: int = 1024
|
|
75
|
-
) -> List[str]:
|
|
73
|
+
async def tail_async(filename: str, lines_limit: int = 50, block_size: int = 1024) -> List[str]:
|
|
76
74
|
"""
|
|
77
75
|
Async version of tail() for use with FastAPI.
|
|
78
76
|
|
|
@@ -87,7 +85,7 @@ async def tail_async(
|
|
|
87
85
|
A list of the last 'lines_limit' lines in the file.
|
|
88
86
|
"""
|
|
89
87
|
lines: List[str] = []
|
|
90
|
-
async with aiofiles.open(filename, "r", encoding="utf-8") as f:
|
|
88
|
+
async with aiofiles.open(filename, "r", encoding="utf-8", errors="replace") as f:
|
|
91
89
|
# Seek to the end of the file.
|
|
92
90
|
await f.seek(0, SEEK_END)
|
|
93
91
|
# Get the current position in the file.
|