4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
211 lines
7.4 KiB
Python
211 lines
7.4 KiB
Python
"""Polling-based file tailer for Hermes log files.
|
|
|
|
Behavior intentionally mirrors ``tail -F`` (capital-F) semantics:
|
|
|
|
* On open, seek to a configurable position (default: end-of-file, so live
|
|
tailing only reports *new* lines).
|
|
* On each poll cycle, read everything available and split into complete
|
|
lines. A trailing partial line (no newline yet) is buffered and prepended
|
|
to the next cycle so we never emit half a record.
|
|
* Detect file rotation by inode/device change *or* truncation (file size
|
|
smaller than our last position). On either, reopen from offset 0 so we
|
|
do not miss the first lines written to the rotated-in file.
|
|
* The tailer does not parse content. It yields raw line strings (with
|
|
trailing newlines stripped). Parsing happens in :mod:`integrations.hermes.parser`.
|
|
|
|
The tailer is single-threaded and pull-based (``__iter__`` blocks on
|
|
``poll_interval_s``); :class:`integrations.hermes.agent.HermesAgent` runs it on its
|
|
own daemon thread and dispatches lines through the parser/classifier.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_POLL_INTERVAL_S: Final[float] = 0.5
|
|
DEFAULT_READ_CHUNK: Final[int] = 64 * 1024
|
|
_REOPEN_LOG_THROTTLE_S: Final[float] = 30.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _FileFingerprint:
|
|
"""Identifier for the *physical* file currently backing the path.
|
|
|
|
Comparing ``(device, inode)`` across polls lets us notice a rename/
|
|
swap (logrotate) even when the path itself is unchanged.
|
|
"""
|
|
|
|
device: int
|
|
inode: int
|
|
|
|
|
|
class FileTailer:
|
|
"""Polling tailer that follows a logfile across rotations.
|
|
|
|
Construct with ``from_start=True`` to replay the existing file from
|
|
offset 0 (useful for tests and one-shot scans). The default
|
|
``from_start=False`` matches ``tail -F``: only new lines after the
|
|
tailer attaches are emitted.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"_path",
|
|
"_poll_interval_s",
|
|
"_read_chunk",
|
|
"_from_start",
|
|
"_stop_event",
|
|
"_partial",
|
|
"_pending_lines",
|
|
"_fingerprint",
|
|
"_position",
|
|
"_last_reopen_log",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
path: Path | str,
|
|
*,
|
|
poll_interval_s: float = DEFAULT_POLL_INTERVAL_S,
|
|
read_chunk: int = DEFAULT_READ_CHUNK,
|
|
from_start: bool = False,
|
|
stop_event: threading.Event | None = None,
|
|
) -> None:
|
|
if poll_interval_s <= 0:
|
|
raise ValueError("poll_interval_s must be > 0")
|
|
if read_chunk <= 0:
|
|
raise ValueError("read_chunk must be > 0")
|
|
|
|
self._path = Path(path)
|
|
self._poll_interval_s = poll_interval_s
|
|
self._read_chunk = read_chunk
|
|
self._from_start = from_start
|
|
self._stop_event = stop_event if stop_event is not None else threading.Event()
|
|
self._partial: str = ""
|
|
self._pending_lines: deque[str] = deque()
|
|
self._fingerprint: _FileFingerprint | None = None
|
|
self._position: int = 0
|
|
self._last_reopen_log: float = 0.0
|
|
|
|
@property
|
|
def path(self) -> Path:
|
|
return self._path
|
|
|
|
def stop(self) -> None:
|
|
"""Signal :meth:`__iter__` to exit at the next sleep boundary."""
|
|
self._stop_event.set()
|
|
|
|
def __iter__(self) -> Iterator[str]:
|
|
while True:
|
|
yield from self._drain_once()
|
|
# ``stop()`` should not discard lines that are already buffered in
|
|
# ``_pending_lines`` (e.g. stop signal arrives mid-drain).
|
|
if self._stop_event.is_set() and not self._pending_lines:
|
|
break
|
|
if self._stop_event.is_set():
|
|
continue
|
|
self._stop_event.wait(self._poll_interval_s)
|
|
|
|
def _drain_once(self) -> Iterator[str]:
|
|
while self._pending_lines:
|
|
yield self._pending_lines.popleft()
|
|
try:
|
|
stat = os.stat(self._path)
|
|
except FileNotFoundError:
|
|
self._fingerprint = None
|
|
self._position = 0
|
|
self._partial = ""
|
|
self._pending_lines.clear()
|
|
return
|
|
except OSError as exc:
|
|
self._maybe_log_reopen("cannot stat %s: %s", self._path, exc)
|
|
return
|
|
|
|
fingerprint = _FileFingerprint(device=stat.st_dev, inode=stat.st_ino)
|
|
rotated = self._fingerprint is not None and fingerprint != self._fingerprint
|
|
truncated = self._fingerprint is not None and stat.st_size < self._position
|
|
|
|
if self._fingerprint is None:
|
|
# First attach: ``from_start`` chooses replay vs live-tail.
|
|
self._position = 0 if self._from_start else stat.st_size
|
|
self._partial = ""
|
|
self._pending_lines.clear()
|
|
self._fingerprint = fingerprint
|
|
elif rotated or truncated:
|
|
self._maybe_log_reopen(
|
|
"log file %s %s; reopening from offset 0",
|
|
self._path,
|
|
"rotated" if rotated else "truncated",
|
|
)
|
|
self._position = 0
|
|
self._partial = ""
|
|
self._pending_lines.clear()
|
|
self._fingerprint = fingerprint
|
|
|
|
if stat.st_size <= self._position:
|
|
return
|
|
|
|
try:
|
|
yield from self._read_from_current_position()
|
|
except OSError as exc:
|
|
self._maybe_log_reopen("read failed on %s: %s", self._path, exc)
|
|
|
|
def _read_from_current_position(self) -> Iterator[str]:
|
|
"""Read complete lines from the file handle.
|
|
|
|
File bytes for each chunk are committed to ``_position`` and
|
|
``_partial`` *before* complete lines are yielded. Any lines not yet
|
|
yielded after a chunk is parsed live in ``_pending_lines`` so a
|
|
consumer that stops mid-iteration (``GeneratorExit``) does not skip
|
|
trailing lines in the chunk — the next drain cycle drains the queue first.
|
|
"""
|
|
with open(self._path, encoding="utf-8", errors="replace") as fh:
|
|
fh.seek(self._position)
|
|
while True:
|
|
while self._pending_lines:
|
|
yield self._pending_lines.popleft()
|
|
chunk = fh.read(self._read_chunk)
|
|
if not chunk:
|
|
break
|
|
chunk_end = fh.tell()
|
|
data = self._partial + chunk
|
|
parts = data.splitlines(keepends=True)
|
|
if not parts:
|
|
self._partial = ""
|
|
self._position = chunk_end
|
|
continue
|
|
if parts[-1].endswith(("\n", "\r")):
|
|
self._partial = ""
|
|
complete_parts = parts
|
|
else:
|
|
self._partial = parts[-1]
|
|
complete_parts = parts[:-1]
|
|
completes = [p.rstrip("\r\n") for p in complete_parts]
|
|
self._position = chunk_end
|
|
self._pending_lines.extend(completes)
|
|
|
|
def _maybe_log_reopen(self, fmt: str, *args: object) -> None:
|
|
# Throttle noisy reopen/error logs so a long-missing file does not
|
|
# flood structured-logging pipelines once per poll interval.
|
|
now = time.monotonic()
|
|
if now - self._last_reopen_log < _REOPEN_LOG_THROTTLE_S:
|
|
return
|
|
self._last_reopen_log = now
|
|
logger.warning(fmt, *args)
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_POLL_INTERVAL_S",
|
|
"DEFAULT_READ_CHUNK",
|
|
"FileTailer",
|
|
]
|