232 lines
6.9 KiB
Python
232 lines
6.9 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from aiohttp import web
|
|
from aiohttp.web import Request, Response
|
|
|
|
from app.library.config import Config
|
|
from app.library.encoder import Encoder
|
|
from app.library.router import route
|
|
|
|
LOG: logging.Logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _parse_jsonl_line(line: bytes | str) -> dict | None:
|
|
raw: str = line.decode(errors="replace") if isinstance(line, bytes) else line
|
|
raw = raw.rstrip("\r\n")
|
|
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
|
|
required = ("id", "datetime", "level", "logger", "message")
|
|
if any(not payload.get(key) for key in required):
|
|
return None
|
|
|
|
result: dict = {
|
|
"id": str(payload["id"]),
|
|
"datetime": str(payload["datetime"]),
|
|
"level": str(payload["level"]),
|
|
"logger": str(payload["logger"]),
|
|
"message": str(payload["message"]).strip(),
|
|
}
|
|
|
|
for key in ("levelno", "source", "process", "thread", "fields", "exception"):
|
|
if key not in payload:
|
|
continue
|
|
|
|
result[key] = payload[key]
|
|
|
|
return result
|
|
|
|
|
|
async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
|
|
"""
|
|
Read a log file and return a set of log lines along with pagination metadata.
|
|
|
|
Args:
|
|
file (Path): The log file path.
|
|
offset (int): Number of lines to skip from the end (newer entries).
|
|
limit (int): Number of lines to return.
|
|
|
|
Returns:
|
|
dict: A dictionary containing:
|
|
- logs: List of log entries.
|
|
- next_offset: Offset for the next page or None.
|
|
- end_is_reached: True if there are no older logs.
|
|
|
|
"""
|
|
from anyio import open_file
|
|
|
|
if not file.exists():
|
|
return {"logs": [], "next_offset": None, "end_is_reached": True}
|
|
|
|
result = []
|
|
try:
|
|
async with await open_file(file, "rb") as f:
|
|
await f.seek(0, os.SEEK_END)
|
|
file_size: int = await f.tell()
|
|
|
|
block_size = 1024
|
|
block_end: int = file_size
|
|
buffer: bytes = b""
|
|
lines: list = []
|
|
|
|
required_count: int = offset + limit + 1
|
|
|
|
while len(lines) < required_count and block_end > 0:
|
|
block_start: int = max(0, block_end - block_size)
|
|
await f.seek(block_start)
|
|
chunk: bytes = await f.read(block_end - block_start)
|
|
buffer: bytes = chunk + buffer # prepend the chunk
|
|
lines = buffer.splitlines()
|
|
block_end = block_start
|
|
|
|
if len(lines) > offset + limit:
|
|
next_offset: int = offset + limit
|
|
end_is_reached = False
|
|
else:
|
|
next_offset = None
|
|
end_is_reached = True
|
|
|
|
selected = lines[-(offset + limit) : -offset] if offset else lines[-limit:]
|
|
result.extend(log for line in selected if (log := _parse_jsonl_line(line)))
|
|
|
|
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
|
|
except Exception:
|
|
return {"logs": [], "next_offset": None, "end_is_reached": True}
|
|
|
|
|
|
async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
|
|
"""
|
|
Live tail.
|
|
|
|
Args:
|
|
file (str): The log file path.
|
|
emitter (callable): A callable to emit new lines.
|
|
sleep_time (float): The time to sleep between reads.
|
|
|
|
"""
|
|
from anyio import open_file
|
|
|
|
if not file.exists():
|
|
return
|
|
|
|
try:
|
|
async with await open_file(file, "rb") as f:
|
|
await f.seek(0, os.SEEK_END)
|
|
while True:
|
|
line: bytes = await f.readline()
|
|
if not line:
|
|
await asyncio.sleep(sleep_time)
|
|
continue
|
|
|
|
if log := _parse_jsonl_line(line):
|
|
await emitter(log)
|
|
except Exception:
|
|
LOG.exception("Failed to tail log file '%s'.", file, extra={"route": "logs.stream", "file_path": str(file)})
|
|
return
|
|
|
|
|
|
@route("GET", "api/logs/", "logs")
|
|
async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
|
"""
|
|
Get recent logs
|
|
|
|
Args:
|
|
request (Request): The request object.
|
|
config (Config): The configuration instance.
|
|
encoder (Encoder): The encoder instance.
|
|
|
|
Returns:
|
|
Response: The response object.
|
|
|
|
"""
|
|
if not config.file_logging:
|
|
return web.json_response(data={"error": "File logging is not enabled."}, status=web.HTTPNotFound.status_code)
|
|
|
|
offset = int(request.query.get("offset", 0))
|
|
limit = int(request.query.get("limit", 100))
|
|
if limit < 1 or limit > 150:
|
|
limit = 50
|
|
|
|
logs_data = await _read_logfile(
|
|
file=Path(config.config_path) / "logs" / "app.jsonl",
|
|
offset=offset,
|
|
limit=limit,
|
|
)
|
|
|
|
return web.json_response(
|
|
data={
|
|
"logs": logs_data["logs"],
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"next_offset": logs_data["next_offset"],
|
|
"end_is_reached": logs_data["end_is_reached"],
|
|
},
|
|
status=web.HTTPOk.status_code,
|
|
dumps=encoder.encode,
|
|
)
|
|
|
|
|
|
@route("GET", "api/logs/stream", "logs.stream")
|
|
async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
|
|
if not config.file_logging:
|
|
return web.json_response(
|
|
data={"error": "File logging is not enabled."},
|
|
status=web.HTTPNotFound.status_code,
|
|
)
|
|
|
|
log_file = Path(config.config_path) / "logs" / "app.jsonl"
|
|
if not log_file.exists():
|
|
return web.json_response(
|
|
data={"error": "Log file is not available."},
|
|
status=web.HTTPNotFound.status_code,
|
|
)
|
|
|
|
response = web.StreamResponse(
|
|
status=web.HTTPOk.status_code,
|
|
headers={
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
},
|
|
)
|
|
await response.prepare(request)
|
|
|
|
async def emit_log(data: dict) -> None:
|
|
if request.transport is None or request.transport.is_closing():
|
|
raise asyncio.CancelledError
|
|
payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n"
|
|
await response.write(payload.encode("utf-8"))
|
|
|
|
from app.features.core.utils import gen_random
|
|
|
|
log_task: asyncio.Task[None] = asyncio.create_task(
|
|
_tail_log(file=log_file, emitter=emit_log),
|
|
name=f"log_stream_{gen_random(8)}",
|
|
)
|
|
|
|
try:
|
|
while not log_task.done():
|
|
await asyncio.sleep(1.0)
|
|
if request.transport is None or request.transport.is_closing():
|
|
log_task.cancel()
|
|
break
|
|
await log_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
try:
|
|
await response.write_eof()
|
|
except ConnectionResetError:
|
|
pass
|
|
|
|
return response
|