firegex/backend/modules/services/logs.py

188 lines
7.7 KiB
Python

"""What a running service is actually doing, as it does it.
Everything interesting a datapath has to say — a connection refused and by which rule,
a filter that printed something, a filter that raised, a warning about falling back —
used to go to the backend's own stderr, where nobody looks during a competition. This
keeps a bounded tail of it per service and pushes new lines to the browser.
Two properties matter more than the feature itself:
**It cannot grow.** A service under attack blocks thousands of connections a minute and
a filter in a print loop produces output as fast as the traffic arrives. The ring is
capped and the oldest lines fall off; nothing here can turn logging into an outage.
**It cannot flood the browser.** Lines are coalesced and flushed on a timer rather than
emitted one by one, and a flush that would carry more than it should says how many it
dropped instead of carrying them.
The same flush is also what tells the browser that the **counters** moved. A block bumps
a filter's total, a pattern's, a function's and the bucketed history, and the interface
holds all of those with `staleTime: Infinity` — nothing refetches on its own, so without
a signal the numbers and the chart sit still while the log scrolls past underneath them.
Riding the flush that already exists beats a second timer, and it is exactly true: the
lines that carry a block are the events that change a counter. It is throttled harder
than the log, because invalidating every query of a service is a heavier thing to ask of
the browser than appending a line.
"""
import asyncio
import time
from collections import deque
import utils
#: Lines kept per service. Enough to see what just happened, small enough that a
#: hundred services cost a few megabytes between them.
HISTORY = 500
#: How long lines are allowed to pile up before the browser is told. Long enough that a
#: burst becomes one message, short enough to still read as live.
FLUSH_INTERVAL = 0.25
#: Most lines carried in a single flush. Beyond this the browser is told the count
#: rather than the content — a thousand identical block lines inform nobody.
FLUSH_LIMIT = 50
#: How often a service may tell the browser its counters are stale. Much slower than the
#: log's own flush: this one invalidates every query of the service — its filters, its
#: patterns, its functions, its statistics — and doing that four times a second under
#: attack would be its own kind of flood.
COUNTER_INTERVAL = 2.0
class LEVEL:
INFO = "info"
BLOCK = "block"
OUTPUT = "output"
WARN = "warn"
ERROR = "error"
class ServiceLog:
"""One service's rolling tail, and the pending batch on its way to the browser."""
def __init__(self, service_id: str):
self.service_id = service_id
self.history: deque[dict] = deque(maxlen=HISTORY)
self._pending: list[dict] = []
self._dropped = 0
self._flush_task: asyncio.Task | None = None
#: When the browser was last told that this service's counters had moved, and
#: the emit waiting for the current window to close.
self._counters_told = 0.0
self._counters_task: asyncio.Task | None = None
#: Counts every line ever added, so the frontend can tell "nothing new" from
#: "the tail happens to look the same".
self.seq = 0
def add(self, level: str, text: str) -> None:
for line in text.splitlines() or [""]:
line = line.rstrip()
if not line:
continue
self.seq += 1
entry = {"at": int(time.time() * 1000), "level": level, "text": line, "seq": self.seq}
self.history.append(entry)
if len(self._pending) < FLUSH_LIMIT:
self._pending.append(entry)
else:
self._dropped += 1
self._schedule()
def _schedule(self) -> None:
if self._flush_task and not self._flush_task.done():
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return # no loop to emit on; the history still has the lines
self._flush_task = loop.create_task(self._flush_later())
async def _flush_later(self) -> None:
await asyncio.sleep(FLUSH_INTERVAL)
entries, dropped = self._pending, self._dropped
self._pending, self._dropped = [], 0
if not entries:
return
if dropped:
entries.append({
"at": int(time.time() * 1000),
"level": LEVEL.WARN,
"text": f"... and {dropped} more line(s) in the same instant",
"seq": self.seq,
})
# Reached through the module rather than imported by value: `utils.socketio` is
# assigned when the app starts, so a direct import would capture the `None` it
# holds at import time and silently never emit anything.
try:
if utils.socketio is not None:
await utils.socketio.emit(
"log", {"service_id": self.service_id, "entries": entries}
)
await self._tell_counters_moved(entries)
except Exception:
pass # a browser that is not listening is not a reason to disturb the datapath
async def _tell_counters_moved(self, entries: list[dict]) -> None:
"""Say that the totals and the chart are out of date, at most every so often.
Only for blocks: they are the lines that come with a counter having changed. A
filter's `print()` or a warning moves nothing, and invalidating every query of
the service for one would be work done for no reason.
Rate-limited with a **trailing** emit rather than by dropping what falls inside
the window. Dropping was the first version and it left the interface frozen on
stale numbers: a burst emitted once at its start, every flush after that was
discarded, and when the traffic stopped there was no further flush to carry the
final count — so the chart sat at whatever it happened to reach two seconds in.
The last block of a burst is precisely the one worth hearing about.
"""
if not any(entry["level"] == LEVEL.BLOCK for entry in entries):
return
waited = time.monotonic() - self._counters_told
if waited >= COUNTER_INTERVAL:
await self._emit_counters()
return
# Inside the window: one emit is scheduled for when it closes, and further
# blocks arriving before then fold into it.
if self._counters_task and not self._counters_task.done():
return
self._counters_task = asyncio.get_running_loop().create_task(
self._emit_counters_after(COUNTER_INTERVAL - waited)
)
async def _emit_counters_after(self, delay: float) -> None:
await asyncio.sleep(delay)
try:
await self._emit_counters()
except Exception:
pass # a browser that is not listening is not a reason to raise here
async def _emit_counters(self) -> None:
self._counters_told = time.monotonic()
if utils.socketio is not None:
await utils.socketio_emit(["services"])
def entries(self) -> list[dict]:
return list(self.history)
def clear(self) -> None:
self.history.clear()
self._pending.clear()
self._dropped = 0
_logs: dict[str, ServiceLog] = {}
def log_for(service_id: str) -> ServiceLog:
log = _logs.get(service_id)
if log is None:
log = _logs[service_id] = ServiceLog(service_id)
return log
def forget(service_id: str) -> None:
"""Drop a deleted service's tail, so nothing outlives what it was about."""
_logs.pop(service_id, None)