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/__init__.py
CHANGED
logtap/api/app.py
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
"""FastAPI application factory for logtap."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
3
7
|
from fastapi import FastAPI
|
|
4
8
|
from fastapi.middleware.cors import CORSMiddleware
|
|
5
9
|
|
|
6
10
|
from logtap import __version__
|
|
7
|
-
from logtap.api.routes import files, health, logs, parsed
|
|
11
|
+
from logtap.api.routes import files, health, logs, parsed, runs
|
|
12
|
+
from logtap.core.runs import RunStore
|
|
8
13
|
|
|
9
14
|
|
|
10
15
|
def create_app() -> FastAPI:
|
|
11
16
|
"""
|
|
12
|
-
Create and configure the FastAPI application.
|
|
17
|
+
Create and configure the FastAPI application for serve mode.
|
|
18
|
+
|
|
19
|
+
Serves static log files from a directory (legacy mode).
|
|
13
20
|
|
|
14
21
|
Returns:
|
|
15
22
|
Configured FastAPI application instance.
|
|
@@ -23,6 +30,10 @@ def create_app() -> FastAPI:
|
|
|
23
30
|
openapi_url="/openapi.json",
|
|
24
31
|
)
|
|
25
32
|
|
|
33
|
+
# Store mode info
|
|
34
|
+
app.state.mode = "serve"
|
|
35
|
+
app.state.features = ["files"]
|
|
36
|
+
|
|
26
37
|
# Configure CORS
|
|
27
38
|
app.add_middleware(
|
|
28
39
|
CORSMiddleware,
|
|
@@ -41,5 +52,60 @@ def create_app() -> FastAPI:
|
|
|
41
52
|
return app
|
|
42
53
|
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
def create_collector_app() -> FastAPI:
|
|
56
|
+
"""
|
|
57
|
+
Create and configure the FastAPI application for collector mode.
|
|
58
|
+
|
|
59
|
+
Accepts ingested log streams and serves them for tailing.
|
|
60
|
+
This is the recommended mode for ML training logs.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Configured FastAPI application instance.
|
|
64
|
+
"""
|
|
65
|
+
app = FastAPI(
|
|
66
|
+
title="logtap",
|
|
67
|
+
description="tail -f for GPU clouds. Survives disconnects, aggregates multi-node.",
|
|
68
|
+
version=__version__,
|
|
69
|
+
docs_url="/docs",
|
|
70
|
+
redoc_url="/redoc",
|
|
71
|
+
openapi_url="/openapi.json",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Store mode info and start time
|
|
75
|
+
app.state.mode = "collect"
|
|
76
|
+
app.state.features = ["runs"]
|
|
77
|
+
app.state.start_time = time.time()
|
|
78
|
+
|
|
79
|
+
# Configure CORS
|
|
80
|
+
app.add_middleware(
|
|
81
|
+
CORSMiddleware,
|
|
82
|
+
allow_origins=["*"],
|
|
83
|
+
allow_credentials=True,
|
|
84
|
+
allow_methods=["*"],
|
|
85
|
+
allow_headers=["*"],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Initialize run store from environment
|
|
89
|
+
data_dir = Path(os.environ.get("LOGTAP_DATA_DIR", "~/.logtap/runs")).expanduser()
|
|
90
|
+
buffer_lines = int(os.environ.get("LOGTAP_BUFFER_LINES", "100000"))
|
|
91
|
+
max_disk_mb = int(os.environ.get("LOGTAP_MAX_DISK_MB", "1000"))
|
|
92
|
+
retention_hours = int(os.environ.get("LOGTAP_RETENTION_HOURS", "72"))
|
|
93
|
+
|
|
94
|
+
run_store = RunStore(
|
|
95
|
+
data_dir=data_dir,
|
|
96
|
+
buffer_lines=buffer_lines,
|
|
97
|
+
max_disk_mb=max_disk_mb,
|
|
98
|
+
retention_hours=retention_hours,
|
|
99
|
+
)
|
|
100
|
+
runs.set_run_store(run_store)
|
|
101
|
+
app.state.run_store = run_store
|
|
102
|
+
|
|
103
|
+
# Include routers
|
|
104
|
+
app.include_router(health.router, tags=["health"])
|
|
105
|
+
app.include_router(runs.router, prefix="/runs", tags=["runs"])
|
|
106
|
+
|
|
107
|
+
return app
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Create default app instance for uvicorn (serve mode)
|
|
45
111
|
app = create_app()
|
logtap/api/routes/health.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Health check endpoint for logtap."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Request
|
|
4
6
|
|
|
5
7
|
from logtap import __version__
|
|
6
8
|
from logtap.models.responses import HealthResponse
|
|
@@ -9,11 +11,31 @@ router = APIRouter()
|
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
@router.get("/health", response_model=HealthResponse)
|
|
12
|
-
async def health_check() -> HealthResponse:
|
|
14
|
+
async def health_check(request: Request) -> HealthResponse:
|
|
13
15
|
"""
|
|
14
16
|
Check the health of the logtap service.
|
|
15
17
|
|
|
16
18
|
Returns:
|
|
17
|
-
Health status and
|
|
19
|
+
Health status, version, mode, and capability information.
|
|
18
20
|
"""
|
|
19
|
-
|
|
21
|
+
mode = getattr(request.app.state, "mode", "serve")
|
|
22
|
+
features = getattr(request.app.state, "features", ["files"])
|
|
23
|
+
|
|
24
|
+
# Get run count if in collect mode
|
|
25
|
+
runs_count = None
|
|
26
|
+
if hasattr(request.app.state, "run_store"):
|
|
27
|
+
runs_count = len(request.app.state.run_store.list_runs())
|
|
28
|
+
|
|
29
|
+
# Get uptime if start_time is set
|
|
30
|
+
uptime = None
|
|
31
|
+
if hasattr(request.app.state, "start_time"):
|
|
32
|
+
uptime = int(time.time() - request.app.state.start_time)
|
|
33
|
+
|
|
34
|
+
return HealthResponse(
|
|
35
|
+
status="healthy",
|
|
36
|
+
version=__version__,
|
|
37
|
+
mode=mode,
|
|
38
|
+
features=features,
|
|
39
|
+
runs=runs_count,
|
|
40
|
+
uptime_seconds=uptime,
|
|
41
|
+
)
|
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
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Routes for run management (collector mode)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
|
|
9
|
+
from logtap.api.dependencies import verify_api_key
|
|
10
|
+
from logtap.core.runs import RunStore
|
|
11
|
+
from logtap.models.responses import (
|
|
12
|
+
IngestResponse,
|
|
13
|
+
RunInfo,
|
|
14
|
+
RunListResponse,
|
|
15
|
+
StreamLineEvent,
|
|
16
|
+
StreamMetaEvent,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
router = APIRouter()
|
|
20
|
+
|
|
21
|
+
# Global run store - will be set by app factory
|
|
22
|
+
_run_store: Optional[RunStore] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_run_store() -> RunStore:
|
|
26
|
+
"""Get the run store instance."""
|
|
27
|
+
if _run_store is None:
|
|
28
|
+
raise HTTPException(status_code=500, detail="Run store not initialized")
|
|
29
|
+
return _run_store
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def set_run_store(store: RunStore) -> None:
|
|
33
|
+
"""Set the global run store instance."""
|
|
34
|
+
global _run_store
|
|
35
|
+
_run_store = store
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_tags(tag_headers: Optional[List[str]]) -> dict:
|
|
39
|
+
"""Parse X-Logtap-Tag headers into dict."""
|
|
40
|
+
if not tag_headers:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
tags = {}
|
|
44
|
+
for tag in tag_headers:
|
|
45
|
+
if "=" in tag:
|
|
46
|
+
key, value = tag.split("=", 1)
|
|
47
|
+
tags[key.strip()] = value.strip()
|
|
48
|
+
return tags
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("", response_model=RunListResponse)
|
|
52
|
+
async def list_runs(
|
|
53
|
+
since_hours: Optional[int] = Query(None, description="Filter to runs active within N hours"),
|
|
54
|
+
_: None = Depends(verify_api_key),
|
|
55
|
+
store: RunStore = Depends(get_run_store),
|
|
56
|
+
) -> RunListResponse:
|
|
57
|
+
"""List all runs."""
|
|
58
|
+
runs = store.list_runs(since_hours=since_hours)
|
|
59
|
+
|
|
60
|
+
return RunListResponse(
|
|
61
|
+
runs=[
|
|
62
|
+
RunInfo(
|
|
63
|
+
id=run.id,
|
|
64
|
+
lines=run.metadata.lines_count,
|
|
65
|
+
cursor_earliest=run.cursor_earliest,
|
|
66
|
+
cursor_latest=run.cursor_latest,
|
|
67
|
+
tags=run.metadata.tags,
|
|
68
|
+
created_at=run.metadata.created_at,
|
|
69
|
+
last_activity=run.metadata.last_activity,
|
|
70
|
+
active=run.metadata.active,
|
|
71
|
+
bytes_on_disk=run.metadata.bytes_on_disk,
|
|
72
|
+
)
|
|
73
|
+
for run in runs
|
|
74
|
+
]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/{run_id}", response_model=RunInfo)
|
|
79
|
+
async def get_run(
|
|
80
|
+
run_id: str,
|
|
81
|
+
_: None = Depends(verify_api_key),
|
|
82
|
+
store: RunStore = Depends(get_run_store),
|
|
83
|
+
) -> RunInfo:
|
|
84
|
+
"""Get details for a specific run."""
|
|
85
|
+
run = store.get(run_id)
|
|
86
|
+
if run is None:
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=404,
|
|
89
|
+
detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return RunInfo(
|
|
93
|
+
id=run.id,
|
|
94
|
+
lines=run.metadata.lines_count,
|
|
95
|
+
cursor_earliest=run.cursor_earliest,
|
|
96
|
+
cursor_latest=run.cursor_latest,
|
|
97
|
+
tags=run.metadata.tags,
|
|
98
|
+
created_at=run.metadata.created_at,
|
|
99
|
+
last_activity=run.metadata.last_activity,
|
|
100
|
+
active=run.metadata.active,
|
|
101
|
+
bytes_on_disk=run.metadata.bytes_on_disk,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.post("/{run_id}/ingest", response_model=IngestResponse)
|
|
106
|
+
async def ingest(
|
|
107
|
+
run_id: str,
|
|
108
|
+
request: Request,
|
|
109
|
+
response: Response,
|
|
110
|
+
x_logtap_tag: Optional[List[str]] = Header(None),
|
|
111
|
+
_: None = Depends(verify_api_key),
|
|
112
|
+
store: RunStore = Depends(get_run_store),
|
|
113
|
+
) -> IngestResponse:
|
|
114
|
+
"""
|
|
115
|
+
Ingest log lines for a run.
|
|
116
|
+
|
|
117
|
+
Send lines as plain text with newline delimiters.
|
|
118
|
+
Supports chunked transfer encoding for streaming.
|
|
119
|
+
"""
|
|
120
|
+
# Check storage
|
|
121
|
+
storage_err = store.check_storage()
|
|
122
|
+
if storage_err:
|
|
123
|
+
raise HTTPException(
|
|
124
|
+
status_code=507,
|
|
125
|
+
detail={"error": "insufficient_storage", "message": "Disk limit exceeded"},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Get or create run
|
|
129
|
+
run, created = store.get_or_create(run_id)
|
|
130
|
+
|
|
131
|
+
# Handle tags (validate and track in metadata)
|
|
132
|
+
tags = parse_tags(x_logtap_tag)
|
|
133
|
+
if tags:
|
|
134
|
+
err = run.set_tags(tags)
|
|
135
|
+
if err:
|
|
136
|
+
raise HTTPException(
|
|
137
|
+
status_code=400,
|
|
138
|
+
detail={"error": "invalid_tag", "message": err},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Read and ingest body
|
|
142
|
+
lines_ingested = 0
|
|
143
|
+
buffer = ""
|
|
144
|
+
|
|
145
|
+
async for chunk in request.stream():
|
|
146
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
147
|
+
buffer += text
|
|
148
|
+
|
|
149
|
+
# Process complete lines
|
|
150
|
+
while "\n" in buffer:
|
|
151
|
+
line, buffer = buffer.split("\n", 1)
|
|
152
|
+
run.append(line, tags) # Pass tags to each line
|
|
153
|
+
lines_ingested += 1
|
|
154
|
+
|
|
155
|
+
# Flush remaining partial line
|
|
156
|
+
if buffer:
|
|
157
|
+
run.append(buffer, tags) # Pass tags to each line
|
|
158
|
+
lines_ingested += 1
|
|
159
|
+
|
|
160
|
+
# Save metadata
|
|
161
|
+
run._save_metadata()
|
|
162
|
+
|
|
163
|
+
# Set status code: 201 for new run, 200 for existing
|
|
164
|
+
response.status_code = 201 if created else 200
|
|
165
|
+
|
|
166
|
+
return IngestResponse(
|
|
167
|
+
run_id=run_id,
|
|
168
|
+
lines_ingested=lines_ingested,
|
|
169
|
+
cursor_end=run.cursor_latest,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@router.get("/{run_id}/stream")
|
|
174
|
+
async def stream_run(
|
|
175
|
+
run_id: str,
|
|
176
|
+
since: Optional[int] = Query(None, description="Cursor to resume from (exclusive)"),
|
|
177
|
+
tail: int = Query(50, description="Lines to show if since not provided"),
|
|
178
|
+
follow: bool = Query(False, description="Keep connection open for new lines"),
|
|
179
|
+
tag: Optional[List[str]] = Query(None, description="Filter by tag (key=value)"),
|
|
180
|
+
_: None = Depends(verify_api_key),
|
|
181
|
+
store: RunStore = Depends(get_run_store),
|
|
182
|
+
) -> StreamingResponse:
|
|
183
|
+
"""
|
|
184
|
+
Stream lines from a run using Server-Sent Events.
|
|
185
|
+
|
|
186
|
+
Supports resume via `since` parameter or Last-Event-ID header.
|
|
187
|
+
"""
|
|
188
|
+
run = store.get(run_id)
|
|
189
|
+
if run is None:
|
|
190
|
+
raise HTTPException(
|
|
191
|
+
status_code=404,
|
|
192
|
+
detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Parse tag filter for per-line filtering
|
|
196
|
+
tag_filter = parse_tags(tag) if tag else None
|
|
197
|
+
|
|
198
|
+
async def generate_sse():
|
|
199
|
+
# Get initial lines and check for gap
|
|
200
|
+
lines, gap = run.get_lines(since=since, tail=tail, tag_filter=tag_filter)
|
|
201
|
+
|
|
202
|
+
# Send meta event
|
|
203
|
+
missed = None
|
|
204
|
+
if gap and since is not None:
|
|
205
|
+
missed = run.cursor_earliest - since - 1
|
|
206
|
+
if missed < 0:
|
|
207
|
+
missed = 0
|
|
208
|
+
|
|
209
|
+
meta = StreamMetaEvent(
|
|
210
|
+
cursor_earliest=run.cursor_earliest,
|
|
211
|
+
cursor_latest=run.cursor_latest,
|
|
212
|
+
gap=gap,
|
|
213
|
+
missed=missed,
|
|
214
|
+
)
|
|
215
|
+
yield f"event: meta\ndata: {meta.model_dump_json()}\n\n"
|
|
216
|
+
|
|
217
|
+
# Send initial lines
|
|
218
|
+
last_cursor = since if since is not None else -1
|
|
219
|
+
for line in lines:
|
|
220
|
+
event = StreamLineEvent(
|
|
221
|
+
cursor=line.cursor,
|
|
222
|
+
line=line.line,
|
|
223
|
+
ts=line.ts,
|
|
224
|
+
)
|
|
225
|
+
yield f"id: {line.cursor}\nevent: line\ndata: {event.model_dump_json()}\n\n"
|
|
226
|
+
last_cursor = line.cursor
|
|
227
|
+
|
|
228
|
+
if not follow:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
# Follow mode - stream new lines
|
|
232
|
+
heartbeat_interval = 15 # seconds
|
|
233
|
+
last_heartbeat = asyncio.get_event_loop().time()
|
|
234
|
+
|
|
235
|
+
while True:
|
|
236
|
+
# Get new lines since last cursor
|
|
237
|
+
new_lines, _ = run.get_lines(since=last_cursor, limit=100, tag_filter=tag_filter)
|
|
238
|
+
|
|
239
|
+
for line in new_lines:
|
|
240
|
+
event = StreamLineEvent(
|
|
241
|
+
cursor=line.cursor,
|
|
242
|
+
line=line.line,
|
|
243
|
+
ts=line.ts,
|
|
244
|
+
)
|
|
245
|
+
yield f"id: {line.cursor}\nevent: line\ndata: {event.model_dump_json()}\n\n"
|
|
246
|
+
last_cursor = line.cursor
|
|
247
|
+
|
|
248
|
+
# Send heartbeat if needed
|
|
249
|
+
now = asyncio.get_event_loop().time()
|
|
250
|
+
if now - last_heartbeat >= heartbeat_interval:
|
|
251
|
+
yield ": heartbeat\n\n"
|
|
252
|
+
last_heartbeat = now
|
|
253
|
+
|
|
254
|
+
# Small delay before checking again
|
|
255
|
+
await asyncio.sleep(0.1)
|
|
256
|
+
|
|
257
|
+
return StreamingResponse(
|
|
258
|
+
generate_sse(),
|
|
259
|
+
media_type="text/event-stream",
|
|
260
|
+
headers={
|
|
261
|
+
"Cache-Control": "no-cache",
|
|
262
|
+
"X-Logtap-Earliest-Cursor": str(run.cursor_earliest),
|
|
263
|
+
"X-Logtap-Latest-Cursor": str(run.cursor_latest),
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@router.get("/{run_id}/query")
|
|
269
|
+
async def query_run(
|
|
270
|
+
run_id: str,
|
|
271
|
+
from_cursor: Optional[int] = Query(None, alias="from", description="Start cursor (inclusive)"),
|
|
272
|
+
to_cursor: Optional[int] = Query(None, alias="to", description="End cursor (inclusive)"),
|
|
273
|
+
tail: int = Query(50, description="Last N lines (if from/to not provided)"),
|
|
274
|
+
limit: int = Query(1000, le=10000, description="Maximum lines to return"),
|
|
275
|
+
search: Optional[str] = Query(None, description="Substring filter"),
|
|
276
|
+
regex: Optional[str] = Query(None, description="Regex filter"),
|
|
277
|
+
output: str = Query("jsonl", description="Output format: jsonl or plain"),
|
|
278
|
+
_: None = Depends(verify_api_key),
|
|
279
|
+
store: RunStore = Depends(get_run_store),
|
|
280
|
+
) -> StreamingResponse:
|
|
281
|
+
"""Query lines from a run."""
|
|
282
|
+
# Validate search/regex mutual exclusion
|
|
283
|
+
if search and regex:
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=400,
|
|
286
|
+
detail={"error": "invalid_query", "message": "Cannot use both search and regex"},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
run = store.get(run_id)
|
|
290
|
+
if run is None:
|
|
291
|
+
raise HTTPException(
|
|
292
|
+
status_code=404,
|
|
293
|
+
detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Get lines
|
|
297
|
+
if from_cursor is not None:
|
|
298
|
+
# Range query - get lines from cursor onwards
|
|
299
|
+
lines, _ = run.get_lines(since=from_cursor - 1, limit=limit)
|
|
300
|
+
if to_cursor is not None:
|
|
301
|
+
lines = [ln for ln in lines if ln.cursor <= to_cursor]
|
|
302
|
+
else:
|
|
303
|
+
# Tail query
|
|
304
|
+
lines, _ = run.get_lines(tail=tail, limit=limit)
|
|
305
|
+
|
|
306
|
+
# Apply search/regex filter
|
|
307
|
+
if search:
|
|
308
|
+
lines = [ln for ln in lines if search in ln.line]
|
|
309
|
+
elif regex:
|
|
310
|
+
import re2
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
pattern = re2.compile(regex)
|
|
314
|
+
lines = [ln for ln in lines if pattern.search(ln.line)]
|
|
315
|
+
except re2.error:
|
|
316
|
+
raise HTTPException(
|
|
317
|
+
status_code=400,
|
|
318
|
+
detail={"error": "invalid_regex", "message": "Invalid regex pattern"},
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
async def generate():
|
|
322
|
+
for line in lines:
|
|
323
|
+
if output == "plain":
|
|
324
|
+
yield line.line + "\n"
|
|
325
|
+
else:
|
|
326
|
+
event = StreamLineEvent(cursor=line.cursor, line=line.line, ts=line.ts)
|
|
327
|
+
yield event.model_dump_json() + "\n"
|
|
328
|
+
|
|
329
|
+
content_type = "text/plain" if output == "plain" else "application/x-ndjson"
|
|
330
|
+
return StreamingResponse(generate(), media_type=content_type)
|