diff --git a/docs/adr/ADR-0003-web-frontend-and-multi-session.md b/docs/adr/ADR-0003-web-frontend-and-multi-session.md index 17cb0e9..4fa6a5a 100644 --- a/docs/adr/ADR-0003-web-frontend-and-multi-session.md +++ b/docs/adr/ADR-0003-web-frontend-and-multi-session.md @@ -12,8 +12,8 @@ phase: Accepted -**Version:** 1.3 -**Last Updated:** 2026-06-14 +**Version:** 1.4 +**Last Updated:** 2026-06-25 ## Context @@ -165,6 +165,10 @@ Two complementary logging systems were added for observability: - **Root logger:** The handler is added to the root logger, so all child loggers benefit - Auto-created on server start — directory and file created if absent +> Enhancement (2026-06-25): Per-session log files were added. Each session now +> writes its own `logs/session-.log` alongside the global log. +> See `observability/session_logging.py`. + #### 2. In-Memory System Log Buffer (`/api/system/log`) - **Handler:** `SystemLogHandler` — a custom `logging.Handler` that captures log records into an in-memory list - **Buffer:** `SYSTEM_LOG` list, max 500 entries @@ -210,6 +214,7 @@ Before a session starts, `MultiSessionManager.create_session()` runs a connectiv 12. **Live agent streaming** — per-agent text output panels show real-time LLM generation 13. **File-based logging** — persistent DEBUG logs survive server restarts 14. **System Log tab** — in-browser log viewer for debugging without terminal access +15. **Per-session log files** — each session has its own dedicated log file for debugging without interleaving ### Negative 1. In-memory sessions: lost on server restart (acceptable for v1.0) @@ -231,6 +236,7 @@ Before a session starts, `MultiSessionManager.create_session()` runs a connectiv 6. Delete confirmation dialog prevents accidental session removal 7. Event history trimmed to 500 entries — recent history always available, oldest events are lost 8. System log buffer is in-memory (500 entries) — lost on server restart, distinct from file-based logging +9. **Per-session log files consume disk space** — each session creates a separate log file that persists after the session ends; cleanup is manual ## Related Issues - #52 (Q&A Graph — Interactive visualization): Extends the frontend with a real-time SVG interaction graph showing agent-scribe communication flow. diff --git a/docs/design/README.md b/docs/design/README.md index 88950e3..ffb763a 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -1,8 +1,8 @@ # DeepeResearch — Design Document -**Version:** 1.5 +**Version:** 1.6 **Status:** Active **Design Authority:** Architects -**Last Updated:** 2026-06-20 +**Last Updated:** 2026-06-25 ## 1. Purpose & Scope @@ -652,6 +652,24 @@ class SessionEvent: details: dict[str, Any] = field(default_factory=dict) ``` +### 6.5 Per-Session File Logging + +Each session automatically gets its own log file at `logs/session-.log` +for isolated debugging. This supplements the global `logs/deepresearch.log`. + +- **Setup:** `observability/session_logging.py` — wired into + `MultiSessionManager._run_session()` +- **File path:** `logs/session-.log` (same directory as global log) +- **Format:** `2026-06-25 20:30:00 [DEBUG] deepresearch.orchestrator [a1b2c3d4]: message` +- **Level:** DEBUG — captures all `deepresearch.*` logger activity for that session +- **Scope:** All loggers under the `deepresearch.*` namespace write to the per-session + file while the session is active +- **Lifecycle:** Created when session status changes to `running`, torn down in + `finally` block (survives errors and cancellations) +- **Safety:** Failure to create the log file is caught gracefully — the session + continues without per-session logging. Concurrent sessions each get their own + independent log file. + ## 7. Implementation Plan ### Phase 1: Core Infrastructure (2-3 days) diff --git a/src/deepresearch/observability/session_logging.py b/src/deepresearch/observability/session_logging.py new file mode 100644 index 0000000..96cecf0 --- /dev/null +++ b/src/deepresearch/observability/session_logging.py @@ -0,0 +1,114 @@ +"""Per-session log file support for debugging individual research sessions. + +Each running session gets its own ``logs/session-.log`` file +so that concurrent session logs are not interleaved in the global +``logs/deepresearch.log``. + +Usage:: + + handler = setup_session_logging(session_id, topic) + try: + ... # session work + finally: + teardown_session_logging(handler) +""" + +from __future__ import annotations + +import logging +from pathlib import Path + + +class SessionFilter(logging.Filter): + """Injects *session_id* into every log record so the formatter can + use ``%(session_id)s``.""" + + def __init__(self, session_id: str) -> None: + super().__init__() + self.session_id = session_id + + def filter(self, record: logging.LogRecord) -> bool: + record.session_id = self.session_id + return True + + +def _get_log_dir() -> Path: + """Return the ``logs/`` directory relative to the project root. + + Uses the same resolution as ``server.py`` so that session log files + land next to ``deepresearch.log``. + """ + return Path(__file__).resolve().parent.parent.parent.parent / "logs" + + +def setup_session_logging(session_id: str, topic: str) -> logging.Handler: + """Create a per-session log file at ``logs/session-.log``. + + The handler is registered on the **root** logger so that **all** + ``deepresearch.*`` loggers write to it. + + Parameters + ---------- + session_id: + 8-character session identifier (e.g. ``"a1b2c3d4"``). + topic: + Research topic, written in the initial delimiter line. + + Returns + ------- + logging.Handler + The handler instance. Keep a reference so it can be removed + in :func:`teardown_session_logging`. + + Raises + ------ + OSError + If the ``logs/`` directory cannot be created or the log file + cannot be opened (caller should catch and log a warning). + """ + log_dir = _get_log_dir() + log_dir.mkdir(parents=True, exist_ok=True) + + log_file = log_dir / f"session-{session_id}.log" + + handler = logging.FileHandler(str(log_file), encoding="utf-8") + handler.setLevel(logging.DEBUG) + + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s [%(session_id)s]: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + handler.setFormatter(formatter) + + handler.addFilter(SessionFilter(session_id)) + + root = logging.getLogger() + root.addHandler(handler) + + # Write initial delimiter line so the file immediately has context. + # Use handler.handle() instead of emit() so the SessionFilter runs + # (StreamHandler.emit() in Python 3.13 bypasses filters). + handler.handle( + logging.LogRecord( + name=__name__, + level=logging.INFO, + pathname=__file__, + lineno=0, + msg=f"=== Session {session_id} started: {topic} ===", + args=None, + exc_info=None, + ) + ) + + return handler + + +def teardown_session_logging(handler: logging.Handler) -> None: + """Remove *handler* from the root logger and close it. + + This flushes any buffered output and closes the underlying file. + It is safe to call multiple times on the same handler. + """ + root = logging.getLogger() + root.removeHandler(handler) + handler.close() diff --git a/src/deepresearch/web/sessions.py b/src/deepresearch/web/sessions.py index e2624a5..063265c 100644 --- a/src/deepresearch/web/sessions.py +++ b/src/deepresearch/web/sessions.py @@ -294,6 +294,21 @@ async def _run_session( info = self._sessions[session_id] info.status = "running" + # ── Per-session log file ────────────────────────────────────────── + _session_log_handler: logging.Handler | None = None + try: + from deepresearch.observability.session_logging import ( + setup_session_logging, + ) + + _session_log_handler = setup_session_logging(session_id, info.topic) + except Exception: + logger.warning( + "Failed to create per-session log for %s (continuing anyway)", + session_id, + exc_info=True, + ) + # Semaphore is already acquired by the server handler before # create_session() returns. Only release happens here (in finally). @@ -476,6 +491,21 @@ async def _run_session( ) finally: + # ── Tear down per-session log file ──────────────────────────── + if _session_log_handler is not None: + try: + from deepresearch.observability.session_logging import ( + teardown_session_logging, + ) + + teardown_session_logging(_session_log_handler) + except Exception: + logger.warning( + "Failed to tear down per-session log for %s", + session_id, + exc_info=True, + ) + self._cancel_events.pop(session_id, None) # Release concurrency semaphore if held. if semaphore is not None: