Blame SOURCES/0015-OCaml-tools-output-messages-into-JSON-for-machine-re.patch

10436e
From 1b29702cefae8a66c83bc84dd15e7b858af3ab47 Mon Sep 17 00:00:00 2001
10436e
From: Pino Toscano <ptoscano@redhat.com>
10436e
Date: Fri, 22 Mar 2019 16:24:25 +0100
10436e
Subject: [PATCH] OCaml tools: output messages into JSON for machine readable
10436e
10436e
When the machine readable mode is enabled, print all the messages
10436e
(progress, info, warning, and errors) also as JSON in the machine
10436e
readable stream: this way, users can easily parse the status of the
10436e
OCaml tool, and report that back.
10436e
10436e
The formatting of the current date time into the RFC 3999 format is done
10436e
in C, because of the lack of OCaml APIs for this.
10436e
10436e
(cherry picked from commit f79129b8dc92470e3a5597daf53c84038bd6859e)
10436e
---
10436e
 .gitignore                                  |   1 +
10436e
 common/mltools/Makefile.am                  |  39 ++++++-
10436e
 common/mltools/parse_tools_messages_test.py | 118 ++++++++++++++++++++
10436e
 common/mltools/test-tools-messages.sh       |  28 +++++
10436e
 common/mltools/tools_messages_tests.ml      |  46 ++++++++
10436e
 common/mltools/tools_utils-c.c              |  51 +++++++++
10436e
 common/mltools/tools_utils.ml               |  16 +++
10436e
 lib/guestfs.pod                             |  19 ++++
10436e
 8 files changed, 316 insertions(+), 2 deletions(-)
10436e
 create mode 100644 common/mltools/parse_tools_messages_test.py
10436e
 create mode 100755 common/mltools/test-tools-messages.sh
10436e
 create mode 100644 common/mltools/tools_messages_tests.ml
10436e
10436e
diff --git a/.gitignore b/.gitignore
10436e
index f2efcdde2..db1dbb7cc 100644
10436e
--- a/.gitignore
10436e
+++ b/.gitignore
10436e
@@ -147,6 +147,7 @@ Makefile.in
10436e
 /common/mltools/JSON_tests
10436e
 /common/mltools/JSON_parser_tests
10436e
 /common/mltools/machine_readable_tests
10436e
+/common/mltools/tools_messages_tests
10436e
 /common/mltools/tools_utils_tests
10436e
 /common/mltools/oUnit-*
10436e
 /common/mlutils/.depend
10436e
diff --git a/common/mltools/Makefile.am b/common/mltools/Makefile.am
10436e
index 37d10e610..ae78b84b7 100644
10436e
--- a/common/mltools/Makefile.am
10436e
+++ b/common/mltools/Makefile.am
10436e
@@ -27,6 +27,8 @@ EXTRA_DIST = \
10436e
 	machine_readable_tests.ml \
10436e
 	test-getopt.sh \
10436e
 	test-machine-readable.sh \
10436e
+	test-tools-messages.sh \
10436e
+	tools_messages_tests.ml \
10436e
 	tools_utils_tests.ml
10436e
 
10436e
 SOURCES_MLI = \
10436e
@@ -45,12 +47,12 @@ SOURCES_MLI = \
10436e
 
10436e
 SOURCES_ML = \
10436e
 	getopt.ml \
10436e
+	JSON.ml \
10436e
 	tools_utils.ml \
10436e
 	URI.ml \
10436e
 	planner.ml \
10436e
 	registry.ml \
10436e
 	regedit.ml \
10436e
-	JSON.ml \
10436e
 	JSON_parser.ml \
10436e
 	curl.ml \
10436e
 	checksums.ml \
10436e
@@ -196,6 +198,15 @@ machine_readable_tests_CPPFLAGS = \
10436e
 machine_readable_tests_BOBJECTS = machine_readable_tests.cmo
10436e
 machine_readable_tests_XOBJECTS = $(machine_readable_tests_BOBJECTS:.cmo=.cmx)
10436e
 
10436e
+tools_messages_tests_SOURCES = dummy.c
10436e
+tools_messages_tests_CPPFLAGS = \
10436e
+	-I. \
10436e
+	-I$(top_builddir) \
10436e
+	-I$(shell $(OCAMLC) -where) \
10436e
+	-I$(top_srcdir)/lib
10436e
+tools_messages_tests_BOBJECTS = tools_messages_tests.cmo
10436e
+tools_messages_tests_XOBJECTS = $(tools_messages_tests_BOBJECTS:.cmo=.cmx)
10436e
+
10436e
 # Can't call the following as <test>_OBJECTS because automake gets confused.
10436e
 if !HAVE_OCAMLOPT
10436e
 tools_utils_tests_THEOBJECTS = $(tools_utils_tests_BOBJECTS)
10436e
@@ -212,6 +223,9 @@ JSON_parser_tests.cmo: OCAMLPACKAGES += $(OCAMLPACKAGES_TESTS)
10436e
 
10436e
 machine_readable_tests_THEOBJECTS = $(machine_readable_tests_BOBJECTS)
10436e
 machine_readable_tests.cmo: OCAMLPACKAGES += $(OCAMLPACKAGES_TESTS)
10436e
+
10436e
+tools_messages_tests_THEOBJECTS = $(tools_messages_tests_tests_BOBJECTS)
10436e
+tools_messages_tests.cmo: OCAMLPACKAGES += $(OCAMLPACKAGES_TESTS)
10436e
 else
10436e
 tools_utils_tests_THEOBJECTS = $(tools_utils_tests_XOBJECTS)
10436e
 tools_utils_tests.cmx: OCAMLPACKAGES += $(OCAMLPACKAGES_TESTS)
10436e
@@ -227,6 +241,9 @@ JSON_parser_tests.cmx: OCAMLPACKAGES += $(OCAMLPACKAGES_TESTS)
10436e
 
10436e
 machine_readable_tests_THEOBJECTS = $(machine_readable_tests_XOBJECTS)
10436e
 machine_readable_tests.cmx: OCAMLPACKAGES += $(OCAMLPACKAGES_TESTS)
10436e
+
10436e
+tools_messages_tests_THEOBJECTS = $(tools_messages_tests_XOBJECTS)
10436e
+tools_messages_tests.cmx: OCAMLPACKAGES += $(OCAMLPACKAGES_TESTS)
10436e
 endif
10436e
 
10436e
 OCAMLLINKFLAGS = \
10436e
@@ -302,14 +319,32 @@ machine_readable_tests_LINK = \
10436e
 	  $(OCAMLPACKAGES) $(OCAMLPACKAGES_TESTS) \
10436e
 	  $(machine_readable_tests_THEOBJECTS) -o $@
10436e
 
10436e
+tools_messages_tests_DEPENDENCIES = \
10436e
+	$(tools_messages_tests_THEOBJECTS) \
10436e
+	../mlstdutils/mlstdutils.$(MLARCHIVE) \
10436e
+	../mlgettext/mlgettext.$(MLARCHIVE) \
10436e
+	../mlpcre/mlpcre.$(MLARCHIVE) \
10436e
+	$(MLTOOLS_CMA) \
10436e
+	$(top_srcdir)/ocaml-link.sh
10436e
+tools_messages_tests_LINK = \
10436e
+	$(top_srcdir)/ocaml-link.sh -cclib '-lutils -lgnu' -- \
10436e
+	  $(OCAMLFIND) $(BEST) $(OCAMLFLAGS) $(OCAMLLINKFLAGS) \
10436e
+	  $(OCAMLPACKAGES) $(OCAMLPACKAGES_TESTS) \
10436e
+	  $(tools_messages_tests_THEOBJECTS) -o $@
10436e
+
10436e
 TESTS_ENVIRONMENT = $(top_builddir)/run --test
10436e
 
10436e
 TESTS = \
10436e
 	test-getopt.sh \
10436e
 	test-machine-readable.sh
10436e
+if HAVE_PYTHON
10436e
+TESTS += \
10436e
+	test-tools-messages.sh
10436e
+endif
10436e
 check_PROGRAMS = \
10436e
 	getopt_tests \
10436e
-	machine_readable_tests
10436e
+	machine_readable_tests \
10436e
+	tools_messages_tests
10436e
 
10436e
 if HAVE_OCAML_PKG_OUNIT
10436e
 check_PROGRAMS += JSON_tests JSON_parser_tests tools_utils_tests
10436e
diff --git a/common/mltools/parse_tools_messages_test.py b/common/mltools/parse_tools_messages_test.py
10436e
new file mode 100644
10436e
index 000000000..9dcd6cae6
10436e
--- /dev/null
10436e
+++ b/common/mltools/parse_tools_messages_test.py
10436e
@@ -0,0 +1,118 @@
10436e
+# Copyright (C) 2019 Red Hat Inc.
10436e
+#
10436e
+# This program is free software; you can redistribute it and/or modify
10436e
+# it under the terms of the GNU General Public License as published by
10436e
+# the Free Software Foundation; either version 2 of the License, or
10436e
+# (at your option) any later version.
10436e
+#
10436e
+# This program is distributed in the hope that it will be useful,
10436e
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
10436e
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10436e
+# GNU General Public License for more details.
10436e
+#
10436e
+# You should have received a copy of the GNU General Public License
10436e
+# along with this program; if not, write to the Free Software
10436e
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
10436e
+
10436e
+import datetime
10436e
+import json
10436e
+import os
10436e
+import sys
10436e
+import unittest
10436e
+
10436e
+exe = "tools_messages_tests"
10436e
+
10436e
+if sys.version_info >= (3, 4):
10436e
+    def set_fd_inheritable(fd):
10436e
+        os.set_inheritable(fd, True)
10436e
+else:
10436e
+    def set_fd_inheritable(fd):
10436e
+        pass
10436e
+
10436e
+
10436e
+if sys.version_info >= (3, 0):
10436e
+    def fdopen(fd, mode):
10436e
+        return open(fd, mode)
10436e
+
10436e
+    def isModuleInstalled(mod):
10436e
+        import importlib
10436e
+        return bool(importlib.util.find_spec(mod))
10436e
+else:
10436e
+    def fdopen(fd, mode):
10436e
+        return os.fdopen(fd, mode)
10436e
+
10436e
+    def isModuleInstalled(mod):
10436e
+        import imp
10436e
+        try:
10436e
+            imp.find_module(mod)
10436e
+            return True
10436e
+        except ImportError:
10436e
+            return False
10436e
+
10436e
+
10436e
+def skipUnlessHasModule(mod):
10436e
+    if not isModuleInstalled(mod):
10436e
+        return unittest.skip("%s not available" % mod)
10436e
+    return lambda func: func
10436e
+
10436e
+
10436e
+def iterload(stream):
10436e
+    dec = json.JSONDecoder()
10436e
+    for line in stream:
10436e
+        yield dec.raw_decode(line)
10436e
+
10436e
+
10436e
+def loadJsonFromCommand(extraargs):
10436e
+    r, w = os.pipe()
10436e
+    set_fd_inheritable(r)
10436e
+    r = fdopen(r, "r")
10436e
+    set_fd_inheritable(w)
10436e
+    w = fdopen(w, "w")
10436e
+    pid = os.fork()
10436e
+    if pid:
10436e
+        w.close()
10436e
+        l = list(iterload(r))
10436e
+        l = [o[0] for o in l]
10436e
+        r.close()
10436e
+        return l
10436e
+    else:
10436e
+        r.close()
10436e
+        args = ["tools_messages_tests",
10436e
+                "--machine-readable=fd:%d" % w.fileno()] + extraargs
10436e
+        os.execvp("./" + exe, args)
10436e
+
10436e
+
10436e
+@skipUnlessHasModule('iso8601')
10436e
+class TestParseToolsMessages(unittest.TestCase):
10436e
+    def check_json(self, json, typ, msg):
10436e
+        import iso8601
10436e
+        # Check the type.
10436e
+        jsontype = json.pop("type")
10436e
+        self.assertEqual(jsontype, typ)
10436e
+        # Check the message.
10436e
+        jsonmsg = json.pop("message")
10436e
+        self.assertEqual(jsonmsg, msg)
10436e
+        # Check the timestamp.
10436e
+        jsonts = json.pop("timestamp")
10436e
+        dt = iso8601.parse_date(jsonts)
10436e
+        now = datetime.datetime.now(dt.tzinfo)
10436e
+        self.assertGreater(now, dt)
10436e
+        # Check there are no more keys left (and thus not previously tested).
10436e
+        self.assertEqual(len(json), 0)
10436e
+
10436e
+    def test_messages(self):
10436e
+        objects = loadJsonFromCommand([])
10436e
+        self.assertEqual(len(objects), 4)
10436e
+        self.check_json(objects[0], "message", "Starting")
10436e
+        self.check_json(objects[1], "info", "An information message")
10436e
+        self.check_json(objects[2], "warning", "Warning: message here")
10436e
+        self.check_json(objects[3], "message", "Finishing")
10436e
+
10436e
+    def test_error(self):
10436e
+        objects = loadJsonFromCommand(["--error"])
10436e
+        self.assertEqual(len(objects), 1)
10436e
+        self.check_json(objects[0], "error", "Error!")
10436e
+
10436e
+
10436e
+if __name__ == '__main__':
10436e
+    unittest.main()
10436e
diff --git a/common/mltools/test-tools-messages.sh b/common/mltools/test-tools-messages.sh
10436e
new file mode 100755
10436e
index 000000000..0e24d6ce9
10436e
--- /dev/null
10436e
+++ b/common/mltools/test-tools-messages.sh
10436e
@@ -0,0 +1,28 @@
10436e
+#!/bin/bash -
10436e
+# libguestfs
10436e
+# Copyright (C) 2019 Red Hat Inc.
10436e
+#
10436e
+# This program is free software; you can redistribute it and/or modify
10436e
+# it under the terms of the GNU General Public License as published by
10436e
+# the Free Software Foundation; either version 2 of the License, or
10436e
+# (at your option) any later version.
10436e
+#
10436e
+# This program is distributed in the hope that it will be useful,
10436e
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
10436e
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10436e
+# GNU General Public License for more details.
10436e
+#
10436e
+# You should have received a copy of the GNU General Public License
10436e
+# along with this program; if not, write to the Free Software
10436e
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
10436e
+
10436e
+# Test the --machine-readable functionality of the module Tools_utils.
10436e
+# See also: machine_readable_tests.ml
10436e
+
10436e
+set -e
10436e
+set -x
10436e
+
10436e
+$TEST_FUNCTIONS
10436e
+skip_if_skipped
10436e
+
10436e
+$PYTHON parse_tools_messages_test.py
10436e
diff --git a/common/mltools/tools_messages_tests.ml b/common/mltools/tools_messages_tests.ml
10436e
new file mode 100644
10436e
index 000000000..d5f9be89b
10436e
--- /dev/null
10436e
+++ b/common/mltools/tools_messages_tests.ml
10436e
@@ -0,0 +1,46 @@
10436e
+(*
10436e
+ * Copyright (C) 2019 Red Hat Inc.
10436e
+ *
10436e
+ * This program is free software; you can redistribute it and/or modify
10436e
+ * it under the terms of the GNU General Public License as published by
10436e
+ * the Free Software Foundation; either version 2 of the License, or
10436e
+ * (at your option) any later version.
10436e
+ *
10436e
+ * This program is distributed in the hope that it will be useful,
10436e
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
10436e
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
10436e
+ * GNU General Public License for more details.
10436e
+ *
10436e
+ * You should have received a copy of the GNU General Public License along
10436e
+ * with this program; if not, write to the Free Software Foundation, Inc.,
10436e
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
10436e
+ *)
10436e
+
10436e
+(* Test the message output for tools of the module Tools_utils.
10436e
+ * The tests are controlled by the test-tools-messages.sh script.
10436e
+ *)
10436e
+
10436e
+open Printf
10436e
+
10436e
+open Std_utils
10436e
+open Tools_utils
10436e
+open Getopt.OptionName
10436e
+
10436e
+let is_error = ref false
10436e
+
10436e
+let args = [
10436e
+  [ L "error" ], Getopt.Set is_error, "Only print the error";
10436e
+]
10436e
+let usage_msg = sprintf "%s: test the message outputs" prog
10436e
+
10436e
+let opthandle = create_standard_options args ~machine_readable:true usage_msg
10436e
+let () =
10436e
+  Getopt.parse opthandle.getopt;
10436e
+
10436e
+  if !is_error then
10436e
+    error "Error!";
10436e
+
10436e
+  message "Starting";
10436e
+  info "An information message";
10436e
+  warning "Warning: message here";
10436e
+  message "Finishing"
10436e
diff --git a/common/mltools/tools_utils-c.c b/common/mltools/tools_utils-c.c
10436e
index c88c95082..b015dcace 100644
10436e
--- a/common/mltools/tools_utils-c.c
10436e
+++ b/common/mltools/tools_utils-c.c
10436e
@@ -23,6 +23,8 @@
10436e
 #include <unistd.h>
10436e
 #include <errno.h>
10436e
 #include <error.h>
10436e
+#include <time.h>
10436e
+#include <string.h>
10436e
 
10436e
 #include <caml/alloc.h>
10436e
 #include <caml/fail.h>
10436e
@@ -37,6 +39,7 @@
10436e
 extern value guestfs_int_mllib_inspect_decrypt (value gv, value gpv, value keysv);
10436e
 extern value guestfs_int_mllib_set_echo_keys (value unitv);
10436e
 extern value guestfs_int_mllib_set_keys_from_stdin (value unitv);
10436e
+extern value guestfs_int_mllib_rfc3999_date_time_string (value unitv);
10436e
 
10436e
 /* Interface with the guestfish inspection and decryption code. */
10436e
 int echo_keys = 0;
10436e
@@ -103,3 +106,51 @@ guestfs_int_mllib_set_keys_from_stdin (value unitv)
10436e
   keys_from_stdin = 1;
10436e
   return Val_unit;
10436e
 }
10436e
+
10436e
+value
10436e
+guestfs_int_mllib_rfc3999_date_time_string (value unitv)
10436e
+{
10436e
+  CAMLparam1 (unitv);
10436e
+  char buf[64];
10436e
+  struct timespec ts;
10436e
+  struct tm tm;
10436e
+  size_t ret;
10436e
+  size_t total = 0;
10436e
+
10436e
+  if (clock_gettime (CLOCK_REALTIME, &ts) == -1)
10436e
+    unix_error (errno, (char *) "clock_gettime", Val_unit);
10436e
+
10436e
+  if (localtime_r (&ts.tv_sec, &tm) == NULL)
10436e
+    unix_error (errno, (char *) "localtime_r", caml_copy_int64 (ts.tv_sec));
10436e
+
10436e
+  /* Sadly strftime does not support nanoseconds, so what we do is:
10436e
+   * - stringify everything before the nanoseconds
10436e
+   * - print the nanoseconds
10436e
+   * - stringify the rest (i.e. the timezone)
10436e
+   * then place ':' between the hours, and the minutes of the
10436e
+   * timezone offset.
10436e
+   */
10436e
+
10436e
+  ret = strftime (buf, sizeof (buf), "%Y-%m-%dT%H:%M:%S.", &tm;;
10436e
+  if (ret == 0)
10436e
+    unix_error (errno, (char *) "strftime", Val_unit);
10436e
+  total += ret;
10436e
+
10436e
+  ret = snprintf (buf + total, sizeof (buf) - total, "%09ld", ts.tv_nsec);
10436e
+  if (ret == 0)
10436e
+    unix_error (errno, (char *) "sprintf", caml_copy_int64 (ts.tv_nsec));
10436e
+  total += ret;
10436e
+
10436e
+  ret = strftime (buf + total, sizeof (buf) - total, "%z", &tm;;
10436e
+  if (ret == 0)
10436e
+    unix_error (errno, (char *) "strftime", Val_unit);
10436e
+  total += ret;
10436e
+
10436e
+  /* Move the timezone minutes one character to the right, moving the
10436e
+   * null character too.
10436e
+   */
10436e
+  memmove (buf + total - 1, buf + total - 2, 3);
10436e
+  buf[total - 2] = ':';
10436e
+
10436e
+  CAMLreturn (caml_copy_string (buf));
10436e
+}
10436e
diff --git a/common/mltools/tools_utils.ml b/common/mltools/tools_utils.ml
10436e
index 35478f39e..de42df600 100644
10436e
--- a/common/mltools/tools_utils.ml
10436e
+++ b/common/mltools/tools_utils.ml
10436e
@@ -32,6 +32,7 @@ and key_store_key =
10436e
 external c_inspect_decrypt : Guestfs.t -> int64 -> (string * key_store_key) list -> unit = "guestfs_int_mllib_inspect_decrypt"
10436e
 external c_set_echo_keys : unit -> unit = "guestfs_int_mllib_set_echo_keys" "noalloc"
10436e
 external c_set_keys_from_stdin : unit -> unit = "guestfs_int_mllib_set_keys_from_stdin" "noalloc"
10436e
+external c_rfc3999_date_time_string : unit -> string = "guestfs_int_mllib_rfc3999_date_time_string"
10436e
 
10436e
 type machine_readable_fn = {
10436e
   pr : 'a. ('a, unit, string, unit) format4 -> 'a;
10436e
@@ -86,12 +87,24 @@ let ansi_magenta ?(chan = stdout) () =
10436e
 let ansi_restore ?(chan = stdout) () =
10436e
   if colours () || istty chan then output_string chan "\x1b[0m"
10436e
 
10436e
+let log_as_json msgtype msg =
10436e
+  match machine_readable () with
10436e
+  | None -> ()
10436e
+  | Some { pr } ->
10436e
+    let json = [
10436e
+      "message", JSON.String msg;
10436e
+      "timestamp", JSON.String (c_rfc3999_date_time_string ());
10436e
+      "type", JSON.String msgtype;
10436e
+    ] in
10436e
+    pr "%s\n" (JSON.string_of_doc ~fmt:JSON.Compact json)
10436e
+
10436e
 (* Timestamped progress messages, used for ordinary messages when not
10436e
  * --quiet.
10436e
  *)
10436e
 let start_t = Unix.gettimeofday ()
10436e
 let message fs =
10436e
   let display str =
10436e
+    log_as_json "message" str;
10436e
     if not (quiet ()) then (
10436e
       let t = sprintf "%.1f" (Unix.gettimeofday () -. start_t) in
10436e
       printf "[%6s] " t;
10436e
@@ -106,6 +119,7 @@ let message fs =
10436e
 (* Error messages etc. *)
10436e
 let error ?(exit_code = 1) fs =
10436e
   let display str =
10436e
+    log_as_json "error" str;
10436e
     let chan = stderr in
10436e
     ansi_red ~chan ();
10436e
     wrap ~chan (sprintf (f_"%s: error: %s") prog str);
10436e
@@ -124,6 +138,7 @@ let error ?(exit_code = 1) fs =
10436e
 
10436e
 let warning fs =
10436e
   let display str =
10436e
+    log_as_json "warning" str;
10436e
     let chan = stdout in
10436e
     ansi_blue ~chan ();
10436e
     wrap ~chan (sprintf (f_"%s: warning: %s") prog str);
10436e
@@ -134,6 +149,7 @@ let warning fs =
10436e
 
10436e
 let info fs =
10436e
   let display str =
10436e
+    log_as_json "info" str;
10436e
     let chan = stdout in
10436e
     ansi_magenta ~chan ();
10436e
     wrap ~chan (sprintf (f_"%s: %s") prog str);
10436e
diff --git a/lib/guestfs.pod b/lib/guestfs.pod
10436e
index f11028466..3c1d635c5 100644
10436e
--- a/lib/guestfs.pod
10436e
+++ b/lib/guestfs.pod
10436e
@@ -3279,6 +3279,25 @@ Some of the tools support a I<--machine-readable> option, which is
10436e
 generally used to make the output more machine friendly, for easier
10436e
 parsing for example.  By default, this output goes to stdout.
10436e
 
10436e
+When using the I<--machine-readable> option, the progress,
10436e
+information, warning, and error messages are also printed in JSON
10436e
+format for easier log tracking.  Thus, it is highly recommended to
10436e
+redirect the machine-readable output to a different stream.  The
10436e
+format of these JSON messages is like the following (actually printed
10436e
+within a single line, below it is indented for readability):
10436e
+
10436e
+ {
10436e
+   "message": "Finishing off",
10436e
+   "timestamp": "2019-03-22T14:46:49.067294446+01:00",
10436e
+   "type": "message"
10436e
+ }
10436e
+
10436e
+C<type> can be: C<message> for progress messages, C<info> for
10436e
+information messages, C<warning> for warning messages, and C<error>
10436e
+for error message.
10436e
+C<timestamp> is the L<RFC 3999|https://www.ietf.org/rfc/rfc3339.txt>
10436e
+timestamp of the message.
10436e
+
10436e
 In addition to that, a subset of these tools support an extra string
10436e
 passed to the I<--machine-readable> option: this string specifies
10436e
 where the machine-readable output will go.
10436e
-- 
10436e
2.26.2
10436e