Repository: dask/distributed

Issue:
Logging update breakages
https://github.com/dask/distributed/pull/4909 introduced some internal refactoring to our cluster logging system. Some of the changes (e.g. the `Logs` -> `MultiLogs` rename) can lead to errors for users using the previous `Log` and `Logs` objects from `distributed.utils` (xref https://github.com/dask/dask-kubernetes/issues/344). Additionally, `Cluster.get_logs()` now returns a dictionary with a different structure (a `dict` of `tuple`s instead of a `dict` with `str`s) which can lead to breakages for user code which parses the output of `Cluster.get_logs()`.

Are there changes we can make to keep the functionality introduced by https://github.com/dask/distributed/pull/4909, but do so in a backward-compatible way? 

cc @charlesbluca @jacobtomlinson 


Discussion:
I think the easiest fix here would be to return to the original behavior of `Log`, `Logs`, and `Cluster.get_logs`, and just refactor how we're wrapping the logs' inline styling. My only reasoning behind the refactor was to directly map the output of `Node.get_logs` to the HTML reprs, but I was under the assumption that these utils weren't being used downstream.

I can start working on a fix now

Resolving patch:
diff --git a/distributed/dashboard/components/scheduler.py b/distributed/dashboard/components/scheduler.py
--- a/distributed/dashboard/components/scheduler.py
+++ b/distributed/dashboard/components/scheduler.py
@@ -70,7 +70,7 @@
 from distributed.diagnostics.task_stream import color_of as ts_color_of
 from distributed.diagnostics.task_stream import colors as ts_color_lookup
 from distributed.metrics import time
-from distributed.utils import Logs, format_time, log_errors, parse_timedelta
+from distributed.utils import Log, format_time, log_errors, parse_timedelta
 
 if dask.config.get("distributed.dashboard.export-tool"):
     from distributed.dashboard.export_tool import ExportTool
@@ -2165,7 +2165,9 @@ def update(self):
 
 class SchedulerLogs:
     def __init__(self, scheduler):
-        logs = Logs(scheduler.get_logs())._repr_html_()
+        logs = Log(
+            "\n".join(line for level, line in scheduler.get_logs())
+        )._repr_html_()
 
         self.root = Div(text=logs)
 
diff --git a/distributed/deploy/cluster.py b/distributed/deploy/cluster.py
--- a/distributed/deploy/cluster.py
+++ b/distributed/deploy/cluster.py
@@ -15,7 +15,8 @@
 from ..core import Status
 from ..objects import SchedulerInfo
 from ..utils import (
-    MultiLogs,
+    Log,
+    Logs,
     format_dashboard_link,
     log_errors,
     parse_timedelta,
@@ -209,19 +210,21 @@ def _log(self, log):
             print(log)
 
     async def _get_logs(self, cluster=True, scheduler=True, workers=True):
-        logs = MultiLogs()
+        logs = Logs()
 
         if cluster:
-            logs["Cluster"] = self._cluster_manager_logs
+            logs["Cluster"] = Log(
+                "\n".join(line[1] for line in self._cluster_manager_logs)
+            )
 
         if scheduler:
             L = await self.scheduler_comm.get_logs()
-            logs["Scheduler"] = L
+            logs["Scheduler"] = Log("\n".join(line for level, line in L))
 
         if workers:
             d = await self.scheduler_comm.worker_logs(workers=workers)
             for k, v in d.items():
-                logs[k] = v
+                logs[k] = Log("\n".join(line for level, line in v))
 
         return logs
 
diff --git a/distributed/utils.py b/distributed/utils.py
--- a/distributed/utils.py
+++ b/distributed/utils.py
@@ -1269,8 +1269,8 @@ def parse_ports(port):
 is_coroutine_function = iscoroutinefunction
 
 
-class Log(tuple):
-    """A container for a single log entry"""
+class Log(str):
+    """A container for newline-delimited string of log entries"""
 
     level_styles = {
         "WARNING": "font-weight: bold; color: orange;",
@@ -1279,34 +1279,34 @@ class Log(tuple):
     }
 
     def _repr_html_(self):
-        level, message = self
-
-        style = "font-family: monospace; margin: 0;"
-        style += self.level_styles.get(level, "")
-
-        return '<p style="{style}">{message}</p>'.format(
-            style=html.escape(style),
-            message=html.escape(message),
-        )
-
-
-class Logs(list):
-    """A container for a list of log entries"""
+        logs_html = []
+        for message in self.split("\n"):
+            style = "font-family: monospace; margin: 0;"
+            for level in self.level_styles:
+                if level in message:
+                    style += self.level_styles[level]
+                    break
+
+            logs_html.append(
+                '<p style="{style}">{message}</p>'.format(
+                    style=html.escape(style),
+                    message=html.escape(message),
+                )
+            )
 
-    def _repr_html_(self):
-        return "\n".join(Log(entry)._repr_html_() for entry in self)
+        return "\n".join(logs_html)
 
 
-class MultiLogs(dict):
-    """A container for a dict mapping strings to lists of log entries"""
+class Logs(dict):
+    """A container for a dict mapping names to strings of log entries"""
 
     def _repr_html_(self):
         summaries = [
             "<details>\n"
             "<summary style='display:list-item'>{title}</summary>\n"
-            "{logs}\n"
-            "</details>".format(title=title, logs=Logs(entries)._repr_html_())
-            for title, entries in sorted(self.items())
+            "{log}\n"
+            "</details>".format(title=title, log=log._repr_html_())
+            for title, log in sorted(self.items())
         ]
         return "\n".join(summaries)
 


Test patch:
diff --git a/distributed/deploy/tests/test_spec_cluster.py b/distributed/deploy/tests/test_spec_cluster.py
--- a/distributed/deploy/tests/test_spec_cluster.py
+++ b/distributed/deploy/tests/test_spec_cluster.py
@@ -287,6 +287,8 @@ async def test_logs(cleanup):
         await cluster
 
         logs = await cluster.get_logs()
+        assert isinstance(logs, dict)
+        assert all(isinstance(log, str) for log in logs)
         assert is_valid_xml("<div>" + logs._repr_html_() + "</div>")
         assert "Scheduler" in logs
         for worker in cluster.scheduler.workers:
diff --git a/distributed/tests/test_utils.py b/distributed/tests/test_utils.py
--- a/distributed/tests/test_utils.py
+++ b/distributed/tests/test_utils.py
@@ -19,8 +19,9 @@
 from distributed.utils import (
     LRU,
     All,
+    Log,
+    Logs,
     LoopRunner,
-    MultiLogs,
     TimeoutError,
     _maybe_complex,
     deprecated,
@@ -549,7 +550,10 @@ def test_format_bytes_compat():
 
 
 def test_logs():
-    d = MultiLogs({"123": [("INFO", "Hello")], "456": [("INFO", "World!")]})
+    log = Log("Hello")
+    assert isinstance(log, str)
+    d = Logs({"123": log, "456": Log("World!")})
+    assert isinstance(d, dict)
     text = d._repr_html_()
     assert is_valid_xml("<div>" + text + "</div>")
     assert "Hello" in text