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.
- logtap/__init__.py +1 -1
- logtap/api/app.py +69 -3
- logtap/api/routes/health.py +26 -4
- logtap/api/routes/logs.py +26 -31
- logtap/api/routes/parsed.py +8 -7
- logtap/api/routes/runs.py +330 -0
- logtap/cli/commands/collect.py +107 -0
- logtap/cli/commands/doctor.py +127 -0
- logtap/cli/commands/ingest.py +123 -0
- logtap/cli/commands/runs.py +116 -0
- logtap/cli/commands/tail.py +220 -23
- logtap/cli/main.py +12 -5
- logtap/core/runs.py +433 -0
- logtap/core/validation.py +132 -0
- logtap/models/responses.py +54 -1
- logtap-0.4.1.dist-info/METADATA +304 -0
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/RECORD +20 -14
- logtap-0.3.0.dist-info/METADATA +0 -319
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/WHEEL +0 -0
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/entry_points.txt +0 -0
- {logtap-0.3.0.dist-info → logtap-0.4.1.dist-info}/licenses/LICENSE +0 -0
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,206 @@ 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 if this is a reconnect (user provided --since)
|
|
170
|
+
is_reconnect = since is not None
|
|
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, is_reconnect)
|
|
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, is_reconnect: bool) -> 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
|
+
# Gap detected - warn user about missed lines
|
|
208
|
+
console.print(
|
|
209
|
+
f"[yellow]reconnected (missed {meta['missed']} lines)[/yellow]",
|
|
210
|
+
stderr=True,
|
|
211
|
+
)
|
|
212
|
+
elif is_reconnect:
|
|
213
|
+
# Clean reconnect - show cursor position
|
|
214
|
+
cursor = meta.get("cursor_earliest", meta.get("cursor_latest", "?"))
|
|
215
|
+
console.print(f"[dim]resumed at cursor {cursor}[/dim]", stderr=True)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
elif event_type == "line" and data:
|
|
219
|
+
try:
|
|
220
|
+
line_data = json.loads(data)
|
|
221
|
+
if output == "jsonl":
|
|
222
|
+
console.print(data)
|
|
223
|
+
elif output == "plain":
|
|
224
|
+
console.print(line_data["line"])
|
|
225
|
+
else:
|
|
226
|
+
# Pretty output - just the line
|
|
227
|
+
console.print(line_data["line"])
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _tail_file(
|
|
233
|
+
server: str,
|
|
234
|
+
filename: str,
|
|
235
|
+
headers: dict,
|
|
236
|
+
follow: bool,
|
|
237
|
+
lines: int,
|
|
238
|
+
output: str,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Tail a static file (legacy mode)."""
|
|
241
|
+
import httpx
|
|
242
|
+
|
|
58
243
|
params = {
|
|
59
244
|
"filename": filename,
|
|
60
245
|
"limit": lines,
|
|
@@ -64,9 +249,15 @@ def tail(
|
|
|
64
249
|
with httpx.Client(timeout=30.0) as client:
|
|
65
250
|
response = client.get(f"{server}/logs", params=params, headers=headers)
|
|
66
251
|
|
|
67
|
-
if response.status_code
|
|
68
|
-
|
|
69
|
-
|
|
252
|
+
if response.status_code == 401:
|
|
253
|
+
console.print("[red]Error: Unauthorized. Check your API key.[/red]")
|
|
254
|
+
raise typer.Exit(1)
|
|
255
|
+
elif response.status_code != 200:
|
|
256
|
+
try:
|
|
257
|
+
error_detail = response.json().get("detail", response.text)
|
|
258
|
+
except Exception:
|
|
259
|
+
error_detail = response.text
|
|
260
|
+
console.print(f"[red]Error:[/red] {error_detail}")
|
|
70
261
|
raise typer.Exit(1)
|
|
71
262
|
|
|
72
263
|
data = response.json()
|
|
@@ -74,7 +265,10 @@ def tail(
|
|
|
74
265
|
|
|
75
266
|
# Print initial lines
|
|
76
267
|
for line in log_lines:
|
|
77
|
-
|
|
268
|
+
if output == "jsonl":
|
|
269
|
+
console.print(json.dumps({"line": line}))
|
|
270
|
+
else:
|
|
271
|
+
console.print(line)
|
|
78
272
|
|
|
79
273
|
if follow:
|
|
80
274
|
console.print()
|
|
@@ -91,13 +285,16 @@ def tail(
|
|
|
91
285
|
ws_url = f"{ws_url}/logs/stream?filename={filename}"
|
|
92
286
|
|
|
93
287
|
extra_headers = {}
|
|
94
|
-
if
|
|
95
|
-
extra_headers["X-API-Key"] =
|
|
288
|
+
if headers.get("X-API-Key"):
|
|
289
|
+
extra_headers["X-API-Key"] = headers["X-API-Key"]
|
|
96
290
|
|
|
97
291
|
try:
|
|
98
292
|
async with websockets.connect(ws_url, extra_headers=extra_headers) as ws:
|
|
99
293
|
async for message in ws:
|
|
100
|
-
|
|
294
|
+
if output == "jsonl":
|
|
295
|
+
console.print(json.dumps({"line": message}))
|
|
296
|
+
else:
|
|
297
|
+
console.print(message)
|
|
101
298
|
except websockets.exceptions.InvalidStatusCode as e:
|
|
102
299
|
if e.status_code == 404:
|
|
103
300
|
console.print("[yellow]Streaming not available.[/yellow]")
|
|
@@ -113,9 +310,9 @@ def tail(
|
|
|
113
310
|
console.print("\n[dim]Stopped.[/dim]")
|
|
114
311
|
|
|
115
312
|
except httpx.ConnectError:
|
|
116
|
-
console.print(f"[
|
|
313
|
+
console.print(f"[red]Error:[/red] Could not connect to {server}")
|
|
117
314
|
console.print("[dim]Is the logtap server running? Start it with 'logtap serve'[/dim]")
|
|
118
315
|
raise typer.Exit(1)
|
|
119
316
|
except Exception as e:
|
|
120
|
-
console.print(f"[
|
|
317
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
121
318
|
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, doctor, 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,21 @@ 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
|
+
app.command()(doctor.doctor)
|
|
48
|
+
|
|
49
|
+
# Legacy commands (file-based workflow)
|
|
43
50
|
app.command()(serve.serve)
|
|
44
51
|
app.command()(query.query)
|
|
45
52
|
app.command()(tail.tail)
|