Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
59 changes: 59 additions & 0 deletions tests/test_client_log_store.py
Original file line number Diff line number Diff line change
@@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: test_long_message_and_stack_are_truncated only covers message and stack. The store also caps source (200), url (1000), and user_agent (500) — none of those truncation behaviors are tested. Add a sibling assertion that over-long source / url / user_agent are truncated to their respective MAX_*_LEN caps.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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
79 changes: 79 additions & 0 deletions tests/test_routes_client_logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Route tests for the client-log capture API."""
import pytest

from tinyagentos.auth_context import CurrentUser, current_user

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: There is no end-to-end test verifying that the User-Agent request header is captured server-side and persisted on the row. The store accepts and truncates user_agent (see ClientLogStore.create and test_long_message_and_stack_are_truncated in the store tests), and the route passes request.headers.get("user-agent", "") — but no test exercises that path. Add an assertion that user_agent round-trips through the POST.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


@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_post_is_rate_limited_per_user(client, monkeypatch):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The test only exercises a single user, so it does not verify that buckets are independent per user. The whole point of RateLimiter(key=user.user_id) is that user A being throttled does not block user B — and the same _post_limiter singleton is shared across all users. Add a sibling assertion: with capacity=2, two posts as user A then two as user B should both succeed (and only a third post by either user is rejected).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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)
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)
6 changes: 6 additions & 0 deletions tinyagentos/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions tinyagentos/client_log_store.py
Original file line number Diff line number Diff line change
@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Ring-buffer prune/ordering relies on created_at with no tie-breaker

created_at is a datetime.now(timezone.utc).isoformat() string and is the sole sort key used both for the ring-buffer prune (ORDER BY created_at DESC LIMIT MAX_ROWS) and for list_recent ordering. Two inserts that land in the same microsecond (plausible under the documented "crash loop posting on every render" scenario) produce identical created_at values, making the relative ordering of those rows undefined. At the MAX_ROWS boundary this means the prune can drop an arbitrary one of the tied rows, and list_recent can return tied rows in an unstable order. Functionally minor since the cap is approximate, but it weakens the "keep the most recent N" guarantee.

Suggested fix: add the primary key as a secondary sort key (e.g. ORDER BY created_at DESC, id DESC) in both the prune subquery and list_recent, or store a monotonically increasing INTEGER PRIMARY KEY / rowid and order by that.

Was this helpful? React with 👍 / 👎

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 ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Ring-buffer prune ORDER BY created_at DESC LIMIT ? has no tiebreaker. created_at is datetime.now(timezone.utc).isoformat() (microsecond precision), so concurrent inserts, or any two writes in the same microsecond, share a value and SQLite returns rows in arbitrary order from the inner SELECT. The DELETE then keeps a non-deterministic subset of the 2000 "newest" rows — under load the cap effectively becomes "2000 rows in some order". Add a deterministic tiebreaker (e.g. ORDER BY created_at DESC, id DESC LIMIT ?).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

)
""",
(MAX_ROWS,),
)
await self._db.commit()
Comment on lines +91 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Performance: Ring-buffer prune runs a full-table DELETE on every insert

create() issues the DELETE FROM client_logs WHERE id NOT IN (SELECT id ... ORDER BY created_at DESC LIMIT ?) on every single insert, even when the table is well below MAX_ROWS (the common case). The NOT IN subquery materializes and scans the table on each write, which is pure overhead for the typical small-table case and adds write amplification exactly in the crash-loop scenario this endpoint is meant to absorb. Since any authenticated user can POST and there is no rate limiting, this amplifies the cost of a flood.

Suggested fix: only prune occasionally rather than every insert (e.g. probabilistically, every Nth insert, or when a cheap COUNT(*) exceeds MAX_ROWS by some slack), or prune by timestamp/rowid threshold instead of NOT IN.

Was this helpful? React with 👍 / 👎

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]
3 changes: 3 additions & 0 deletions tinyagentos/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Loading