feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)

After Stop (or at the cap, or from Settings → Diagnosztika's past sessions)
one dialog drafts the issue: title, description, the steps drafted from the
session's markers and page changes (a marker without a label is named by its
time), the environment (version, browser, system, window, language). Beside
it, what goes with it: the browser and server logs — each reviewable line by
line, an unticked line is not sent — and the screenshots, removable, with
paste / drop / pick to add more.

- it saves as it goes (debug_sessions.draft, migration 0090): the rendered
  markdown in the user's language, the excluded rows, and its own fields to
  reopen as it was; a sent session is read-only
- the zip export (issue.md, client.log, server.log, screenshots) is built
  from the saved draft by app/issues/bundle.py — the one definition the Gitea
  delivery (S4) will share; .log, not .jsonl, which Gitea refuses
- the log line's level tags and clock move to components/logLineParts so the
  review list draws a level the way both log viewers do
This commit is contained in:
2026-09-19 22:26:49 +02:00
parent 9f177557d2
commit 8d0083d4e4
23 changed files with 1620 additions and 35 deletions
@@ -0,0 +1,27 @@
"""add debug_sessions.draft — the issue being written from a session (E16 S3)
Revision ID: 0090_debug_session_draft
Revises: 0089_debug_sessions
Create Date: 2026-09-19
The composer saves as it goes: the title, the rendered markdown body that an export or an issue
carries, the log rows the user took out, and the form's own fields so reopening it later (Settings →
Diagnosztika lists past sessions) shows what was written. NULL until the composer first saves.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0090_debug_session_draft"
down_revision: str | None = "0089_debug_sessions"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("debug_sessions", sa.Column("draft", JSONB, nullable=True))
def downgrade() -> None:
op.drop_column("debug_sessions", "draft")
+3 -2
View File
@@ -13,11 +13,12 @@ Three layers, by *who* runs the code — not by feature:
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
2. **`plex/`, `downloads/`, `sync/` — domain logic shared by more than one caller.** Anything the
2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
route, the worker, and the scheduler share one definition. Examples from this codebase:
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline.
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
+19 -10
View File
@@ -127,6 +127,13 @@ def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime |
return session.level, session.until
def save_draft(session: DebugSession, draft: dict) -> None:
"""Keep the composer's draft (the caller validated and commits). Shape: `title`, `body` (the
rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
evidence) and `form` (the composer's own fields, to reopen it as it was)."""
session.draft = draft
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
if session is None:
return None
@@ -139,6 +146,7 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None
"ended_reason": session.ended_reason,
"issue_url": session.issue_url,
"screenshots": screenshots,
"draft": session.draft,
}
@@ -231,23 +239,24 @@ def _evidence(session: DebugSession):
return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True)))
def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]:
def bundle(
db: Session, session: DebugSession, *, drop_excluded: bool = False
) -> tuple[list[LogEvent], int]:
"""The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and
how many rows it has in all, so a cut bundle can say so."""
how many rows it has in all, so a cut bundle can say so. `drop_excluded` leaves out the rows
the user took out in the composer: what an export or an issue carries."""
where = list(_evidence(session))
excluded = (session.draft or {}).get("excluded") or [] if drop_excluded else []
if excluded:
where.append(LogEvent.id.not_in(excluded))
rows = list(
db.scalars(
select(LogEvent)
.where(*_evidence(session))
.order_by(LogEvent.ts, LogEvent.id)
.limit(BUNDLE_MAX)
select(LogEvent).where(*where).order_by(LogEvent.ts, LogEvent.id).limit(BUNDLE_MAX)
)
)
total = len(rows)
if total == BUNDLE_MAX:
total = (
db.scalar(select(func.count()).select_from(LogEvent).where(*_evidence(session)))
or total
)
total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total
return rows, total
+3
View File
@@ -0,0 +1,3 @@
"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go —
downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded
and tagged — is the core module `app/debugsession.py`."""
+77
View File
@@ -0,0 +1,77 @@
"""What an issue from a debug session carries, as files — one definition for the zip export and the
Gitea upload, so the two cannot drift apart.
* `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there,
in their language; the server does not rewrite it);
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
`.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
* the screenshots, in the order they were taken.
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
field the review list did not show.
"""
import io
import json
import zipfile
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import debugsession, logsink
from app.models import DebugScreenshot, DebugSession
_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
@dataclass(frozen=True)
class IssueFile:
name: str
data: bytes
mime: str
def title_of(session: DebugSession) -> str:
# Not a sentence of ours: the composer always saves a title in the user's language, and a
# session exported before it ever saved is named by its date alone, in no language at all.
title = ((session.draft or {}).get("title") or "").strip()
return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}"
def body_of(session: DebugSession) -> str:
return ((session.draft or {}).get("body") or "").strip()
def _lines(rows) -> bytes:
return "".join(
json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows
).encode()
def files(db: Session, session: DebugSession) -> list[IssueFile]:
"""The session's issue as files: the text, the two logs, the screenshots."""
rows, _ = debugsession.bundle(db, session, drop_excluded=True)
text = f"# {title_of(session)}\n\n{body_of(session)}\n"
out = [
IssueFile("issue.md", text.encode(), "text/markdown"),
IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"),
IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"),
]
shots = db.scalars(
select(DebugScreenshot)
.where(DebugScreenshot.session_id == session.id)
.order_by(DebugScreenshot.id)
)
for n, shot in enumerate(shots, start=1):
out.append(IssueFile(f"screenshot-{n}.{_EXT.get(shot.mime, 'bin')}", shot.data, shot.mime))
return out
def zipped(items: list[IssueFile]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for item in items:
z.writestr(item.name, item.data)
return buf.getvalue()
+2
View File
@@ -619,6 +619,8 @@ class DebugSession(Base):
# app/debugsession.py `EndReason`: stopped by the user, or past `until`.
ended_reason: Mapped[str | None] = mapped_column(String(16))
issue_url: Mapped[str | None] = mapped_column(String(512))
# The issue being written from it (E16 S3; app/debugsession.py `save_draft` holds its shape).
draft: Mapped[dict | None] = mapped_column(JSONB)
class DebugScreenshot(Base):
+57 -1
View File
@@ -10,10 +10,12 @@ form — one image per request, capped while it arrives, and kept only if its by
those types, whatever the Content-Type claimed.
"""
import json
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -21,6 +23,7 @@ from sqlalchemy.orm import Session
from app import debugsession, logs, logsink
from app.auth import require_human
from app.db import get_db
from app.issues import bundle
from app.models import DebugScreenshot, DebugSession, User
from app.routes._common import capped_body, not_found, owned_or_404
from app.utils import iso
@@ -111,6 +114,59 @@ def session_events(
return {"items": [logsink.serialize(r) for r in rows], "total": total}
# --- the draft and its export ---------------------------------------------------------------
# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it
# was — bounded here so a draft cannot become a place to store anything else.
FORM_MAX_BYTES = 64 * 1024
class DraftIn(BaseModel):
title: str = Field(max_length=200)
# The rendered markdown the export and the issue carry.
body: str = Field(max_length=50_000)
# Log rows taken out of the evidence in the review.
excluded: list[int] = Field(default_factory=list, max_length=debugsession.BUNDLE_MAX)
form: dict[str, Any] = Field(default_factory=dict)
@router.put("/{sid}/draft")
def save_draft(
sid: str,
body: DraftIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if session.issue_url is not None:
# Sent: the issue is on the tracker now, and a draft edited after it would say otherwise.
raise HTTPException(status_code=409, detail={"code": "already_sent"})
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
debugsession.save_draft(session, body.model_dump())
db.commit()
return {"ok": True, "session": _view(db, session)}
@router.get("/{sid}/export")
def export_session(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> Response:
"""The issue as a zip — the text, the two logs and the screenshots — for an instance without an
issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
what it saved."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
data = bundle.zipped(bundle.files(db, session))
name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip"
return Response(
content=data,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{name}"',
"Cache-Control": "no-store",
},
)
# --- screenshots ---------------------------------------------------------------------------
def _shot_view(shot: DebugScreenshot) -> dict:
return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)}
+97
View File
@@ -127,10 +127,13 @@ def test_nobody_reaches_another_users_session(db, make_user, as_user):
("get", f"/api/debug-sessions/{sid}/screenshots"),
("get", f"/api/debug-sessions/{sid}/screenshots/{shot}"),
("delete", f"/api/debug-sessions/{sid}/screenshots/{shot}"),
("get", f"/api/debug-sessions/{sid}/export"),
):
r = getattr(stranger, method)(path)
assert r.status_code == 404, (method, path, r.status_code)
assert r.json()["detail"] == {"code": "not_found", "reason": "debug_session"}
r = stranger.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"})
assert r.status_code == 404
r = stranger.post(
f"/api/debug-sessions/{sid}/screenshots",
content=_PNG,
@@ -446,3 +449,97 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
def test_the_upload_path_left_out_of_the_evidence_is_the_real_one():
assert debugsession.INGEST_PATH in {r.path for r in diagnostics_routes.router.routes}
# --- the draft and the export ---------------------------------------------------------------
def test_a_draft_is_kept_and_comes_back_with_the_session(recording):
client, sid = recording
draft = {
"title": "Spinner stays",
"body": "## Steps\n1. open",
"excluded": [3],
"form": {"a": 1},
}
r = client.put(f"/api/debug-sessions/{sid}/draft", json=draft)
assert r.status_code == 200 and r.json()["session"]["draft"] == draft
assert client.get(f"/api/debug-sessions/{sid}").json()["draft"] == draft
def test_a_draft_is_bounded(recording, monkeypatch):
from app.routes import debug_sessions as routes
client, sid = recording
monkeypatch.setattr(routes, "FORM_MAX_BYTES", 16)
r = client.put(
f"/api/debug-sessions/{sid}/draft",
json={"title": "t", "body": "b", "form": {"text": "x" * 32}},
)
assert r.status_code == 413
too_long = {"title": "t" * 201, "body": "b"}
assert client.put(f"/api/debug-sessions/{sid}/draft", json=too_long).status_code == 422
def test_a_sent_session_keeps_its_draft(db, recording):
client, sid = recording
db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
db.commit()
r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "t", "body": "b"})
assert r.status_code == 409 and r.json()["detail"]["code"] == "already_sent"
def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make_user, as_user):
import io
import json
import zipfile
user = make_user()
client = as_user(user)
sid = _start(client)["id"]
now = datetime.now(timezone.utc)
def row(ts, source, msg):
return LogEvent(
ts=ts,
level="INFO",
sev=9,
source=source,
logger=source,
event="x.y",
msg=msg,
user_id=user.id,
debug_session_id=sid,
)
kept_web, dropped_web, kept_api = (
row(now, "web", "kept click"),
row(now + timedelta(seconds=1), "web", "private search"),
row(now + timedelta(seconds=2), "api", "GET /api/x 200"),
)
db.add_all([kept_web, dropped_web, kept_api])
db.commit()
_upload(client, sid, _PNG)
_upload(client, sid, _JPEG)
client.put(
f"/api/debug-sessions/{sid}/draft",
json={"title": "Spinner stays", "body": "It spins.", "excluded": [dropped_web.id, 999999]},
)
r = client.get(f"/api/debug-sessions/{sid}/export")
assert r.status_code == 200 and r.headers["content-type"] == "application/zip"
assert "attachment;" in r.headers["content-disposition"]
z = zipfile.ZipFile(io.BytesIO(r.content))
assert z.namelist() == [
"issue.md",
"client.log",
"server.log",
"screenshot-1.png",
"screenshot-2.jpg",
]
assert z.read("issue.md").decode() == "# Spinner stays\n\nIt spins.\n"
web = [json.loads(line)["msg"] for line in z.read("client.log").decode().splitlines()]
api = [json.loads(line)["msg"] for line in z.read("server.log").decode().splitlines()]
assert "kept click" in web and "private search" not in web
assert "GET /api/x 200" in api and all(m != "kept click" for m in api)
assert z.read("screenshot-1.png") == _PNG