feat(diagnostics): debug sessions — start, stop, the tag on every line, screenshots (E16 S1)

A session is one recording, capped at 60 minutes by the server. While it is
open, a request naming the caller's own session in X-Debug-Session is logged
at the session's level with every line tagged; a header naming anything else
is ignored. The browser's batches count a session as consent to its level and
keep the tag only on its own events. Screenshots are judged by their bytes,
capped in size and count, and served back to their owner only.

- debug_sessions / debug_screenshots tables, one open session per user
  (partial unique index), log_events.debug_session_id indexed
- /api/debug-sessions: start (joins a running one), stop, list, get, events,
  screenshots; /api/me reports the live session
- the hourly log job closes expired sessions and drops them with the logs
- logsink.serialize and routes._common.capped_body shared by their readers
This commit is contained in:
2026-09-19 21:38:53 +02:00
parent 44a792c34f
commit a7d7456279
18 changed files with 1133 additions and 54 deletions
+54 -1
View File
@@ -12,6 +12,7 @@ from sqlalchemy import (
Index,
Integer,
JSON,
LargeBinary,
SmallInteger,
String,
Text,
@@ -565,7 +566,15 @@ class LogEvent(Base):
the retention job (`log_retention_days`) bounds whatever another process writes late."""
__tablename__ = "log_events"
__table_args__ = (Index("ix_log_events_user_ts", "user_id", "ts"),)
__table_args__ = (
Index("ix_log_events_user_ts", "user_id", "ts"),
# E16: an issue's bundle is `WHERE debug_session_id = …`; nearly every row has none.
Index(
"ix_log_events_debug_session",
"debug_session_id",
postgresql_where="debug_session_id IS NOT NULL",
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
@@ -584,6 +593,50 @@ class LogEvent(Base):
version: Mapped[str | None] = mapped_column(String(64))
class DebugSession(Base):
"""One recording: Start → reproduce → Stop (E16, app/debugsession.py). While it is open, every
request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`,
and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the
server set at Start; an open session past it is over whether or not the sweep has closed it yet.
One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`)."""
__tablename__ = "debug_sessions"
__table_args__ = (
Index(
"uq_debug_sessions_open_per_user",
"user_id",
unique=True,
postgresql_where="ended_at IS NULL",
),
)
id: Mapped[str] = mapped_column(String(16), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
level: Mapped[str] = mapped_column(String(8))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
until: Mapped[datetime] = mapped_column(DateTime(timezone=True))
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# 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))
class DebugScreenshot(Base):
"""A screenshot attached to a debug session (E16) — pasted, dropped, or captured from the pill.
Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade."""
__tablename__ = "debug_screenshots"
id: Mapped[int] = mapped_column(primary_key=True)
session_id: Mapped[str] = mapped_column(
ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
mime: Mapped[str] = mapped_column(String(16))
size: Mapped[int] = mapped_column(Integer)
data: Mapped[bytes] = mapped_column(LargeBinary)
class AppState(Base):
"""Single-row global app state (admin-controlled)."""