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
10 changes: 8 additions & 2 deletions docs/adr/ADR-0003-web-frontend-and-multi-session.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ phase:

Accepted

**Version:** 1.3
**Last Updated:** 2026-06-14
**Version:** 1.4
**Last Updated:** 2026-06-25

## Context

Expand Down Expand Up @@ -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-<session_id>.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
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down
22 changes: 20 additions & 2 deletions docs/design/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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-<session_id>.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-<session_id>.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)
Expand Down
114 changes: 114 additions & 0 deletions src/deepresearch/observability/session_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Per-session log file support for debugging individual research sessions.

Each running session gets its own ``logs/session-<session_id>.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-<session_id>.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()
30 changes: 30 additions & 0 deletions src/deepresearch/web/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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:
Expand Down
Loading