Repository: locustio/locust

Issue:
Add Log Viewer to Modern UI
### Prerequisites

- [X] I am using [the latest version of Locust](https://github.com/locustio/locust/releases/)
- [X] I am suggesting a new feature, not asking a question

### Description

### Proposed Solution

- Expose a new route from `web.py` `/log`, which will return all of the applications logs in JSON format
- Add a new tab to the modern web UI that will fetch and display the logs

### Implementation

There are two possible implementations:
1. Using logfiles:
    - The logs are outputted to a file when the `--logfile` option is set
    - The `/log` resource can then read from this logfile and return the output in JSON format
2. Using a new handler:
    - Add a custom log handler that keeps track of all application logs
    - The `/log` resource would then read from this custom handler

The one complexity will be if we want to display worker logs as well we will need to setup new event handlers so that the worker can report their logs to master


Discussion:
Second option sounds better to me. Perhaps we need to limit the amount of data held in the buffer though (something like 1000 log messages could be a reasonable default)

Worker logs would be nice too of course, but that can be step two.

Resolving patch:
diff --git a/locust/log.py b/locust/log.py
--- a/locust/log.py
+++ b/locust/log.py
@@ -9,6 +9,15 @@
 unhandled_greenlet_exception = False
 
 
+class LogReader(logging.Handler):
+    def __init__(self):
+        super().__init__()
+        self.logs = []
+
+    def emit(self, record):
+        self.logs.append(self.format(record))
+
+
 def setup_logging(loglevel, logfile=None):
     loglevel = loglevel.upper()
 
@@ -32,21 +41,22 @@ def setup_logging(loglevel, logfile=None):
                 "class": "logging.StreamHandler",
                 "formatter": "plain",
             },
+            "log_reader": {"class": "locust.log.LogReader", "formatter": "default"},
         },
         "loggers": {
             "locust": {
-                "handlers": ["console"],
+                "handlers": ["console", "log_reader"],
                 "level": loglevel,
                 "propagate": False,
             },
             "locust.stats_logger": {
-                "handlers": ["console_plain"],
+                "handlers": ["console_plain", "log_reader"],
                 "level": "INFO",
                 "propagate": False,
             },
         },
         "root": {
-            "handlers": ["console"],
+            "handlers": ["console", "log_reader"],
             "level": loglevel,
         },
     }
@@ -58,8 +68,8 @@ def setup_logging(loglevel, logfile=None):
             "filename": logfile,
             "formatter": "default",
         }
-        LOGGING_CONFIG["loggers"]["locust"]["handlers"] = ["file"]
-        LOGGING_CONFIG["root"]["handlers"] = ["file"]
+        LOGGING_CONFIG["loggers"]["locust"]["handlers"] = ["file", "log_reader"]
+        LOGGING_CONFIG["root"]["handlers"] = ["file", "log_reader"]
 
     logging.config.dictConfig(LOGGING_CONFIG)
 
diff --git a/locust/web.py b/locust/web.py
--- a/locust/web.py
+++ b/locust/web.py
@@ -136,6 +136,12 @@ def __init__(
         if not delayed_start:
             self.start()
 
+        @app.errorhandler(Exception)
+        def handle_exception(error):
+            error_message = str(error)
+            logger.log(logging.CRITICAL, error_message)
+            return make_response(error_message, 500)
+
         @app.route("/assets/<path:path>")
         def send_assets(path):
             webui_build_path = self.webui_build_path
@@ -474,6 +480,20 @@ def tasks() -> Dict[str, Dict[str, Dict[str, float]]]:
             }
             return task_data
 
+        @app.route("/logs")
+        @self.auth_required_if_enabled
+        def logs():
+            log_reader_handler = [
+                handler for handler in logging.getLogger("root").handlers if handler.name == "log_reader"
+            ]
+
+            if log_reader_handler:
+                logs = log_reader_handler[0].logs
+            else:
+                logs = []
+
+            return jsonify({"logs": logs})
+
     def start(self):
         self.greenlet = gevent.spawn(self.start_server)
         self.greenlet.link_exception(greenlet_exception_handler)


Test patch:
diff --git a/locust/test/test_web.py b/locust/test/test_web.py
--- a/locust/test/test_web.py
+++ b/locust/test/test_web.py
@@ -5,13 +5,13 @@
 import re
 import textwrap
 import traceback
+import logging
 from io import StringIO
 from tempfile import NamedTemporaryFile, TemporaryDirectory
 
 import gevent
 import requests
 from pyquery import PyQuery as pq
-
 import locust
 from locust import constant, LoadTestShape
 from locust.argument_parser import get_parser, parse_options
@@ -21,6 +21,7 @@
 from locust import stats
 from locust.stats import StatsCSVFileWriter
 from locust.web import WebUI
+from locust.log import LogReader
 
 from .mock_locustfile import mock_locustfile
 from .testcases import LocustTestCase
@@ -1005,6 +1006,19 @@ def test_html_stats_report(self):
         self.assertIn("Script: <span>locust.py</span>", str(d))
         self.assertIn("Target Host: <span>http://localhost</span>", str(d))
 
+    def test_logs(self):
+        log_handler = LogReader()
+        log_handler.name = "log_reader"
+        log_handler.setLevel(logging.INFO)
+        logger = logging.getLogger("root")
+        logger.addHandler(log_handler)
+        log_line = "some log info"
+        logger.info(log_line)
+
+        response = requests.get("http://127.0.0.1:%i/logs" % self.web_port)
+
+        self.assertIn(log_line, response.json().get("logs"))
+
 
 class TestWebUIAuth(LocustTestCase):
     def setUp(self):