logtap 0.4.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/api/routes/logs.py +26 -31
- logtap/api/routes/parsed.py +8 -7
- logtap/api/routes/runs.py +9 -30
- logtap/cli/commands/doctor.py +127 -0
- logtap/cli/commands/tail.py +13 -5
- logtap/cli/main.py +2 -1
- logtap/core/runs.py +71 -31
- logtap/core/validation.py +132 -0
- logtap-0.4.1.dist-info/METADATA +304 -0
- {logtap-0.4.0.dist-info → logtap-0.4.1.dist-info}/RECORD +13 -12
- logtap-0.4.0.dist-info/METADATA +0 -319
- {logtap-0.4.0.dist-info → logtap-0.4.1.dist-info}/WHEEL +0 -0
- {logtap-0.4.0.dist-info → logtap-0.4.1.dist-info}/entry_points.txt +0 -0
- {logtap-0.4.0.dist-info → logtap-0.4.1.dist-info}/licenses/LICENSE +0 -0
logtap/api/routes/logs.py
CHANGED
|
@@ -11,42 +11,39 @@ from starlette.responses import StreamingResponse
|
|
|
11
11
|
from logtap.api.dependencies import get_settings, verify_api_key
|
|
12
12
|
from logtap.core.reader import tail_async
|
|
13
13
|
from logtap.core.search import filter_lines
|
|
14
|
-
from logtap.core.validation import
|
|
14
|
+
from logtap.core.validation import (
|
|
15
|
+
is_limit_valid,
|
|
16
|
+
is_search_term_valid,
|
|
17
|
+
resolve_safe_path,
|
|
18
|
+
)
|
|
15
19
|
from logtap.models.config import Settings
|
|
16
20
|
from logtap.models.responses import LogResponse
|
|
17
21
|
|
|
18
22
|
router = APIRouter()
|
|
19
23
|
|
|
20
24
|
|
|
21
|
-
# Error messages
|
|
22
|
-
ERROR_INVALID_FILENAME =
|
|
25
|
+
# Error messages
|
|
26
|
+
ERROR_INVALID_FILENAME = "Invalid filename"
|
|
23
27
|
ERROR_LONG_SEARCH_TERM = "Search term is too long: must be 100 characters or fewer"
|
|
24
28
|
ERROR_INVALID_LIMIT = "Invalid limit value: must be between 1 and 1000"
|
|
25
29
|
|
|
26
30
|
|
|
27
|
-
def
|
|
28
|
-
"""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# Block any filename with path separators
|
|
35
|
-
if "/" in filename or "\\" in filename:
|
|
31
|
+
def get_filepath(filename: str, settings: Settings) -> str:
|
|
32
|
+
"""Get full filepath and validate it exists and is within allowed directory."""
|
|
33
|
+
log_dir = settings.get_log_directory()
|
|
34
|
+
# resolve_safe_path handles: NUL bytes, control chars, path traversal,
|
|
35
|
+
# separators, absolute paths, symlink escape, and containment check
|
|
36
|
+
filepath = resolve_safe_path(log_dir, filename)
|
|
37
|
+
if filepath is None:
|
|
36
38
|
raise HTTPException(
|
|
37
39
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
38
40
|
detail=ERROR_INVALID_FILENAME,
|
|
39
41
|
)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def get_filepath(filename: str, settings: Settings) -> str:
|
|
43
|
-
"""Get full filepath and validate it exists."""
|
|
44
|
-
log_dir = settings.get_log_directory()
|
|
45
|
-
filepath = os.path.join(log_dir, filename)
|
|
42
|
+
# Separate existence check for correct 404 response
|
|
46
43
|
if not os.path.isfile(filepath):
|
|
47
44
|
raise HTTPException(
|
|
48
45
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
49
|
-
detail=f"File not found: {
|
|
46
|
+
detail=f"File not found: {filename} does not exist",
|
|
50
47
|
)
|
|
51
48
|
return filepath
|
|
52
49
|
|
|
@@ -67,8 +64,6 @@ async def get_logs(
|
|
|
67
64
|
This endpoint reads the last N lines from a log file and optionally
|
|
68
65
|
filters them by a search term or regex pattern.
|
|
69
66
|
"""
|
|
70
|
-
validate_filename(filename)
|
|
71
|
-
|
|
72
67
|
if term and not is_search_term_valid(term):
|
|
73
68
|
raise HTTPException(
|
|
74
69
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
@@ -126,8 +121,11 @@ async def get_logs_multi(
|
|
|
126
121
|
|
|
127
122
|
for filename in file_list:
|
|
128
123
|
try:
|
|
129
|
-
|
|
130
|
-
|
|
124
|
+
filepath = resolve_safe_path(log_dir, filename)
|
|
125
|
+
|
|
126
|
+
if filepath is None:
|
|
127
|
+
results[filename] = {"error": ERROR_INVALID_FILENAME, "lines": []}
|
|
128
|
+
continue
|
|
131
129
|
|
|
132
130
|
if not os.path.isfile(filepath):
|
|
133
131
|
results[filename] = {"error": "File not found", "lines": []}
|
|
@@ -164,16 +162,14 @@ async def stream_logs(
|
|
|
164
162
|
# Get settings (can't use Depends in WebSocket easily)
|
|
165
163
|
settings = get_settings()
|
|
166
164
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
165
|
+
log_dir = settings.get_log_directory()
|
|
166
|
+
filepath = resolve_safe_path(log_dir, filename)
|
|
167
|
+
|
|
168
|
+
if filepath is None:
|
|
169
|
+
await websocket.send_json({"error": ERROR_INVALID_FILENAME})
|
|
171
170
|
await websocket.close()
|
|
172
171
|
return
|
|
173
172
|
|
|
174
|
-
log_dir = settings.get_log_directory()
|
|
175
|
-
filepath = os.path.join(log_dir, filename)
|
|
176
|
-
|
|
177
173
|
if not os.path.isfile(filepath):
|
|
178
174
|
await websocket.send_json({"error": f"File not found: {filename}"})
|
|
179
175
|
await websocket.close()
|
|
@@ -221,7 +217,6 @@ async def stream_logs_sse(
|
|
|
221
217
|
|
|
222
218
|
Alternative to WebSocket for simpler clients.
|
|
223
219
|
"""
|
|
224
|
-
validate_filename(filename)
|
|
225
220
|
filepath = get_filepath(filename, settings)
|
|
226
221
|
|
|
227
222
|
async def event_generator():
|
logtap/api/routes/parsed.py
CHANGED
|
@@ -9,6 +9,7 @@ from logtap.api.dependencies import get_settings, verify_api_key
|
|
|
9
9
|
from logtap.core.parsers import AutoParser, LogLevel
|
|
10
10
|
from logtap.core.reader import tail_async
|
|
11
11
|
from logtap.core.search import filter_entries
|
|
12
|
+
from logtap.core.validation import resolve_safe_path
|
|
12
13
|
from logtap.models.config import Settings
|
|
13
14
|
|
|
14
15
|
router = APIRouter()
|
|
@@ -40,21 +41,21 @@ async def get_parsed_logs(
|
|
|
40
41
|
|
|
41
42
|
Supported formats: syslog, JSON, nginx, apache (auto-detected).
|
|
42
43
|
"""
|
|
43
|
-
# Validate filename
|
|
44
|
-
|
|
44
|
+
# Validate filename and resolve safe path
|
|
45
|
+
log_dir = settings.get_log_directory()
|
|
46
|
+
# resolve_safe_path handles all path security: traversal, symlinks, containment
|
|
47
|
+
filepath = resolve_safe_path(log_dir, filename)
|
|
48
|
+
|
|
49
|
+
if filepath is None:
|
|
45
50
|
raise HTTPException(
|
|
46
51
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
47
52
|
detail='Invalid filename: must not contain ".." or start with "/"',
|
|
48
53
|
)
|
|
49
54
|
|
|
50
|
-
# Build file path
|
|
51
|
-
log_dir = settings.get_log_directory()
|
|
52
|
-
filepath = os.path.join(log_dir, filename)
|
|
53
|
-
|
|
54
55
|
if not os.path.isfile(filepath):
|
|
55
56
|
raise HTTPException(
|
|
56
57
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
57
|
-
detail=f"File not found: {
|
|
58
|
+
detail=f"File not found: {filename} does not exist",
|
|
58
59
|
)
|
|
59
60
|
|
|
60
61
|
# Read file lines
|
logtap/api/routes/runs.py
CHANGED
|
@@ -128,14 +128,14 @@ async def ingest(
|
|
|
128
128
|
# Get or create run
|
|
129
129
|
run, created = store.get_or_create(run_id)
|
|
130
130
|
|
|
131
|
-
# Handle tags
|
|
131
|
+
# Handle tags (validate and track in metadata)
|
|
132
132
|
tags = parse_tags(x_logtap_tag)
|
|
133
133
|
if tags:
|
|
134
134
|
err = run.set_tags(tags)
|
|
135
135
|
if err:
|
|
136
136
|
raise HTTPException(
|
|
137
|
-
status_code=
|
|
138
|
-
detail={"error": "
|
|
137
|
+
status_code=400,
|
|
138
|
+
detail={"error": "invalid_tag", "message": err},
|
|
139
139
|
)
|
|
140
140
|
|
|
141
141
|
# Read and ingest body
|
|
@@ -149,12 +149,12 @@ async def ingest(
|
|
|
149
149
|
# Process complete lines
|
|
150
150
|
while "\n" in buffer:
|
|
151
151
|
line, buffer = buffer.split("\n", 1)
|
|
152
|
-
run.append(line)
|
|
152
|
+
run.append(line, tags) # Pass tags to each line
|
|
153
153
|
lines_ingested += 1
|
|
154
154
|
|
|
155
155
|
# Flush remaining partial line
|
|
156
156
|
if buffer:
|
|
157
|
-
run.append(buffer)
|
|
157
|
+
run.append(buffer, tags) # Pass tags to each line
|
|
158
158
|
lines_ingested += 1
|
|
159
159
|
|
|
160
160
|
# Save metadata
|
|
@@ -192,33 +192,12 @@ async def stream_run(
|
|
|
192
192
|
detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
|
|
193
193
|
)
|
|
194
194
|
|
|
195
|
-
#
|
|
196
|
-
if tag
|
|
197
|
-
required_tags = parse_tags(tag)
|
|
198
|
-
for key, value in required_tags.items():
|
|
199
|
-
if run.metadata.tags.get(key) != value:
|
|
200
|
-
# Run doesn't match filter - return empty stream
|
|
201
|
-
async def empty_stream():
|
|
202
|
-
meta = StreamMetaEvent(
|
|
203
|
-
cursor_earliest=run.cursor_earliest,
|
|
204
|
-
cursor_latest=run.cursor_latest,
|
|
205
|
-
gap=False,
|
|
206
|
-
)
|
|
207
|
-
yield f"event: meta\ndata: {meta.model_dump_json()}\n\n"
|
|
208
|
-
|
|
209
|
-
return StreamingResponse(
|
|
210
|
-
empty_stream(),
|
|
211
|
-
media_type="text/event-stream",
|
|
212
|
-
headers={
|
|
213
|
-
"Cache-Control": "no-cache",
|
|
214
|
-
"X-Logtap-Earliest-Cursor": str(run.cursor_earliest),
|
|
215
|
-
"X-Logtap-Latest-Cursor": str(run.cursor_latest),
|
|
216
|
-
},
|
|
217
|
-
)
|
|
195
|
+
# Parse tag filter for per-line filtering
|
|
196
|
+
tag_filter = parse_tags(tag) if tag else None
|
|
218
197
|
|
|
219
198
|
async def generate_sse():
|
|
220
199
|
# Get initial lines and check for gap
|
|
221
|
-
lines, gap = run.get_lines(since=since, tail=tail)
|
|
200
|
+
lines, gap = run.get_lines(since=since, tail=tail, tag_filter=tag_filter)
|
|
222
201
|
|
|
223
202
|
# Send meta event
|
|
224
203
|
missed = None
|
|
@@ -255,7 +234,7 @@ async def stream_run(
|
|
|
255
234
|
|
|
256
235
|
while True:
|
|
257
236
|
# Get new lines since last cursor
|
|
258
|
-
new_lines, _ = run.get_lines(since=last_cursor, limit=100)
|
|
237
|
+
new_lines, _ = run.get_lines(since=last_cursor, limit=100, tag_filter=tag_filter)
|
|
259
238
|
|
|
260
239
|
for line in new_lines:
|
|
261
240
|
event = StreamLineEvent(
|
|
@@ -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")
|
logtap/cli/commands/tail.py
CHANGED
|
@@ -166,8 +166,8 @@ def _tail_run(
|
|
|
166
166
|
console.print(f"[red]Error: Server returned {response.status_code}[/red]")
|
|
167
167
|
raise typer.Exit(1)
|
|
168
168
|
|
|
169
|
-
# Track
|
|
170
|
-
|
|
169
|
+
# Track if this is a reconnect (user provided --since)
|
|
170
|
+
is_reconnect = since is not None
|
|
171
171
|
|
|
172
172
|
# Parse SSE stream
|
|
173
173
|
buffer = ""
|
|
@@ -175,7 +175,7 @@ def _tail_run(
|
|
|
175
175
|
buffer += chunk
|
|
176
176
|
while "\n\n" in buffer:
|
|
177
177
|
event_str, buffer = buffer.split("\n\n", 1)
|
|
178
|
-
_process_sse_event(event_str, output,
|
|
178
|
+
_process_sse_event(event_str, output, is_reconnect)
|
|
179
179
|
|
|
180
180
|
except httpx.ConnectError:
|
|
181
181
|
console.print(f"[red]Error: Could not connect to {server}[/red]")
|
|
@@ -185,7 +185,7 @@ def _tail_run(
|
|
|
185
185
|
console.print("\n[dim]Stopped.[/dim]")
|
|
186
186
|
|
|
187
187
|
|
|
188
|
-
def _process_sse_event(event_str: str, output: str,
|
|
188
|
+
def _process_sse_event(event_str: str, output: str, is_reconnect: bool) -> None:
|
|
189
189
|
"""Process a single SSE event."""
|
|
190
190
|
event_type = None
|
|
191
191
|
data = None
|
|
@@ -204,7 +204,15 @@ def _process_sse_event(event_str: str, output: str, last_cursor: Optional[int])
|
|
|
204
204
|
try:
|
|
205
205
|
meta = json.loads(data)
|
|
206
206
|
if meta.get("gap") and meta.get("missed"):
|
|
207
|
-
|
|
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)
|
|
208
216
|
except Exception:
|
|
209
217
|
pass
|
|
210
218
|
elif event_type == "line" and data:
|
logtap/cli/main.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import typer
|
|
4
4
|
|
|
5
5
|
from logtap import __version__
|
|
6
|
-
from logtap.cli.commands import collect, files, ingest, query, runs, 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",
|
|
@@ -44,6 +44,7 @@ def main(
|
|
|
44
44
|
app.command()(collect.collect)
|
|
45
45
|
app.command()(ingest.ingest)
|
|
46
46
|
app.command()(runs.runs)
|
|
47
|
+
app.command()(doctor.doctor)
|
|
47
48
|
|
|
48
49
|
# Legacy commands (file-based workflow)
|
|
49
50
|
app.command()(serve.serve)
|
logtap/core/runs.py
CHANGED
|
@@ -17,11 +17,12 @@ TAG_VALUE_MAX_LEN = 256
|
|
|
17
17
|
|
|
18
18
|
@dataclass
|
|
19
19
|
class RunLine:
|
|
20
|
-
"""A single log line with cursor and
|
|
20
|
+
"""A single log line with cursor, timestamp, and optional tags."""
|
|
21
21
|
|
|
22
22
|
cursor: int
|
|
23
23
|
line: str
|
|
24
24
|
ts: datetime
|
|
25
|
+
tags: Dict[str, str] = field(default_factory=dict)
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
@dataclass
|
|
@@ -100,32 +101,52 @@ class Run:
|
|
|
100
101
|
|
|
101
102
|
def _populate_cache_from_disk(self) -> None:
|
|
102
103
|
"""Load last N lines from disk into cache."""
|
|
104
|
+
import json
|
|
105
|
+
|
|
103
106
|
if not self.log_file.exists():
|
|
104
107
|
return
|
|
105
108
|
|
|
106
|
-
|
|
109
|
+
run_lines: List[RunLine] = []
|
|
107
110
|
with open(self.log_file, "r", encoding="utf-8", errors="replace") as f:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
for raw_line in f:
|
|
112
|
+
raw_line = raw_line.rstrip("\n")
|
|
113
|
+
if not raw_line:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Try JSONL format first
|
|
117
|
+
if raw_line.startswith("{"):
|
|
118
|
+
try:
|
|
119
|
+
record = json.loads(raw_line)
|
|
120
|
+
run_lines.append(
|
|
121
|
+
RunLine(
|
|
122
|
+
cursor=record["c"],
|
|
123
|
+
line=record["l"],
|
|
124
|
+
ts=datetime.fromisoformat(record["t"]),
|
|
125
|
+
tags=record.get("g", {}),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
continue
|
|
129
|
+
except (json.JSONDecodeError, KeyError):
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
# Legacy plain text format
|
|
133
|
+
run_lines.append(
|
|
134
|
+
RunLine(
|
|
135
|
+
cursor=len(run_lines),
|
|
136
|
+
line=raw_line,
|
|
137
|
+
ts=self.metadata.last_activity,
|
|
138
|
+
tags={},
|
|
139
|
+
)
|
|
140
|
+
)
|
|
111
141
|
|
|
112
142
|
# Only keep last buffer_lines
|
|
113
|
-
if len(
|
|
114
|
-
|
|
115
|
-
start_cursor = self.metadata.cursor_latest - len(lines) + 1
|
|
116
|
-
else:
|
|
117
|
-
start_cursor = 0
|
|
143
|
+
if len(run_lines) > self.buffer_lines:
|
|
144
|
+
run_lines = run_lines[-self.buffer_lines :]
|
|
118
145
|
|
|
119
|
-
self._cache_start_cursor =
|
|
146
|
+
self._cache_start_cursor = run_lines[0].cursor if run_lines else 0
|
|
120
147
|
self._cache.clear()
|
|
121
|
-
for
|
|
122
|
-
self._cache.append(
|
|
123
|
-
RunLine(
|
|
124
|
-
cursor=start_cursor + i,
|
|
125
|
-
line=line,
|
|
126
|
-
ts=self.metadata.last_activity, # Approximate
|
|
127
|
-
)
|
|
128
|
-
)
|
|
148
|
+
for rl in run_lines:
|
|
149
|
+
self._cache.append(rl)
|
|
129
150
|
|
|
130
151
|
def _save_metadata(self) -> None:
|
|
131
152
|
"""Save metadata to disk."""
|
|
@@ -147,17 +168,26 @@ class Run:
|
|
|
147
168
|
f,
|
|
148
169
|
)
|
|
149
170
|
|
|
150
|
-
def append(self, line: str) -> RunLine:
|
|
171
|
+
def append(self, line: str, tags: Optional[Dict[str, str]] = None) -> RunLine:
|
|
151
172
|
"""Append a line to the run. Returns the line with assigned cursor."""
|
|
173
|
+
import json
|
|
174
|
+
|
|
152
175
|
with self._lock:
|
|
153
176
|
now = datetime.now(timezone.utc)
|
|
154
177
|
cursor = self.metadata.cursor_latest + 1
|
|
155
178
|
|
|
156
|
-
run_line = RunLine(cursor=cursor, line=line, ts=now)
|
|
179
|
+
run_line = RunLine(cursor=cursor, line=line, ts=now, tags=tags or {})
|
|
157
180
|
|
|
158
|
-
# Append to disk
|
|
181
|
+
# Append to disk as JSONL
|
|
182
|
+
record = {
|
|
183
|
+
"c": cursor,
|
|
184
|
+
"l": line,
|
|
185
|
+
"t": now.isoformat(),
|
|
186
|
+
}
|
|
187
|
+
if tags:
|
|
188
|
+
record["g"] = tags # g for tags (short key)
|
|
159
189
|
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
160
|
-
written = f.write(
|
|
190
|
+
written = f.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
161
191
|
self.metadata.bytes_on_disk += written
|
|
162
192
|
|
|
163
193
|
# Update cache
|
|
@@ -172,17 +202,23 @@ class Run:
|
|
|
172
202
|
|
|
173
203
|
return run_line
|
|
174
204
|
|
|
175
|
-
def append_batch(
|
|
205
|
+
def append_batch(
|
|
206
|
+
self, lines: List[str], tags: Optional[Dict[str, str]] = None
|
|
207
|
+
) -> List[RunLine]:
|
|
176
208
|
"""Append multiple lines atomically."""
|
|
177
209
|
with self._lock:
|
|
178
210
|
result = []
|
|
179
211
|
for line in lines:
|
|
180
|
-
result.append(self.append(line))
|
|
212
|
+
result.append(self.append(line, tags))
|
|
181
213
|
self._save_metadata()
|
|
182
214
|
return result
|
|
183
215
|
|
|
184
216
|
def set_tags(self, tags: Dict[str, str]) -> Optional[str]:
|
|
185
|
-
"""
|
|
217
|
+
"""Validate tags. Returns error message on invalid tag, None on success.
|
|
218
|
+
|
|
219
|
+
Note: Tags are now stored per-line, not per-run. This method just validates
|
|
220
|
+
and tracks known tag keys in run metadata for discoverability.
|
|
221
|
+
"""
|
|
186
222
|
import re
|
|
187
223
|
|
|
188
224
|
with self._lock:
|
|
@@ -193,12 +229,8 @@ class Run:
|
|
|
193
229
|
# Validate value length
|
|
194
230
|
if len(value) > TAG_VALUE_MAX_LEN:
|
|
195
231
|
return f"Tag value too long: {key}"
|
|
196
|
-
# Check for conflict
|
|
197
|
-
if key in self.metadata.tags and self.metadata.tags[key] != value:
|
|
198
|
-
existing = self.metadata.tags[key]
|
|
199
|
-
return f"Tag conflict for key '{key}': existing='{existing}', new='{value}'"
|
|
200
232
|
|
|
201
|
-
#
|
|
233
|
+
# Track tag keys in metadata (last value wins, just for discoverability)
|
|
202
234
|
self.metadata.tags.update(tags)
|
|
203
235
|
self._save_metadata()
|
|
204
236
|
return None
|
|
@@ -222,6 +254,7 @@ class Run:
|
|
|
222
254
|
since: Optional[int] = None,
|
|
223
255
|
tail: int = 50,
|
|
224
256
|
limit: int = 1000,
|
|
257
|
+
tag_filter: Optional[Dict[str, str]] = None,
|
|
225
258
|
) -> tuple[List[RunLine], bool]:
|
|
226
259
|
"""
|
|
227
260
|
Get lines from run.
|
|
@@ -230,6 +263,7 @@ class Run:
|
|
|
230
263
|
since: Cursor to start from (exclusive). If None, returns last `tail` lines.
|
|
231
264
|
tail: Number of recent lines if since is None.
|
|
232
265
|
limit: Maximum lines to return.
|
|
266
|
+
tag_filter: Filter lines by tags (AND semantics).
|
|
233
267
|
|
|
234
268
|
Returns:
|
|
235
269
|
Tuple of (lines, gap_detected).
|
|
@@ -253,6 +287,12 @@ class Run:
|
|
|
253
287
|
# Tail mode - get last N lines
|
|
254
288
|
lines = list(self._cache)[-tail:]
|
|
255
289
|
|
|
290
|
+
# Filter by tags (AND semantics)
|
|
291
|
+
if tag_filter:
|
|
292
|
+
lines = [
|
|
293
|
+
ln for ln in lines if all(ln.tags.get(k) == v for k, v in tag_filter.items())
|
|
294
|
+
]
|
|
295
|
+
|
|
256
296
|
# Apply limit
|
|
257
297
|
if len(lines) > limit:
|
|
258
298
|
lines = lines[:limit]
|