From f302ed108b32c02dea6c659c5557e9e1aa753fc8 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 25 Jun 2026 12:59:34 +0100 Subject: [PATCH 1/2] feat(logging): server-side client log + crash capture (#106) A PWA has no readable console, so a front-end crash is invisible to the user and to us. Add a backend sink: POST /api/client-logs (authenticated) records a browser-side error/warn/info/debug/fatal line; GET /api/client-logs (admin) returns the most recent entries, optionally filtered by level. ClientLogStore is bounded (ring-buffer prune to the most recent rows; message/stack length-capped). This is the substrate for chasing crashes like the Messages app failure; the front-end error-boundary wiring is a follow-up. --- tests/conftest.py | 5 ++ tests/test_client_log_store.py | 59 ++++++++++++++ tests/test_routes_client_logs.py | 61 +++++++++++++++ tinyagentos/app.py | 6 ++ tinyagentos/client_log_store.py | 123 ++++++++++++++++++++++++++++++ tinyagentos/routes/__init__.py | 3 + tinyagentos/routes/client_logs.py | 69 +++++++++++++++++ 7 files changed, 326 insertions(+) create mode 100644 tests/test_client_log_store.py create mode 100644 tests/test_routes_client_logs.py create mode 100644 tinyagentos/client_log_store.py create mode 100644 tinyagentos/routes/client_logs.py diff --git a/tests/conftest.py b/tests/conftest.py index ac7831fb..7c35decb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -337,6 +337,10 @@ async def client(app, tmp_data_dir): if feedback_store._db is not None: await feedback_store.close() await feedback_store.init() + client_log_store = app.state.client_log_store + if client_log_store._db is not None: + await client_log_store.close() + await client_log_store.init() # BrowserApp v2 stores from tinyagentos.routes.desktop_browser.store import BrowserStore, BrowserCookieStore _browser_store = BrowserStore(tmp_data_dir / "browser.sqlite3") @@ -391,6 +395,7 @@ async def client(app, tmp_data_dir): await store.close() await office_docs.close() await feedback_store.close() + await client_log_store.close() await app.state.qmd_client.close() await app.state.http_client.aclose() await _browser_store.close() diff --git a/tests/test_client_log_store.py b/tests/test_client_log_store.py new file mode 100644 index 00000000..bee2c81d --- /dev/null +++ b/tests/test_client_log_store.py @@ -0,0 +1,59 @@ +import pytest +import pytest_asyncio + +from tinyagentos.client_log_store import ClientLogStore + + +@pytest_asyncio.fixture +async def store(tmp_path): + s = ClientLogStore(tmp_path / "client_logs.db") + await s.init() + yield s + await s.close() + + +@pytest.mark.asyncio +async def test_create_and_list(store): + await store.create( + user_id="u1", level="error", message="boom", + source="MessagesApp", url="/desktop", stack="at foo", + ) + items = await store.list_recent() + assert len(items) == 1 + assert items[0]["level"] == "error" + assert items[0]["message"] == "boom" + assert items[0]["source"] == "MessagesApp" + assert items[0]["url"] == "/desktop" + + +@pytest.mark.asyncio +async def test_list_filters_by_level(store): + await store.create(user_id="u1", level="error", message="e") + await store.create(user_id="u1", level="info", message="i") + errs = await store.list_recent(level="error") + assert len(errs) == 1 + assert errs[0]["level"] == "error" + + +@pytest.mark.asyncio +async def test_long_message_and_stack_are_truncated(store): + await store.create( + user_id="u1", level="error", + message="x" * 5000, stack="y" * 20000, + ) + item = (await store.list_recent())[0] + assert len(item["message"]) == 4000 + assert len(item["stack"]) == 16000 + + +@pytest.mark.asyncio +async def test_ring_buffer_caps_total_rows(store, monkeypatch): + monkeypatch.setattr("tinyagentos.client_log_store.MAX_ROWS", 3) + for i in range(6): + await store.create(user_id="u1", level="debug", message=f"m{i}") + items = await store.list_recent(limit=100) + assert len(items) == 3 + # The newest entry is retained; the oldest are pruned. + msgs = [i["message"] for i in items] + assert "m5" in msgs + assert "m0" not in msgs diff --git a/tests/test_routes_client_logs.py b/tests/test_routes_client_logs.py new file mode 100644 index 00000000..9a32f018 --- /dev/null +++ b/tests/test_routes_client_logs.py @@ -0,0 +1,61 @@ +"""Route tests for the client-log capture API.""" +import pytest + +from tinyagentos.auth_context import CurrentUser, current_user + + +@pytest.mark.asyncio +async def test_post_client_log_returns_201(client): + resp = await client.post( + "/api/client-logs", + json={"level": "error", "message": "boom", "source": "MessagesApp", + "url": "/desktop", "stack": "at render"}, + ) + assert resp.status_code == 201 + d = resp.json() + assert d["level"] == "error" + assert d["message"] == "boom" + assert "id" in d and "created_at" in d + + +@pytest.mark.asyncio +async def test_get_lists_posted_log(client): + await client.post("/api/client-logs", json={"level": "warn", "message": "hmm"}) + resp = await client.get("/api/client-logs") + assert resp.status_code == 200 + msgs = [i["message"] for i in resp.json()["items"]] + assert "hmm" in msgs + + +@pytest.mark.asyncio +async def test_get_filters_by_level(client): + await client.post("/api/client-logs", json={"level": "error", "message": "E"}) + await client.post("/api/client-logs", json={"level": "info", "message": "I"}) + resp = await client.get("/api/client-logs?level=error") + items = resp.json()["items"] + assert items and all(i["level"] == "error" for i in items) + assert "E" in [i["message"] for i in items] + + +@pytest.mark.asyncio +async def test_invalid_level_rejected(client): + resp = await client.post("/api/client-logs", json={"level": "bogus", "message": "x"}) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_empty_message_rejected(client): + resp = await client.post("/api/client-logs", json={"level": "error", "message": " "}) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_non_admin_can_post_but_not_read(app, client): + app.dependency_overrides[current_user] = lambda: CurrentUser(user_id="bob", is_admin=False) + try: + post = await client.post("/api/client-logs", json={"level": "error", "message": "bob-err"}) + assert post.status_code == 201 + resp = await client.get("/api/client-logs") + assert resp.status_code == 403 + finally: + app.dependency_overrides.pop(current_user, None) diff --git a/tinyagentos/app.py b/tinyagentos/app.py index d3d622b3..c6676b1d 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -90,6 +90,7 @@ async def get_response(self, path, scope): from tinyagentos.chat.canvas import CanvasStore from tinyagentos.desktop_settings import DesktopSettingsStore from tinyagentos.feedback_store import FeedbackStore +from tinyagentos.client_log_store import ClientLogStore from tinyagentos.user_memory import UserMemoryStore from tinyagentos.user_personas import UserPersonaStore from tinyagentos.installed_apps import InstalledAppsStore @@ -380,6 +381,7 @@ async def _probe_backend(backend: dict) -> dict: user_personas = UserPersonaStore(data_dir / "user_personas.db") installed_apps = InstalledAppsStore(data_dir / "installed_apps.db") feedback_store = FeedbackStore(data_dir / "feedback.db") + client_log_store = ClientLogStore(data_dir / "client_logs.db") from tinyagentos.userspace.store import UserspaceAppStore from tinyagentos.userspace.data_store import UserspaceDataStore userspace_apps = UserspaceAppStore(data_dir / "userspace_apps.db") @@ -484,6 +486,8 @@ async def lifespan(app: FastAPI): await installed_apps.init() await feedback_store.init() app.state.feedback_store = feedback_store + await client_log_store.init() + app.state.client_log_store = client_log_store await userspace_apps.init() app.state.userspace_apps = userspace_apps await userspace_data.init() @@ -1204,6 +1208,7 @@ async def _reload_llm_proxy_on_catalog_change() -> None: await archive.close() await installed_apps.close() await feedback_store.close() + await client_log_store.close() await userspace_apps.close() await userspace_data.close() await office_docs.close() @@ -1389,6 +1394,7 @@ async def dispatch(self, request, call_next): app.state.user_personas = user_personas app.state.installed_apps = installed_apps app.state.feedback_store = feedback_store + app.state.client_log_store = client_log_store app.state.userspace_apps = userspace_apps app.state.userspace_data = userspace_data app.state.office_docs = office_docs diff --git a/tinyagentos/client_log_store.py b/tinyagentos/client_log_store.py new file mode 100644 index 00000000..598577c9 --- /dev/null +++ b/tinyagentos/client_log_store.py @@ -0,0 +1,123 @@ +"""Server-side capture of client (browser/PWA) logs and crashes. + +In a PWA there is no devtools console for the user (or us) to read, so a +front-end crash is invisible unless the client ships it somewhere. This store is +that sink: the desktop posts errors, warnings, and debug lines to +POST /api/client-logs and they land here, readable by an admin via +GET /api/client-logs. It is the substrate for chasing crashes like the Messages +app failure (#106 log capture). + +Bounded by design: a crash loop must not grow the table without limit, so every +insert prunes to the most recent MAX_ROWS rows (a ring buffer), and message/stack +text is length-capped. +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from tinyagentos.base_store import BaseStore + +# The levels a client may report. Mirrors console severities plus an explicit +# "fatal" for an uncaught error / error-boundary crash. +VALID_LEVELS = frozenset({"fatal", "error", "warn", "info", "debug"}) + +MAX_MESSAGE_LEN = 4_000 +MAX_STACK_LEN = 16_000 +MAX_SOURCE_LEN = 200 +MAX_URL_LEN = 1_000 +MAX_UA_LEN = 500 +# Ring-buffer cap: keep only the most recent N entries across all users so a +# crash loop posting on every render cannot grow the DB unbounded. +MAX_ROWS = 2_000 + + +class ClientLogStore(BaseStore): + SCHEMA = """ + CREATE TABLE IF NOT EXISTS client_logs ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + stack TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS client_logs_created + ON client_logs (created_at DESC); + CREATE INDEX IF NOT EXISTS client_logs_level_created + ON client_logs (level, created_at DESC); + """ + + async def create( + self, + *, + user_id: str, + level: str, + message: str, + source: str = "", + url: str = "", + stack: str = "", + user_agent: str = "", + ) -> dict: + assert self._db is not None + item_id = str(uuid.uuid4()) + created_at = datetime.now(timezone.utc).isoformat() + row = { + "id": item_id, + "user_id": user_id, + "level": level, + "message": message[:MAX_MESSAGE_LEN], + "source": source[:MAX_SOURCE_LEN], + "url": url[:MAX_URL_LEN], + "stack": stack[:MAX_STACK_LEN], + "user_agent": user_agent[:MAX_UA_LEN], + "created_at": created_at, + } + await self._db.execute( + """ + INSERT INTO client_logs + (id, user_id, level, message, source, url, stack, user_agent, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row["id"], row["user_id"], row["level"], row["message"], + row["source"], row["url"], row["stack"], row["user_agent"], + row["created_at"], + ), + ) + # Ring-buffer prune: drop everything older than the newest MAX_ROWS. + await self._db.execute( + """ + DELETE FROM client_logs WHERE id NOT IN ( + SELECT id FROM client_logs ORDER BY created_at DESC LIMIT ? + ) + """, + (MAX_ROWS,), + ) + await self._db.commit() + return row + + async def list_recent( + self, *, level: str | None = None, limit: int = 200 + ) -> list[dict]: + """Most recent entries first, optionally filtered by level. Admin-read.""" + assert self._db is not None + limit = max(1, min(limit, 1000)) + cols = "id, user_id, level, message, source, url, stack, user_agent, created_at" + if level: + cursor = await self._db.execute( + f"SELECT {cols} FROM client_logs WHERE level = ? " + "ORDER BY created_at DESC LIMIT ?", + (level, limit), + ) + else: + cursor = await self._db.execute( + f"SELECT {cols} FROM client_logs ORDER BY created_at DESC LIMIT ?", + (limit,), + ) + rows = await cursor.fetchall() + keys = cols.split(", ") + return [dict(zip(keys, r)) for r in rows] diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index 78edffc5..a8ac666b 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -314,6 +314,9 @@ def register_all_routers(app): from tinyagentos.routes.feedback import router as feedback_router app.include_router(feedback_router) + from tinyagentos.routes.client_logs import router as client_logs_router + app.include_router(client_logs_router) + from tinyagentos.routes.account_proxy import router as account_proxy_router app.include_router(account_proxy_router) diff --git a/tinyagentos/routes/client_logs.py b/tinyagentos/routes/client_logs.py new file mode 100644 index 00000000..505b1c54 --- /dev/null +++ b/tinyagentos/routes/client_logs.py @@ -0,0 +1,69 @@ +"""Client log capture API (#106 log capture). + +POST /api/client-logs lets the desktop ship a browser-side error/warning/debug +line to the controller (where the PWA has no readable console). GET is admin-only +and returns the most recent entries so a crash can be chased server-side. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from tinyagentos.auth_context import CurrentUser, current_user +from tinyagentos.client_log_store import VALID_LEVELS + +router = APIRouter() + + +class ClientLogIn(BaseModel): + level: str + message: str + source: str = "" + url: str = "" + stack: str = "" + + +@router.post("/api/client-logs", status_code=201) +async def post_client_log( + body: ClientLogIn, request: Request, user: CurrentUser = Depends(current_user) +): + """Record one client-side log line for the calling user.""" + level = body.level.strip().lower() + if level not in VALID_LEVELS: + return JSONResponse( + {"error": f"level must be one of {sorted(VALID_LEVELS)}"}, status_code=400 + ) + message = body.message.strip() + if not message: + return JSONResponse({"error": "message required"}, status_code=400) + store = request.app.state.client_log_store + rec = await store.create( + user_id=user.user_id, + level=level, + message=message, + source=body.source, + url=body.url, + stack=body.stack, + user_agent=request.headers.get("user-agent", ""), + ) + return rec + + +@router.get("/api/client-logs") +async def list_client_logs( + request: Request, + level: str | None = None, + limit: int = 200, + user: CurrentUser = Depends(current_user), +): + """The most recent client logs, newest first. Admin only (logs may carry + stack traces and URLs from any user's session).""" + if not user.is_admin: + return JSONResponse({"error": "forbidden"}, status_code=403) + lvl = level.strip().lower() if level else None + if lvl and lvl not in VALID_LEVELS: + return JSONResponse({"error": "invalid level"}, status_code=400) + store = request.app.state.client_log_store + items = await store.list_recent(level=lvl, limit=limit) + return {"items": items} From b2dc838be4f93dff1398d9354952c0b46a5219d4 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Thu, 25 Jun 2026 13:07:30 +0100 Subject: [PATCH 2/2] fix(logging): rate-limit client-log POST per user (Kilo finding) Without a cap, a crash loop or one user could flood the shared 2000-row ring buffer and evict everyone else's recent errors (a DoS against debuggability). Add a per-user token bucket (burst ~30, ~1/sec sustained); only valid writes are limited, so malformed requests still get a clean 400 and never touch the buffer. --- tests/test_routes_client_logs.py | 18 ++++++++++++++++++ tinyagentos/routes/client_logs.py | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/tests/test_routes_client_logs.py b/tests/test_routes_client_logs.py index 9a32f018..37e7eeea 100644 --- a/tests/test_routes_client_logs.py +++ b/tests/test_routes_client_logs.py @@ -49,6 +49,24 @@ async def test_empty_message_rejected(client): assert resp.status_code == 400 +@pytest.mark.asyncio +async def test_post_is_rate_limited_per_user(client, monkeypatch): + from tinyagentos.rate_limit import RateLimiter + + # Small, non-refilling bucket so a flood is deterministically capped: the + # 4th valid post past a capacity of 3 is rejected, protecting other users' + # entries in the shared ring buffer. + monkeypatch.setattr( + "tinyagentos.routes.client_logs._post_limiter", + RateLimiter(capacity=3, refill_per_second=0.0), + ) + for _ in range(3): + ok = await client.post("/api/client-logs", json={"level": "error", "message": "flood"}) + assert ok.status_code == 201 + blocked = await client.post("/api/client-logs", json={"level": "error", "message": "flood"}) + assert blocked.status_code == 429 + + @pytest.mark.asyncio async def test_non_admin_can_post_but_not_read(app, client): app.dependency_overrides[current_user] = lambda: CurrentUser(user_id="bob", is_admin=False) diff --git a/tinyagentos/routes/client_logs.py b/tinyagentos/routes/client_logs.py index 505b1c54..1d29a440 100644 --- a/tinyagentos/routes/client_logs.py +++ b/tinyagentos/routes/client_logs.py @@ -12,9 +12,15 @@ from tinyagentos.auth_context import CurrentUser, current_user from tinyagentos.client_log_store import VALID_LEVELS +from tinyagentos.rate_limit import RateLimiter router = APIRouter() +# Per-user token bucket: a crash loop (or one user) must not be able to flood the +# shared ring buffer and evict everyone else's recent errors. Allows a burst of +# ~30 lines (a noisy crash) then ~1/sec sustained. +_post_limiter = RateLimiter(capacity=30, refill_per_second=1.0) + class ClientLogIn(BaseModel): level: str @@ -37,6 +43,10 @@ async def post_client_log( message = body.message.strip() if not message: return JSONResponse({"error": "message required"}, status_code=400) + # Only valid writes are rate-limited (malformed requests already 400 above + # and never reach the buffer), so a flood of real logs cannot evict others'. + if not _post_limiter.check(user.user_id): + return JSONResponse({"error": "rate limited, slow down"}, status_code=429) store = request.app.state.client_log_store rec = await store.create( user_id=user.user_id,