vibe/vibe/core/log_reader.py
Mathias Gesbert e9a9217cc8
v2.7.4 (#579)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Peter Evers <pevers90@gmail.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-04-09 18:40:46 +02:00

224 lines
6.9 KiB
Python

from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import re
import threading
from vibe.core.logger import logger
from vibe.core.paths import LOG_FILE
@dataclass(frozen=True, slots=True)
class LogEntry:
timestamp: datetime
ppid: int
pid: int
level: str
message: str
raw_line: str
line_number: int
@dataclass(frozen=True, slots=True)
class PaginatedLogs:
entries: list[LogEntry]
has_more: bool
cursor: int | None
LogConsumer = Callable[[LogEntry], None]
# Format: timestamp ppid pid level message [exception]
# Timestamp is ISO 8601 from datetime.isoformat(), e.g. 2026-02-21T10:28:51.529718+00:00
DEFAULT_LOG_PATTERN = re.compile(
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+(?:[+-]\d{2}:\d{2})?)\s+"
r"(\d+)\s+(\d+)\s+"
r"(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+"
r"(.+)$"
)
LOG_POLL_INTERVAL = 0.5
class LogReader:
def __init__(
self,
log_file: Path | None = None,
consumer: LogConsumer | None = None,
log_pattern: re.Pattern[str] = DEFAULT_LOG_PATTERN,
poll_interval: float = LOG_POLL_INTERVAL,
) -> None:
self._log_file = log_file if log_file is not None else LOG_FILE.path
self._consumer = consumer
self._log_pattern = log_pattern
self._lock = threading.Lock()
self._last_position: int = 0
self._new_lines_count: int = 0
self._stop_event: threading.Event | None = None
self._thread: threading.Thread | None = None
self._poll_interval = poll_interval
@property
def log_file(self) -> Path:
return self._log_file
@property
def is_watching(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def get_logs(self, limit: int = 100, offset: int = 0) -> PaginatedLogs:
if not self._log_file.exists():
return PaginatedLogs(entries=[], has_more=False, cursor=None)
entries: list[LogEntry] = []
for line, relative_position in self._read_lines_backward(start_position=offset):
if entry := self._parse_line(line, relative_position):
entries.append(entry)
if len(entries) >= limit:
return PaginatedLogs(
entries=entries, has_more=True, cursor=offset + len(entries)
)
return PaginatedLogs(entries=entries, has_more=False, cursor=None)
def _read_lines_backward(self, start_position: int) -> Iterator[tuple[str, int]]:
if not self._log_file.exists():
return
with self._lock:
# Snapshot once: file end-position is also captured once (seek EOF below),
# so new lines appended by the poll thread after this point are invisible
# to this iteration and the skip count stays consistent.
adjusted_skip = start_position + self._new_lines_count
chunk_size = 8192
relative_position = 0
skipped = 0
with self._log_file.open("rb") as f:
f.seek(0, 2)
position = f.tell()
remainder = b""
while position > 0:
read_size = min(chunk_size, position)
position -= read_size
f.seek(position)
chunk = f.read(read_size) + remainder
lines = chunk.split(b"\n")
remainder = lines[0]
for line in reversed(lines[1:]):
if not line:
continue
if skipped < adjusted_skip:
skipped += 1
continue
relative_position += 1
yield line.decode("utf-8", errors="replace"), relative_position
if remainder:
if skipped < adjusted_skip:
return
relative_position += 1
yield remainder.decode("utf-8", errors="replace"), relative_position
def set_consumer(self, consumer: LogConsumer | None) -> None:
self._consumer = consumer
def start_watching(self) -> None:
if self._thread and self._thread.is_alive():
return
with self._lock:
self._last_position = (
self._log_file.stat().st_size if self._log_file.exists() else 0
)
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._poll_log_loop, name="log-reader-poll", daemon=True
)
self._thread.start()
def stop_watching(self) -> None:
if self._stop_event:
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=1)
self._thread = None
self._stop_event = None
with self._lock:
self._new_lines_count = 0
def shutdown(self) -> None:
self.stop_watching()
def _poll_log_loop(self) -> None:
stop_event = self._stop_event
if stop_event is None:
return
while not stop_event.is_set():
stop_event.wait(self._poll_interval)
if stop_event.is_set():
break
self._process_new_content()
def _process_new_content(self) -> None:
consumer = self._consumer
if consumer is None:
return
lines: list[str]
with self._lock:
if not self._log_file.exists():
self._last_position = 0
return
current_size = self._log_file.stat().st_size
if current_size < self._last_position:
self._last_position = 0
self._new_lines_count = 0
if current_size == self._last_position:
return
with self._log_file.open("r") as f:
f.seek(self._last_position)
new_content = f.read()
self._last_position = f.tell()
lines = new_content.splitlines()
self._new_lines_count += len(lines)
for line in lines:
if entry := self._parse_line(line, 0):
consumer(entry)
def _parse_line(self, line: str, line_number: int) -> LogEntry | None:
try:
match = self._log_pattern.match(line)
if not match:
return None
timestamp_str, ppid_str, pid_str, level, message = match.groups()
timestamp = datetime.fromisoformat(timestamp_str)
return LogEntry(
timestamp=timestamp,
ppid=int(ppid_str),
pid=int(pid_str),
level=level,
message=message,
raw_line=line,
line_number=line_number,
)
except Exception:
logger.debug("Failed to parse log line: %s", line[:100])
return None