diff --git a/API.md b/API.md
index 57d30de0..b360a8ff 100644
--- a/API.md
+++ b/API.md
@@ -2315,11 +2315,25 @@ Binary image data with appropriate headers
{
"id": "",
"datetime": "2026-05-18T12:00:00.000+00:00",
- "level": "info",
- "levelno": 20,
+ "level": "error",
+ "levelno": 40,
"logger": "downloads.queue",
- "message": "Download started",
- "exception_message": null,
+ "message": "Download failed",
+ "exception": {
+ "type": "ValueError",
+ "message": "bad",
+ "file": "/app/library/downloads/queue_manager.py",
+ "line": 123,
+ "stack": [
+ {
+ "path": "/app/library/downloads/queue_manager.py",
+ "file": "queue_manager.py",
+ "module": "queue_manager",
+ "function": "start",
+ "line": 123
+ }
+ ]
+ },
"source": {
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
@@ -2337,7 +2351,6 @@ Binary image data with appropriate headers
}
```
- Returns `404 Not Found` if file logging is not enabled.
-- `fields` contains scalar `logging.extra` values only. Exceptions are returned in `exception` when present.
---
@@ -2353,11 +2366,25 @@ Binary image data with appropriate headers
{
"id": "",
"datetime": "2026-05-18T12:00:00.000+00:00",
- "level": "info",
- "levelno": 20,
+ "level": "error",
+ "levelno": 40,
"logger": "downloads.queue",
- "message": "Download started",
- "exception_message": null,
+ "message": "Download failed",
+ "exception": {
+ "type": "ValueError",
+ "message": "bad",
+ "file": "/app/library/downloads/queue_manager.py",
+ "line": 123,
+ "stack": [
+ {
+ "path": "/app/library/downloads/queue_manager.py",
+ "file": "queue_manager.py",
+ "module": "queue_manager",
+ "function": "start",
+ "line": 123
+ }
+ ]
+ },
"source": {
"path": "/app/library/downloads/queue_manager.py",
"file": "queue_manager.py",
diff --git a/app/library/Utils.py b/app/library/Utils.py
index bcc79005..4c383524 100644
--- a/app/library/Utils.py
+++ b/app/library/Utils.py
@@ -8,6 +8,7 @@ import os
import re
import socket
import time
+import traceback
import uuid
from datetime import UTC, datetime, timedelta
from functools import lru_cache, wraps
@@ -46,6 +47,13 @@ LOG_RECORD_ATTRS: set[str] = set(logging.makeLogRecord({}).__dict__) | {"asctime
class JsonLogFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
+ source = {
+ "path": record.pathname,
+ "file": record.filename,
+ "module": record.module,
+ "function": record.funcName,
+ "line": record.lineno,
+ }
data: dict[str, Any] = {
"id": str(uuid.uuid4()),
"datetime": self.formatTime(record),
@@ -53,24 +61,17 @@ class JsonLogFormatter(logging.Formatter):
"levelno": record.levelno,
"logger": record.name,
"message": record.getMessage(),
- "source": {
- "path": record.pathname,
- "file": record.filename,
- "module": record.module,
- "function": record.funcName,
- "line": record.lineno,
- },
+ "source": source,
"process": {"id": record.process, "name": record.processName},
"thread": {"id": record.thread, "name": record.threadName},
"fields": self._extra(record),
}
if record.exc_info:
- data["exception"] = self.formatException(record.exc_info)
- data["exception_message"] = self._exception_message(record.exc_info)
-
- if record.stack_info:
- data["stack"] = self.formatStack(record.stack_info)
+ exception = self._exception(record.exc_info)
+ if exception is not None:
+ data["exception"] = exception
+ data["source"].update(self._exception_source(exception))
return json.dumps(data, ensure_ascii=False, default=str)
@@ -90,16 +91,88 @@ class JsonLogFormatter(logging.Formatter):
return extra
@staticmethod
- def _exception_message(
+ def _exception(
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
- ) -> str | None:
+ ) -> dict[str, Any] | None:
exc = exc_info[1]
if exc is None:
return None
- name = exc.__class__.__name__
- msg = str(exc).strip()
- return f"{name}: {msg}" if msg else name
+ stack = JsonLogFormatter._exception_stack(exc_info)
+ data: dict[str, Any] = {"type": JsonLogFormatter._exception_type(exc)}
+
+ message = str(exc).strip()
+ if message:
+ data["message"] = message
+
+ if stack:
+ origin = stack[-1]
+ data["file"] = origin["path"]
+ data["line"] = origin["line"]
+ data["stack"] = stack
+
+ return data
+
+ @staticmethod
+ def _exception_type(exc: BaseException) -> str:
+ module = exc.__class__.__module__
+ name = exc.__class__.__qualname__
+ return name if module == "builtins" else f"{module}.{name}"
+
+ @staticmethod
+ def _exception_stack(
+ exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
+ ) -> list[dict[str, Any]]:
+ exc = exc_info[1]
+ tb = exc_info[2] if len(exc_info) > 2 else None
+ if tb is None and exc is not None:
+ tb = exc.__traceback__
+
+ if tb is None:
+ return []
+
+ return [JsonLogFormatter._frame(frame) for frame in traceback.extract_tb(tb)]
+
+ @staticmethod
+ def _frame(frame: traceback.FrameSummary) -> dict[str, Any]:
+ path = frame.filename
+ file_path = Path(path)
+ return {
+ "path": path,
+ "file": file_path.name,
+ "module": file_path.stem,
+ "function": frame.name,
+ "line": frame.lineno,
+ }
+
+ @staticmethod
+ def _exception_source(exception: dict[str, Any]) -> dict[str, Any]:
+ source: dict[str, Any] = {}
+
+ path = exception.get("file")
+ if isinstance(path, str) and path:
+ file_path = Path(path)
+ source["path"] = path
+ source["file"] = file_path.name
+ source["module"] = file_path.stem
+
+ line = exception.get("line")
+ if isinstance(line, int):
+ source["line"] = line
+
+ stack = exception.get("stack")
+ if isinstance(stack, list) and stack:
+ frame = stack[-1]
+ if isinstance(frame, dict):
+ function = frame.get("function")
+ if isinstance(function, str) and function:
+ source["function"] = function
+
+ module = frame.get("module")
+ if isinstance(module, str) and module:
+ source["module"] = module
+
+ return source
def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
diff --git a/app/routes/api/logs.py b/app/routes/api/logs.py
index 2a9efe54..07c7aec4 100644
--- a/app/routes/api/logs.py
+++ b/app/routes/api/logs.py
@@ -38,7 +38,7 @@ def _parse_jsonl_line(line: bytes | str) -> dict | None:
"message": str(payload["message"]).strip(),
}
- for key in ("levelno", "source", "process", "thread", "fields", "exception", "exception_message", "stack"):
+ for key in ("levelno", "source", "process", "thread", "fields", "exception"):
if key not in payload:
continue
diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py
index bf618da1..96493605 100644
--- a/app/tests/test_utils.py
+++ b/app/tests/test_utils.py
@@ -310,8 +310,32 @@ class TestJsonLogFormatter:
data = json.loads(formatter.format(record))
assert data["message"] == "failed"
- assert data["exception_message"] == "ValueError: bad"
- assert "ValueError: bad" in data["exception"]
+ assert data["exception"] == {
+ "type": "ValueError",
+ "message": "bad",
+ "file": __file__,
+ "line": data["exception"]["line"],
+ "stack": data["exception"]["stack"],
+ }
+ assert data["exception"]["line"] > 0
+ assert data["exception"]["stack"][-1] == {
+ "path": __file__,
+ "file": Path(__file__).name,
+ "module": Path(__file__).stem,
+ "function": "test_exception",
+ "line": data["exception"]["line"],
+ }
+ assert data["source"] == data["exception"]["stack"][-1]
+ assert "exception_message" not in data
+
+ def test_no_raw_stack(self):
+ formatter = JsonLogFormatter()
+ record = logging.LogRecord("test", logging.ERROR, __file__, 1, "failed", (), None)
+ record.stack_info = 'Stack (most recent call last):\n File "x", line 1, in y'
+
+ data = json.loads(formatter.format(record))
+
+ assert "stack" not in data
class TestCalcDownloadPath:
@@ -1581,6 +1605,21 @@ class TestReadLogfile:
"level": "error",
"logger": "test",
"message": "line 3",
+ "exception": {
+ "type": "ValueError",
+ "message": "bad",
+ "file": "/tmp/test.py",
+ "line": 3,
+ "stack": [
+ {
+ "path": "/tmp/test.py",
+ "file": "test.py",
+ "module": "test",
+ "function": "run",
+ "line": 3,
+ }
+ ],
+ },
},
]
self.log_file.write_text("\n".join(json.dumps(line) for line in lines) + "\n")
@@ -1593,6 +1632,7 @@ class TestReadLogfile:
assert all("line" not in line for line in result["logs"])
assert [line["message"] for line in result["logs"]] == ["line 2", "line 3"]
assert result["logs"][0]["level"] == "warning"
+ assert result["logs"][1]["exception"]["type"] == "ValueError"
assert result["next_offset"] == 2
assert result["end_is_reached"] is False
diff --git a/ui/app/pages/logs.vue b/ui/app/pages/logs.vue
index 2b205cc3..1113c86a 100644
--- a/ui/app/pages/logs.vue
+++ b/ui/app/pages/logs.vue
@@ -177,8 +177,8 @@
>
{{ entry.log.message }}
-
- : {{ entry.log.exception_message }}
+
+ : {{ exceptionSummary(entry.log) }}
@@ -266,11 +266,11 @@
@@ -292,20 +292,7 @@
{{ selectedLog.exception }}
-
-
-
-
-
- Stack
-
- {{ selectedLog.stack }}{{ exceptionText(selectedLog) }}
@@ -816,13 +803,27 @@ const logTimeTitle = (value?: string): string =>
const logRaw = (log: log_line): string => JSON.stringify(log, null, 2);
+const exceptionSummary = (log: log_line): string => {
+ const type = log.exception?.type?.trim() ?? '';
+ const message = log.exception?.message?.trim() ?? '';
+
+ if (type && message) {
+ return `${type}: ${message}`;
+ }
+
+ return type || message;
+};
+
+const exceptionText = (log: log_line): string =>
+ log.exception ? JSON.stringify(log.exception, null, 2) : '';
+
const searchableLog = (log: log_line): string =>
[
log.message,
log.level,
log.logger,
- log.exception,
- log.stack,
+ exceptionSummary(log),
+ log.exception ? JSON.stringify(log.exception) : '',
log.source ? JSON.stringify(log.source) : '',
log.process ? JSON.stringify(log.process) : '',
log.thread ? JSON.stringify(log.thread) : '',
diff --git a/ui/app/types/logs.ts b/ui/app/types/logs.ts
index 48cccbda..3fddb9ac 100644
--- a/ui/app/types/logs.ts
+++ b/ui/app/types/logs.ts
@@ -16,6 +16,22 @@ type log_thread = {
name?: string;
};
+type log_exception_frame = {
+ path?: string;
+ file?: string;
+ module?: string;
+ function?: string;
+ line?: number;
+};
+
+type log_exception = {
+ type: string;
+ message?: string;
+ file?: string;
+ line?: number;
+ stack?: Array;
+};
+
type log_line = {
id: string;
message: string;
@@ -27,9 +43,7 @@ type log_line = {
process?: log_process;
thread?: log_thread;
fields?: Record;
- exception?: string | null;
- exception_message?: string | null;
- stack?: string | null;
+ exception?: log_exception | null;
};
-export type { log_line, log_process, log_source, log_thread };
+export type { log_exception, log_exception_frame, log_line, log_process, log_source, log_thread };