Skip to content

Commit a0e44f1

Browse files
authored
feat(logging): server-side client log + crash capture (#106) (#1436)
* 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. * 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.
1 parent 2a6515d commit a0e44f1

7 files changed

Lines changed: 354 additions & 0 deletions

File tree

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,10 @@ async def client(app, tmp_data_dir):
337337
if feedback_store._db is not None:
338338
await feedback_store.close()
339339
await feedback_store.init()
340+
client_log_store = app.state.client_log_store
341+
if client_log_store._db is not None:
342+
await client_log_store.close()
343+
await client_log_store.init()
340344
# BrowserApp v2 stores
341345
from tinyagentos.routes.desktop_browser.store import BrowserStore, BrowserCookieStore
342346
_browser_store = BrowserStore(tmp_data_dir / "browser.sqlite3")
@@ -391,6 +395,7 @@ async def client(app, tmp_data_dir):
391395
await store.close()
392396
await office_docs.close()
393397
await feedback_store.close()
398+
await client_log_store.close()
394399
await app.state.qmd_client.close()
395400
await app.state.http_client.aclose()
396401
await _browser_store.close()

tests/test_client_log_store.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import pytest
2+
import pytest_asyncio
3+
4+
from tinyagentos.client_log_store import ClientLogStore
5+
6+
7+
@pytest_asyncio.fixture
8+
async def store(tmp_path):
9+
s = ClientLogStore(tmp_path / "client_logs.db")
10+
await s.init()
11+
yield s
12+
await s.close()
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_create_and_list(store):
17+
await store.create(
18+
user_id="u1", level="error", message="boom",
19+
source="MessagesApp", url="/desktop", stack="at foo",
20+
)
21+
items = await store.list_recent()
22+
assert len(items) == 1
23+
assert items[0]["level"] == "error"
24+
assert items[0]["message"] == "boom"
25+
assert items[0]["source"] == "MessagesApp"
26+
assert items[0]["url"] == "/desktop"
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_list_filters_by_level(store):
31+
await store.create(user_id="u1", level="error", message="e")
32+
await store.create(user_id="u1", level="info", message="i")
33+
errs = await store.list_recent(level="error")
34+
assert len(errs) == 1
35+
assert errs[0]["level"] == "error"
36+
37+
38+
@pytest.mark.asyncio
39+
async def test_long_message_and_stack_are_truncated(store):
40+
await store.create(
41+
user_id="u1", level="error",
42+
message="x" * 5000, stack="y" * 20000,
43+
)
44+
item = (await store.list_recent())[0]
45+
assert len(item["message"]) == 4000
46+
assert len(item["stack"]) == 16000
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_ring_buffer_caps_total_rows(store, monkeypatch):
51+
monkeypatch.setattr("tinyagentos.client_log_store.MAX_ROWS", 3)
52+
for i in range(6):
53+
await store.create(user_id="u1", level="debug", message=f"m{i}")
54+
items = await store.list_recent(limit=100)
55+
assert len(items) == 3
56+
# The newest entry is retained; the oldest are pruned.
57+
msgs = [i["message"] for i in items]
58+
assert "m5" in msgs
59+
assert "m0" not in msgs

tests/test_routes_client_logs.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Route tests for the client-log capture API."""
2+
import pytest
3+
4+
from tinyagentos.auth_context import CurrentUser, current_user
5+
6+
7+
@pytest.mark.asyncio
8+
async def test_post_client_log_returns_201(client):
9+
resp = await client.post(
10+
"/api/client-logs",
11+
json={"level": "error", "message": "boom", "source": "MessagesApp",
12+
"url": "/desktop", "stack": "at render"},
13+
)
14+
assert resp.status_code == 201
15+
d = resp.json()
16+
assert d["level"] == "error"
17+
assert d["message"] == "boom"
18+
assert "id" in d and "created_at" in d
19+
20+
21+
@pytest.mark.asyncio
22+
async def test_get_lists_posted_log(client):
23+
await client.post("/api/client-logs", json={"level": "warn", "message": "hmm"})
24+
resp = await client.get("/api/client-logs")
25+
assert resp.status_code == 200
26+
msgs = [i["message"] for i in resp.json()["items"]]
27+
assert "hmm" in msgs
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_get_filters_by_level(client):
32+
await client.post("/api/client-logs", json={"level": "error", "message": "E"})
33+
await client.post("/api/client-logs", json={"level": "info", "message": "I"})
34+
resp = await client.get("/api/client-logs?level=error")
35+
items = resp.json()["items"]
36+
assert items and all(i["level"] == "error" for i in items)
37+
assert "E" in [i["message"] for i in items]
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_invalid_level_rejected(client):
42+
resp = await client.post("/api/client-logs", json={"level": "bogus", "message": "x"})
43+
assert resp.status_code == 400
44+
45+
46+
@pytest.mark.asyncio
47+
async def test_empty_message_rejected(client):
48+
resp = await client.post("/api/client-logs", json={"level": "error", "message": " "})
49+
assert resp.status_code == 400
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_post_is_rate_limited_per_user(client, monkeypatch):
54+
from tinyagentos.rate_limit import RateLimiter
55+
56+
# Small, non-refilling bucket so a flood is deterministically capped: the
57+
# 4th valid post past a capacity of 3 is rejected, protecting other users'
58+
# entries in the shared ring buffer.
59+
monkeypatch.setattr(
60+
"tinyagentos.routes.client_logs._post_limiter",
61+
RateLimiter(capacity=3, refill_per_second=0.0),
62+
)
63+
for _ in range(3):
64+
ok = await client.post("/api/client-logs", json={"level": "error", "message": "flood"})
65+
assert ok.status_code == 201
66+
blocked = await client.post("/api/client-logs", json={"level": "error", "message": "flood"})
67+
assert blocked.status_code == 429
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_non_admin_can_post_but_not_read(app, client):
72+
app.dependency_overrides[current_user] = lambda: CurrentUser(user_id="bob", is_admin=False)
73+
try:
74+
post = await client.post("/api/client-logs", json={"level": "error", "message": "bob-err"})
75+
assert post.status_code == 201
76+
resp = await client.get("/api/client-logs")
77+
assert resp.status_code == 403
78+
finally:
79+
app.dependency_overrides.pop(current_user, None)

tinyagentos/app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async def get_response(self, path, scope):
9090
from tinyagentos.chat.canvas import CanvasStore
9191
from tinyagentos.desktop_settings import DesktopSettingsStore
9292
from tinyagentos.feedback_store import FeedbackStore
93+
from tinyagentos.client_log_store import ClientLogStore
9394
from tinyagentos.user_memory import UserMemoryStore
9495
from tinyagentos.user_personas import UserPersonaStore
9596
from tinyagentos.installed_apps import InstalledAppsStore
@@ -380,6 +381,7 @@ async def _probe_backend(backend: dict) -> dict:
380381
user_personas = UserPersonaStore(data_dir / "user_personas.db")
381382
installed_apps = InstalledAppsStore(data_dir / "installed_apps.db")
382383
feedback_store = FeedbackStore(data_dir / "feedback.db")
384+
client_log_store = ClientLogStore(data_dir / "client_logs.db")
383385
from tinyagentos.userspace.store import UserspaceAppStore
384386
from tinyagentos.userspace.data_store import UserspaceDataStore
385387
userspace_apps = UserspaceAppStore(data_dir / "userspace_apps.db")
@@ -484,6 +486,8 @@ async def lifespan(app: FastAPI):
484486
await installed_apps.init()
485487
await feedback_store.init()
486488
app.state.feedback_store = feedback_store
489+
await client_log_store.init()
490+
app.state.client_log_store = client_log_store
487491
await userspace_apps.init()
488492
app.state.userspace_apps = userspace_apps
489493
await userspace_data.init()
@@ -1204,6 +1208,7 @@ async def _reload_llm_proxy_on_catalog_change() -> None:
12041208
await archive.close()
12051209
await installed_apps.close()
12061210
await feedback_store.close()
1211+
await client_log_store.close()
12071212
await userspace_apps.close()
12081213
await userspace_data.close()
12091214
await office_docs.close()
@@ -1389,6 +1394,7 @@ async def dispatch(self, request, call_next):
13891394
app.state.user_personas = user_personas
13901395
app.state.installed_apps = installed_apps
13911396
app.state.feedback_store = feedback_store
1397+
app.state.client_log_store = client_log_store
13921398
app.state.userspace_apps = userspace_apps
13931399
app.state.userspace_data = userspace_data
13941400
app.state.office_docs = office_docs

tinyagentos/client_log_store.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Server-side capture of client (browser/PWA) logs and crashes.
2+
3+
In a PWA there is no devtools console for the user (or us) to read, so a
4+
front-end crash is invisible unless the client ships it somewhere. This store is
5+
that sink: the desktop posts errors, warnings, and debug lines to
6+
POST /api/client-logs and they land here, readable by an admin via
7+
GET /api/client-logs. It is the substrate for chasing crashes like the Messages
8+
app failure (#106 log capture).
9+
10+
Bounded by design: a crash loop must not grow the table without limit, so every
11+
insert prunes to the most recent MAX_ROWS rows (a ring buffer), and message/stack
12+
text is length-capped.
13+
"""
14+
from __future__ import annotations
15+
16+
import uuid
17+
from datetime import datetime, timezone
18+
19+
from tinyagentos.base_store import BaseStore
20+
21+
# The levels a client may report. Mirrors console severities plus an explicit
22+
# "fatal" for an uncaught error / error-boundary crash.
23+
VALID_LEVELS = frozenset({"fatal", "error", "warn", "info", "debug"})
24+
25+
MAX_MESSAGE_LEN = 4_000
26+
MAX_STACK_LEN = 16_000
27+
MAX_SOURCE_LEN = 200
28+
MAX_URL_LEN = 1_000
29+
MAX_UA_LEN = 500
30+
# Ring-buffer cap: keep only the most recent N entries across all users so a
31+
# crash loop posting on every render cannot grow the DB unbounded.
32+
MAX_ROWS = 2_000
33+
34+
35+
class ClientLogStore(BaseStore):
36+
SCHEMA = """
37+
CREATE TABLE IF NOT EXISTS client_logs (
38+
id TEXT NOT NULL PRIMARY KEY,
39+
user_id TEXT NOT NULL,
40+
level TEXT NOT NULL,
41+
message TEXT NOT NULL,
42+
source TEXT NOT NULL DEFAULT '',
43+
url TEXT NOT NULL DEFAULT '',
44+
stack TEXT NOT NULL DEFAULT '',
45+
user_agent TEXT NOT NULL DEFAULT '',
46+
created_at TEXT NOT NULL
47+
);
48+
CREATE INDEX IF NOT EXISTS client_logs_created
49+
ON client_logs (created_at DESC);
50+
CREATE INDEX IF NOT EXISTS client_logs_level_created
51+
ON client_logs (level, created_at DESC);
52+
"""
53+
54+
async def create(
55+
self,
56+
*,
57+
user_id: str,
58+
level: str,
59+
message: str,
60+
source: str = "",
61+
url: str = "",
62+
stack: str = "",
63+
user_agent: str = "",
64+
) -> dict:
65+
assert self._db is not None
66+
item_id = str(uuid.uuid4())
67+
created_at = datetime.now(timezone.utc).isoformat()
68+
row = {
69+
"id": item_id,
70+
"user_id": user_id,
71+
"level": level,
72+
"message": message[:MAX_MESSAGE_LEN],
73+
"source": source[:MAX_SOURCE_LEN],
74+
"url": url[:MAX_URL_LEN],
75+
"stack": stack[:MAX_STACK_LEN],
76+
"user_agent": user_agent[:MAX_UA_LEN],
77+
"created_at": created_at,
78+
}
79+
await self._db.execute(
80+
"""
81+
INSERT INTO client_logs
82+
(id, user_id, level, message, source, url, stack, user_agent, created_at)
83+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
84+
""",
85+
(
86+
row["id"], row["user_id"], row["level"], row["message"],
87+
row["source"], row["url"], row["stack"], row["user_agent"],
88+
row["created_at"],
89+
),
90+
)
91+
# Ring-buffer prune: drop everything older than the newest MAX_ROWS.
92+
await self._db.execute(
93+
"""
94+
DELETE FROM client_logs WHERE id NOT IN (
95+
SELECT id FROM client_logs ORDER BY created_at DESC LIMIT ?
96+
)
97+
""",
98+
(MAX_ROWS,),
99+
)
100+
await self._db.commit()
101+
return row
102+
103+
async def list_recent(
104+
self, *, level: str | None = None, limit: int = 200
105+
) -> list[dict]:
106+
"""Most recent entries first, optionally filtered by level. Admin-read."""
107+
assert self._db is not None
108+
limit = max(1, min(limit, 1000))
109+
cols = "id, user_id, level, message, source, url, stack, user_agent, created_at"
110+
if level:
111+
cursor = await self._db.execute(
112+
f"SELECT {cols} FROM client_logs WHERE level = ? "
113+
"ORDER BY created_at DESC LIMIT ?",
114+
(level, limit),
115+
)
116+
else:
117+
cursor = await self._db.execute(
118+
f"SELECT {cols} FROM client_logs ORDER BY created_at DESC LIMIT ?",
119+
(limit,),
120+
)
121+
rows = await cursor.fetchall()
122+
keys = cols.split(", ")
123+
return [dict(zip(keys, r)) for r in rows]

tinyagentos/routes/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@ def register_all_routers(app):
314314
from tinyagentos.routes.feedback import router as feedback_router
315315
app.include_router(feedback_router)
316316

317+
from tinyagentos.routes.client_logs import router as client_logs_router
318+
app.include_router(client_logs_router)
319+
317320
from tinyagentos.routes.account_proxy import router as account_proxy_router
318321
app.include_router(account_proxy_router)
319322

0 commit comments

Comments
 (0)