"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from. While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth dependency accepts the header only for the caller's OWN open session (anything else is ignored, never an error — a stale tab must not break the app) and then logs that request at the session's level with every line tagged by its id; the browser tags its own events the same way. The issue is later built from exactly the rows carrying the tag, so what the review list shows is what gets sent. Starting a session IS the consent for its level, for its duration: the start dialog carries the consent text. It needs no change to the user's own diagnostics level, and none is made — when the session ends the level in force is simply the user's own again. A session is capped at `MAX_MINUTES`, set by the server at Start. An open one past its cap is over whether or not anything has closed it yet: it reads as over at once, a new Start closes it, and the hourly log job sweeps the rest. Sessions go with the logs they point at, after `log_retention_days`. Core layer: imports `models`, `diagnostics`. """ import secrets from datetime import datetime, timedelta, timezone from enum import StrEnum from typing import Literal, get_args from sqlalchemy import and_, delete, func, not_, select, update from sqlalchemy.orm import Session, defer from app import diagnostics from app.models import DebugScreenshot, DebugSession, LogEvent, User HEADER = "x-debug-session" MAX_MINUTES = diagnostics.SESSION_MINUTES SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024 SCREENSHOTS_MAX = 8 # Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the # bundle says how many it left out rather than growing without bound. BUNDLE_MAX = 5_000 SessionLevel = Literal["DEBUG", "TRACE"] SESSION_LEVELS: tuple[str, ...] = get_args(SessionLevel) # Detail order of the diagnostics levels: a session raises the level in force, never lowers it. _DETAIL = {"INFO": 0, "DEBUG": 1, "TRACE": 2} _ID_LEN = 16 class EndReason(StrEnum): STOPPED = "stopped" EXPIRED = "expired" def _is_live(session: DebugSession, now: datetime) -> bool: return session.ended_at is None and session.until > now def open_session(db: Session, user: User) -> DebugSession | None: """The user's live session, or None. Read-only: one past its cap is over and reads as None; `start` and the hourly sweep are what close it.""" session = _unclosed(db, user) if session is None or not _is_live(session, datetime.now(timezone.utc)): return None return session def _unclosed(db: Session, user: User) -> DebugSession | None: return db.scalar( select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None)) ) def start(db: Session, user: User, level: SessionLevel) -> DebugSession: """Open a session, or return the live one — a second tab pressing Start joins the recording rather than ending it. One past its cap is closed first: the one-open-per-user index would refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both get here: the index refuses the second, and the caller retries once.""" now = datetime.now(timezone.utc) current = _unclosed(db, user) if current is not None: if _is_live(current, now): return current end(current, EndReason.EXPIRED) db.flush() session = DebugSession( id=secrets.token_hex(_ID_LEN // 2), user_id=user.id, level=level, started_at=now, until=now + timedelta(minutes=MAX_MINUTES), ) db.add(session) db.flush() return session def end(session: DebugSession, reason: EndReason) -> None: """Close a session. An expired one is dated at its cap, not at whenever it was noticed.""" if session.ended_at is not None: return session.ended_at = ( session.until if reason is EndReason.EXPIRED else min(datetime.now(timezone.utc), session.until) ) session.ended_reason = reason def from_header(db: Session, user: User, value: str | None) -> DebugSession | None: """The session a request names, if it is the caller's own and still live; else None.""" if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value): return None session = db.get(DebugSession, value) if ( session is None or session.user_id != user.id or not _is_live(session, datetime.now(timezone.utc)) ): return None return session def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | None]: """The diagnostics level (and its expiry) a request is logged at: the user's own, raised to the session's while one is named — whichever records more.""" own = diagnostics.effective_level(user) until = user.diagnostics_until if own != diagnostics.BASE else None if session is None or _DETAIL[session.level] <= _DETAIL[own]: return own, until 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 return { "id": session.id, "level": session.level, "started_at": session.started_at.isoformat(), "until": session.until.isoformat(), "ended_at": session.ended_at.isoformat() if session.ended_at else None, "ended_reason": session.ended_reason, "issue_url": session.issue_url, "screenshots": screenshots, "draft": session.draft, } def screenshot_count(db: Session, session: DebugSession) -> int: return ( db.scalar( select(func.count()) .select_from(DebugScreenshot) .where(DebugScreenshot.session_id == session.id) ) or 0 ) # --- screenshots --------------------------------------------------------------------------- # By content, not by the declared type: what is stored is served back to a browser as an image. _SIGNATURES = ( (b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"), ) def sniff(data: bytes) -> str | None: """The image type `data` really is — PNG, JPEG or WebP — or None.""" for magic, mime in _SIGNATURES: if data.startswith(magic): return mime if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "image/webp" return None class ScreenshotRefused(Exception): """A screenshot that cannot be kept; `code` is the refusal's machine word.""" def __init__(self, code: str) -> None: super().__init__(code) self.code = code def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot: """Keep one screenshot. Flushes; the caller commits. Refused once the issue was sent (it can no longer carry it), past the per-session count, or when it is not an image we can show.""" if session.issue_url is not None: raise ScreenshotRefused("already_sent") if not data: raise ScreenshotRefused("empty") mime = sniff(data) if mime is None: raise ScreenshotRefused("not_an_image") if screenshot_count(db, session) >= SCREENSHOTS_MAX: raise ScreenshotRefused("too_many") shot = DebugScreenshot( session_id=session.id, created_at=datetime.now(timezone.utc), mime=mime, size=len(data), data=data, ) db.add(shot) db.flush() return shot def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]: """The session's screenshots, oldest first, WITHOUT their bytes loaded.""" return list( db.scalars( select(DebugScreenshot) .where(DebugScreenshot.session_id == session.id) .options(defer(DebugScreenshot.data)) .order_by(DebugScreenshot.id) ) ) # --- the evidence -------------------------------------------------------------------------- # The browser's own log upload (`POST` here, every few seconds while recording) is how the evidence # arrives, not part of it: its server lines would fill the bundle with the recording's plumbing. The # browser leaves its copies out for the same reason (frontend lib/diagnostics `isOwnRequest`). INGEST_PATH = "/api/diagnostics/events" def _evidence(session: DebugSession): upload_line = and_( LogEvent.event == "http.request", LogEvent.attrs["path"].astext == INGEST_PATH ) # `IS TRUE`, not the bare condition: for a row with no event or no attrs it is NULL, and # `NOT NULL` would drop exactly the rows that are evidence. return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True))) 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. `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(*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(*where)) or total return rows, total # --- housekeeping (the hourly log job) ----------------------------------------------------- def sweep(db: Session) -> int: """Stage closing every open session past its cap; returns how many.""" now = datetime.now(timezone.utc) result = db.execute( update(DebugSession) .where(DebugSession.ended_at.is_(None), DebugSession.until <= now) .values(ended_at=DebugSession.until, ended_reason=EndReason.EXPIRED.value) .execution_options(synchronize_session=False) ) return result.rowcount or 0 def gc(db: Session, retention_days: int) -> int: """Stage deleting sessions (and by cascade their screenshots) started before the log retention window — the rows they would bundle are gone by then too. Returns how many.""" cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days)) result = db.execute(delete(DebugSession).where(DebugSession.started_at < cutoff)) return result.rowcount or 0