feat: events journal read path with filters and backward pagination
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+81
-1
@@ -11,7 +11,7 @@ import os
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Iterable
|
||||
from typing import Dict, Any, Optional, Callable, Iterable, List, Tuple, Union
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -334,6 +334,86 @@ class MessageJournal:
|
||||
}
|
||||
|
||||
|
||||
def _iter_journal_events(journal_dir: Union[str, Path], journal_file: str) -> Iterable[Dict[str, Any]]:
|
||||
"""Yield event dicts from the journal files, newest event first.
|
||||
|
||||
Reads the current file, then rotated backups newest-to-oldest (backup
|
||||
names embed rotation timestamps, so filename sort is chronological).
|
||||
"""
|
||||
dirp = Path(journal_dir)
|
||||
files = [dirp / journal_file]
|
||||
files.extend(sorted(dirp.glob(journal_file + '.*'), reverse=True))
|
||||
for f in files:
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
lines = f.read_text(encoding='utf-8', errors='replace').splitlines()
|
||||
except OSError as e:
|
||||
logger.warning(f"Cannot read journal file {f}: {e}")
|
||||
continue
|
||||
for line in reversed(lines):
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(ev, dict):
|
||||
yield ev
|
||||
|
||||
|
||||
def filter_events(
|
||||
events: Iterable[Dict[str, Any]],
|
||||
limit: int = 100,
|
||||
before: Optional[float] = None,
|
||||
host: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""Filter an iterable of event dicts already ordered newest-first.
|
||||
|
||||
Returns (events, more): up to *limit* matching events, and whether at
|
||||
least one further matching event exists beyond the limit.
|
||||
"""
|
||||
host_l = host.lower() if host else None
|
||||
level_l = level.lower() if level else None
|
||||
q_l = q.lower() if q else None
|
||||
out: List[Dict[str, Any]] = []
|
||||
for ev in events:
|
||||
ts = ev.get('ts')
|
||||
if before is not None and (not isinstance(ts, (int, float)) or ts >= before):
|
||||
continue
|
||||
if host_l and host_l not in str(ev.get('host') or '').lower():
|
||||
continue
|
||||
if level_l and str(ev.get('level') or '').lower() != level_l:
|
||||
continue
|
||||
if q_l and q_l not in str(ev.get('message') or '').lower():
|
||||
continue
|
||||
if predicate is not None and not predicate(ev):
|
||||
continue
|
||||
if len(out) >= limit:
|
||||
return out, True
|
||||
out.append(ev)
|
||||
return out, False
|
||||
|
||||
|
||||
def read_events(
|
||||
journal_dir: Union[str, Path],
|
||||
journal_file: str = 'events.journal',
|
||||
*,
|
||||
limit: int = 100,
|
||||
before: Optional[float] = None,
|
||||
host: Optional[str] = None,
|
||||
level: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], bool]:
|
||||
"""Read filtered events newest-first from the events journal files."""
|
||||
return filter_events(
|
||||
_iter_journal_events(journal_dir, journal_file),
|
||||
limit=limit, before=before, host=host, level=level, q=q, predicate=predicate,
|
||||
)
|
||||
|
||||
|
||||
# Global journal instance
|
||||
_journal_instance: Optional[MessageJournal] = None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user