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.
@@ -1,6 +1,7 @@
1
1
  """Tail command for logtap CLI - real-time log streaming."""
2
2
 
3
- from typing import Optional
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
- filename: str = typer.Argument(
13
- "syslog",
14
- help="Name of the log file to tail.",
13
+ target: str = typer.Argument(
14
+ ...,
15
+ help="Run name or file path to tail.",
15
16
  ),
16
- server: str = typer.Option(
17
- "http://localhost:8000",
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). Requires WebSocket support.",
28
+ help="Follow log output (like tail -f).",
28
29
  ),
29
30
  lines: int = typer.Option(
30
- 10,
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 log file, optionally following new entries.
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
- Example:
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
- # First, get initial lines
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 != 200:
68
- error_detail = response.json().get("detail", response.text)
69
- console.print(f"[bold red]Error:[/bold red] {error_detail}")
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
- console.print(line)
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 api_key:
95
- extra_headers["X-API-Key"] = 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
- console.print(message)
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"[bold red]Error:[/bold red] Could not connect to {server}")
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"[bold red]Error:[/bold red] {e}")
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="A CLI-first log access tool for Unix systems. Remote log file access without SSH.",
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 - Remote log file access without SSH.
35
+ logtap - tail -f for GPU clouds.
36
36
 
37
- Start a server with 'logtap serve' or query a remote server with 'logtap query'.
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
- # Add commands
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)