File size: 10,493 Bytes
d656a30 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | """Ship the server's logs to a private Hugging Face dataset (6.5b step 4, docs/SERVING.md "Privacy").
The free Space wipes its disk on restart, so the request logs go to a private dataset:
- `ShipHandler` writes a filtered copy of the `tinyllm` log as JSON lines, one file per server start
per UTC hour: `<dir>/<YYYY-MM-DD>/<start>-<replica>-<HH>.jsonl`. Files are never shared between
restarts, so an upload only ever touches the files of this run (in practice the current hour's).
- Shipped: `request` and `slow request` lines, engine snapshots thinned to one per 30 s (the status
page keeps the 5 s ones in memory), the lifecycle lines (`model loaded`, `server ready`,
`shutting down: draining`, `stopped`) and errors without their traceback. Never debug lines.
No prompt or completion text and no raw IP reach the log at all (tests/test_logs.py checks it).
- `LogShipper` uploads the folder with `huggingface_hub.CommitScheduler` every 10 minutes from a
background thread, plus once more during the graceful drain. Upload failures are logged to stderr
and never touch serving. A hard kill loses at most the last 10 minutes.
- Retention: once a day (first at start) the uploader deletes dataset files older than 30 days, and
squashes the dataset history when its oldest commit is older than 30 days, so the deleted files
stop using storage.
"""
from __future__ import annotations
import datetime as dt
import logging
import os
import re
import shutil
import threading
import time
from contextlib import nullcontext
from pathlib import Path
from typing import Callable
from tinyllm.obs import JsonFormatter
log = logging.getLogger("tinyllm.logship")
SHIPPED = frozenset({"model loaded", "server ready", "shutting down: draining", "stopped",
"request", "slow request"})
SNAPSHOT = "engine"
KEEP_DAYS = 30
UPLOAD_EVERY_MIN = 10.0
SNAPSHOT_EVERY_S = 30.0
PATH_IN_REPO = "logs"
_DAY = re.compile(r"\d{4}-\d{2}-\d{2}")
def start_id(t: float) -> str:
"""The server start in a file name, UTC: 20261107T131500Z."""
return time.strftime("%Y%m%dT%H%M%SZ", time.gmtime(t))
def default_replica() -> str:
"""The Space name on HF (`SPACE_ID` is owner/name), else "local"; safe for a file name."""
name = os.environ.get("SPACE_ID", "local").split("/")[-1]
return re.sub(r"[^A-Za-z0-9_.-]", "-", name) or "local"
def log_path(root: Path, start: str, replica: str, t: float) -> Path:
g = time.gmtime(t)
return root / time.strftime("%Y-%m-%d", g) / f"{start}-{replica}-{g.tm_hour:02d}.jsonl"
class ShipHandler(logging.Handler):
"""Appends the shipped lines to the hourly file. `lock` guards each write against the uploader
reading the file at the same moment (CommitScheduler caps the upload at the size it saw)."""
def __init__(self, root: Path, start: str, replica: str, snapshot_every_s: float = SNAPSHOT_EVERY_S,
lock: Callable[[], object] | None = None):
super().__init__(level=logging.INFO)
self.root, self.start, self.replica = Path(root), start, replica
self.snapshot_every_s = snapshot_every_s
self._io_lock = lock or nullcontext
self._last_snapshot: dict[str, float] = {}
self.setFormatter(JsonFormatter(with_exc=False))
def keep(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
if msg == SNAPSHOT and record.levelno < logging.WARNING:
variant = str((getattr(record, "fields", None) or {}).get("variant"))
last = self._last_snapshot.get(variant)
if last is not None and record.created - last < 0.95 * self.snapshot_every_s:
return False
self._last_snapshot[variant] = record.created
return True
return msg in SHIPPED or record.levelno >= logging.ERROR
def emit(self, record: logging.LogRecord) -> None:
try:
if not self.keep(record):
return
line = self.format(record) + "\n"
path = log_path(self.root, self.start, self.replica, record.created)
with self._io_lock():
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(line)
except Exception:
self.handleError(record)
def _day(name: str) -> dt.date | None:
m = _DAY.fullmatch(name)
try:
return dt.date.fromisoformat(m.group()) if m else None
except ValueError:
return None
def apply_retention(api, repo_id: str, root: Path | None, now: float, keep_days: int = KEEP_DAYS) -> dict:
"""Delete log files older than `keep_days` (by their UTC date folder) from the dataset, in one
commit, and from `root`; squash the dataset history once its oldest commit is older than
`keep_days`. `api` is an `HfApi` (a fake in the tests). Returns what was done."""
from huggingface_hub import CommitOperationDelete
cutoff = dt.datetime.fromtimestamp(now, dt.timezone.utc).date() - dt.timedelta(days=keep_days)
old = []
for f in api.list_repo_files(repo_id, repo_type="dataset"):
parts = f.split("/")
if len(parts) >= 3 and parts[0] == PATH_IN_REPO and (d := _day(parts[1])) is not None and d < cutoff:
old.append(f)
if old:
api.create_commit(repo_id, repo_type="dataset", operations=[CommitOperationDelete(f) for f in old],
commit_message=f"Delete logs older than {keep_days} days")
commits = api.list_repo_commits(repo_id, repo_type="dataset")
squash = len(commits) > 1 and commits[-1].created_at.timestamp() < now - keep_days * 86400
if squash:
api.super_squash_history(repo_id, repo_type="dataset",
commit_message=f"Squash history (logs kept {keep_days} days)")
local = 0
if root is not None and root.is_dir():
for d in root.iterdir():
if d.is_dir() and (day := _day(d.name)) is not None and day < cutoff:
shutil.rmtree(d, ignore_errors=True)
local += 1
return {"deleted": len(old), "squashed": squash, "local_dirs_deleted": local}
def _scheduler_class():
"""CommitScheduler that runs the retention once a day before an upload. Uploads, deletes and the
squash all run on the scheduler's one worker thread, so they never overlap."""
from huggingface_hub import CommitScheduler
class RetentionScheduler(CommitScheduler):
def __init__(self, *a, keep_days: int = KEEP_DAYS, clock: Callable[[], float] = time.time, **kw):
self.keep_days, self.clock, self.last_retention = keep_days, clock, None
super().__init__(*a, **kw)
def push_to_hub(self):
now = self.clock()
if self.last_retention is None or now - self.last_retention >= 86400:
self.last_retention = now
try:
done = apply_retention(self.api, self.repo_id, self.folder_path, now, self.keep_days)
log.info("log retention", extra={"fields": {"dataset": self.repo_id, **done}})
except Exception as e:
log.warning("log retention failed", extra={"fields": {"error": f"{type(e).__name__}: {e}"}})
return super().push_to_hub()
return RetentionScheduler
class LogShipper:
"""Local hourly JSONL under `log_dir`; with `dataset`, uploads it to that private HF dataset.
The token is the usual one: `HF_TOKEN` (a Space secret) or the local `hf auth login`."""
def __init__(self, log_dir: str | Path, dataset: str | None = None, replica: str | None = None,
every_min: float = UPLOAD_EVERY_MIN, keep_days: int = KEEP_DAYS,
snapshot_every_s: float = SNAPSHOT_EVERY_S, start: float | None = None,
scheduler_factory: Callable | None = None):
self.root = Path(log_dir)
self.root.mkdir(parents=True, exist_ok=True)
self.dataset, self.every_min, self.keep_days = dataset, every_min, keep_days
self.start = start_id(time.time() if start is None else start)
self.replica = replica or default_replica()
self.scheduler = None
self._factory = scheduler_factory
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self.handler = ShipHandler(self.root, self.start, self.replica, snapshot_every_s, self._io_lock)
def _io_lock(self):
s = self.scheduler
return s.lock if s is not None else self._lock
def attach(self, logger: logging.Logger | None = None) -> "LogShipper":
(logger or logging.getLogger("tinyllm")).addHandler(self.handler)
return self
def start_upload(self) -> None:
"""Open the dataset in the background (the first upload and the retention run right away)."""
if self.dataset and self._thread is None:
self._thread = threading.Thread(target=self._connect, name="log-upload", daemon=True)
self._thread.start()
def _connect(self) -> None:
factory = self._factory or _scheduler_class()
try:
self.scheduler = factory(repo_id=self.dataset, repo_type="dataset", private=True,
folder_path=self.root, path_in_repo=PATH_IN_REPO,
every=self.every_min, keep_days=self.keep_days)
log.info("log upload on", extra={"fields": {"dataset": self.dataset, "every_min": self.every_min}})
except Exception as e: # no token, no access, no network: keep the logs local
log.warning("log upload off: logs stay local", extra={"fields": {
"dataset": self.dataset, "dir": str(self.root), "error": f"{type(e).__name__}: {e}"}})
def close(self, timeout_s: float = 30.0) -> None:
"""Last upload (during the graceful drain), then stop the scheduler. Never raises."""
if self._thread is not None:
self._thread.join(timeout_s)
s = self.scheduler
if s is None:
return
try:
s.trigger().result(timeout=timeout_s)
log.info("logs uploaded", extra={"fields": {"dataset": self.dataset}})
except Exception as e:
log.warning("last log upload failed", extra={"fields": {"error": f"{type(e).__name__}: {e}"}})
finally:
s.stop()
logging.getLogger("tinyllm").removeHandler(self.handler)
|