Refactor: move log related inside the logs module
This commit is contained in:
parent
b1a05b1d79
commit
f88422e92b
4 changed files with 142 additions and 122 deletions
|
|
@ -79,5 +79,20 @@ def format_validation_errors(exc: ValidationError) -> list[dict[str, Any]]:
|
||||||
|
|
||||||
def gen_random(length: int = 16) -> str:
|
def gen_random(length: int = 16) -> str:
|
||||||
import secrets
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
return "".join(secrets.token_urlsafe(length)[:length])
|
if length < 1:
|
||||||
|
msg = "length must be >= 1"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
middle_alphabet = string.ascii_letters + string.digits + "-_"
|
||||||
|
edge_alphabet = string.ascii_letters + string.digits
|
||||||
|
|
||||||
|
if 1 == length:
|
||||||
|
return secrets.choice(edge_alphabet)
|
||||||
|
|
||||||
|
return (
|
||||||
|
secrets.choice(edge_alphabet)
|
||||||
|
+ "".join(secrets.choice(middle_alphabet) for _ in range(length - 2))
|
||||||
|
+ secrets.choice(edge_alphabet)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ FILES_TYPE: list = [
|
||||||
|
|
||||||
TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
|
TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
|
||||||
"Regex to find tags in templates."
|
"Regex to find tags in templates."
|
||||||
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
|
||||||
"Regex to match ISO 8601 datetime strings."
|
|
||||||
|
|
||||||
|
|
||||||
class FileLogFormatter(logging.Formatter):
|
class FileLogFormatter(logging.Formatter):
|
||||||
|
|
@ -969,116 +967,6 @@ def strip_newline(string: str) -> str:
|
||||||
return res.strip() if res else ""
|
return res.strip() if res else ""
|
||||||
|
|
||||||
|
|
||||||
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 hashlib import sha256
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
|
|
||||||
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
|
|
||||||
msg: str = line.decode(errors="replace")
|
|
||||||
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
|
|
||||||
result.append(
|
|
||||||
{
|
|
||||||
"id": sha256(line_bytes).hexdigest(),
|
|
||||||
"line": msg[dt_match.end() :] if dt_match else msg,
|
|
||||||
"datetime": dt_match.group(1) if dt_match else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""
|
|
||||||
Continuously read a log file and emit new lines.
|
|
||||||
|
|
||||||
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 asyncio import sleep as asyncio_sleep
|
|
||||||
from hashlib import sha256
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
msg: str = line.decode(errors="replace")
|
|
||||||
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
|
|
||||||
|
|
||||||
await emitter(
|
|
||||||
{
|
|
||||||
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
|
|
||||||
"line": msg[dt_match.end() :] if dt_match else msg,
|
|
||||||
"datetime": dt_match.group(1) if dt_match else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
|
def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
|
||||||
"""
|
"""
|
||||||
Validate and load a cookie file.
|
Validate and load a cookie file.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
@ -8,10 +10,121 @@ from aiohttp.web import Request, Response
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.router import route
|
from app.library.router import route
|
||||||
from app.library.Utils import read_logfile, tail_log
|
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
||||||
|
"Match ISO8601."
|
||||||
|
|
||||||
|
|
||||||
|
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 hashlib import sha256
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
|
||||||
|
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
|
||||||
|
msg: str = line.decode(errors="replace")
|
||||||
|
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": sha256(line_bytes).hexdigest(),
|
||||||
|
"line": msg[dt_match.end() :] if dt_match else msg,
|
||||||
|
"datetime": dt_match.group(1) if dt_match else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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 hashlib import sha256
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
msg: str = line.decode(errors="replace")
|
||||||
|
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
|
||||||
|
|
||||||
|
await emitter(
|
||||||
|
{
|
||||||
|
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
|
||||||
|
"line": msg[dt_match.end() :] if dt_match else msg,
|
||||||
|
"datetime": dt_match.group(1) if dt_match else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
@route("GET", "api/logs/", "logs")
|
@route("GET", "api/logs/", "logs")
|
||||||
async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||||
|
|
@ -35,7 +148,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||||
if limit < 1 or limit > 150:
|
if limit < 1 or limit > 150:
|
||||||
limit = 50
|
limit = 50
|
||||||
|
|
||||||
logs_data = await read_logfile(
|
logs_data = await _read_logfile(
|
||||||
file=Path(config.config_path) / "logs" / "app.log",
|
file=Path(config.config_path) / "logs" / "app.log",
|
||||||
offset=offset,
|
offset=offset,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
|
@ -85,12 +198,17 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res
|
||||||
payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n"
|
payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n"
|
||||||
await response.write(payload.encode("utf-8"))
|
await response.write(payload.encode("utf-8"))
|
||||||
|
|
||||||
log_task: asyncio.Task[None] = asyncio.create_task(tail_log(file=log_file, emitter=emit_log), name="log_stream")
|
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:
|
try:
|
||||||
LOG.debug("Log streaming connected.")
|
LOG.debug("Log streaming connected.")
|
||||||
while not log_task.done():
|
while not log_task.done():
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(1.0)
|
||||||
if request.transport is None or request.transport.is_closing():
|
if request.transport is None or request.transport.is_closing():
|
||||||
log_task.cancel()
|
log_task.cancel()
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,14 @@ from app.library.Utils import (
|
||||||
merge_dict,
|
merge_dict,
|
||||||
move_file,
|
move_file,
|
||||||
parse_tags,
|
parse_tags,
|
||||||
read_logfile,
|
|
||||||
rename_file,
|
rename_file,
|
||||||
str_to_dt,
|
str_to_dt,
|
||||||
strip_newline,
|
strip_newline,
|
||||||
tail_log,
|
|
||||||
timed_lru_cache,
|
timed_lru_cache,
|
||||||
validate_url,
|
validate_url,
|
||||||
validate_uuid,
|
validate_uuid,
|
||||||
)
|
)
|
||||||
|
from app.routes.api.logs import _read_logfile, _tail_log
|
||||||
|
|
||||||
|
|
||||||
class TestTimedLruCache:
|
class TestTimedLruCache:
|
||||||
|
|
@ -1517,7 +1516,7 @@ class TestReadLogfile:
|
||||||
"""Test reading non-existent log file."""
|
"""Test reading non-existent log file."""
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
result = await read_logfile(self.log_file)
|
result = await _read_logfile(self.log_file)
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert "logs" in result
|
assert "logs" in result
|
||||||
|
|
||||||
|
|
@ -1528,7 +1527,7 @@ class TestReadLogfile:
|
||||||
self.log_file.write_text("line 1\nline 2\nline 3\n")
|
self.log_file.write_text("line 1\nline 2\nline 3\n")
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
result = await read_logfile(self.log_file, limit=2)
|
result = await _read_logfile(self.log_file, limit=2)
|
||||||
assert isinstance(result, dict)
|
assert isinstance(result, dict)
|
||||||
assert "logs" in result
|
assert "logs" in result
|
||||||
|
|
||||||
|
|
@ -1559,7 +1558,7 @@ class TestTailLog:
|
||||||
|
|
||||||
async def test():
|
async def test():
|
||||||
try:
|
try:
|
||||||
await tail_log(self.log_file, emitter, sleep_time=0.1)
|
await _tail_log(self.log_file, emitter, sleep_time=0.1)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Expected for non-existent file
|
pass # Expected for non-existent file
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue