2023-01-12 22:21:18

by Ian Rogers

[permalink] [raw]
Subject: [PATCH v2] perf script flamegraph: Avoid d3-flame-graph package dependency

Currently flame graph generation requires a d3-flame-graph template to
be installed. Unfortunately this is hard to come by for things like
Debian [1]. If the template isn't installed then ask if it should be
downloaded from jsdelivr CDN. The downloaded HTML file is validated
against an md5sum. If the download fails, generate a minimal flame
graph with the javascript coming from links to jsdelivr CDN.

v2. Change the warning to a prompt about downloading and add the
--allow-download command line flag. Add an md5sum check for the
downloaded HTML.

[1] https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=996839

Signed-off-by: Ian Rogers <[email protected]>
---
tools/perf/scripts/python/flamegraph.py | 96 +++++++++++++++++++------
1 file changed, 74 insertions(+), 22 deletions(-)

diff --git a/tools/perf/scripts/python/flamegraph.py b/tools/perf/scripts/python/flamegraph.py
index b6af1dd5f816..086619053e4e 100755
--- a/tools/perf/scripts/python/flamegraph.py
+++ b/tools/perf/scripts/python/flamegraph.py
@@ -19,12 +19,34 @@
# pylint: disable=missing-function-docstring

from __future__ import print_function
-import sys
-import os
-import io
import argparse
+import hashlib
+import io
import json
+import os
import subprocess
+import sys
+import urllib.request
+
+minimal_html = """<head>
+ <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.css">
+</head>
+<body>
+ <div id="chart"></div>
+ <script type="text/javascript" src="https://d3js.org/d3.v7.js"></script>
+ <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.min.js"></script>
+ <script type="text/javascript">
+ const stacks = [/** @flamegraph_json **/];
+ // Note, options is unused.
+ const options = [/** @options_json **/];
+
+ var chart = flamegraph();
+ d3.select("#chart")
+ .datum(stacks[0])
+ .call(chart);
+ </script>
+</body>
+"""

# pylint: disable=too-few-public-methods
class Node:
@@ -50,16 +72,6 @@ class FlameGraphCLI:
self.args = args
self.stack = Node("all", "root")

- if self.args.format == "html" and \
- not os.path.isfile(self.args.template):
- print("Flame Graph template {} does not exist. Please install "
- "the js-d3-flame-graph (RPM) or libjs-d3-flame-graph (deb) "
- "package, specify an existing flame graph template "
- "(--template PATH) or another output format "
- "(--format FORMAT).".format(self.args.template),
- file=sys.stderr)
- sys.exit(1)
-
@staticmethod
def get_libtype_from_dso(dso):
"""
@@ -128,16 +140,52 @@ class FlameGraphCLI:
}
options_json = json.dumps(options)

+ template_md5sum = None
+ if self.args.format == "html":
+ if os.path.isfile(self.args.template):
+ template = f"file://{self.args.template}"
+ else:
+ if not self.args.allow_download:
+ print(f"""Warning: Flame Graph template '{self.args.template}'
+does not exist. To avoid this please install a package such as the
+js-d3-flame-graph or libjs-d3-flame-graph, specify an existing flame
+graph template (--template PATH) or use another output format (--format
+FORMAT).""",
+ file=sys.stderr)
+ s = None
+ while s != "y" and s != "n":
+ s = input("Do you wish to download a template from cdn.jsdelivr.net? (this warning can be suppressed with --allow-download) [yn] ").lower()
+ if s == "n":
+ quit()
+ template = "https://cdn.jsdelivr.net/npm/[email protected]/dist/templates/d3-flamegraph-base.html"
+ template_md5sum = "143e0d06ba69b8370b9848dcd6ae3f36"
+
try:
- with io.open(self.args.template, encoding="utf-8") as template:
- output_str = (
- template.read()
- .replace("/** @options_json **/", options_json)
- .replace("/** @flamegraph_json **/", stacks_json)
- )
- except IOError as err:
- print("Error reading template file: {}".format(err), file=sys.stderr)
- sys.exit(1)
+ with urllib.request.urlopen(template) as template:
+ output_str = "".join([
+ l.decode("utf-8") for l in template.readlines()
+ ])
+ except Exception as err:
+ print(f"Error reading template {template}: {err}\n"
+ "a minimal flame graph will be generated", file=sys.stderr)
+ output_str = minimal_html
+ template_md5sum = None
+
+ if template_md5sum:
+ download_md5sum = hashlib.md5(output_str.encode("utf-8")).hexdigest()
+ if download_md5sum != template_md5sum:
+ s = None
+ while s != "y" and s != "n":
+ s = input(f"""Unexpected template md5sum.
+{download_md5sum} != {template_md5sum}, for:
+{output_str}
+continue?[yn] """).lower()
+ if s == "n":
+ quit()
+
+ output_str = output_str.replace("/** @options_json **/", options_json)
+ output_str = output_str.replace("/** @flamegraph_json **/", stacks_json)
+
output_fn = self.args.output or "flamegraph.html"
else:
output_str = stacks_json
@@ -172,6 +220,10 @@ if __name__ == "__main__":
choices=["blue-green", "orange"])
parser.add_argument("-i", "--input",
help=argparse.SUPPRESS)
+ parser.add_argument("--allow-download",
+ default=False,
+ action="store_true",
+ help="allow unprompted downloading of HTML template")

cli_args = parser.parse_args()
cli = FlameGraphCLI(cli_args)
--
2.39.0.314.g84b9a713c41-goog


2023-01-17 15:34:34

by Andreas Gerstmayr

[permalink] [raw]
Subject: Re: [PATCH v2] perf script flamegraph: Avoid d3-flame-graph package dependency

On 12.01.23 23:00, Ian Rogers wrote:
> Currently flame graph generation requires a d3-flame-graph template to
> be installed. Unfortunately this is hard to come by for things like
> Debian [1]. If the template isn't installed then ask if it should be
> downloaded from jsdelivr CDN. The downloaded HTML file is validated
> against an md5sum. If the download fails, generate a minimal flame
> graph with the javascript coming from links to jsdelivr CDN.
>
> v2. Change the warning to a prompt about downloading and add the
> --allow-download command line flag. Add an md5sum check for the
> downloaded HTML.
>
> [1] https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=996839
>
> Signed-off-by: Ian Rogers <[email protected]>

Thank you for the changes. I've tested v2 with:

* d3-flame-graph package installed
* template not installed, download template from jsdelivr
* download from jsdelivr, with --allow-download
* invalid checksum
* unreachable jsdelivr, creating a minimal template

Everything works great except when I'm invoking "perf script flamegraph
-a -F 99 sleep 10" (combining perf report + perf script):

[root@agerstmayr-thinkpad tmp]# perf script flamegraph -a -F 99 sleep 10
------------------------------------------------------------
[...]
------------------------------------------------------------
Warning: Flame Graph template
'/usr/share/d3-flame-graph/d3-flamegraph-base.html'
does not exist. To avoid this please install a package such as the
js-d3-flame-graph or libjs-d3-flame-graph, specify an existing flame
graph template (--template PATH) or use another output format (--format
FORMAT).
Do you wish to download a template from cdn.jsdelivr.net? (this warning
can be suppressed with --allow-download) [yn] Traceback (most recent
call last):
File "/usr/libexec/perf-core/scripts/python/flamegraph.py", line 157,
in trace_end
s = input("Do you wish to download a template from
cdn.jsdelivr.net? (this warning can be suppressed with --allow-download)
[yn] ").lower()

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
OSError: [Errno 9] Bad file descriptor
Fatal Python error: handler_call_die: problem in Python trace event handler
Python runtime state: initialized

Current thread 0x00007ff4053a3cc0 (most recent call first):
<no Python frame>

Extension modules: systemd._journal, systemd._reader, systemd.id128
(total: 3)
/usr/libexec/perf-core/scripts/python/bin/flamegraph-report: line 3:
2135491 Aborted (core dumped) perf script -s
"$PERF_EXEC_PATH"/scripts/python/flamegraph.py -- "$@"


iirc when running "perf script flamegraph" the perf.data gets piped to
stdin of the flamegraph script, so we can't ask the user in this case.
You can check this condition with `self.args.input == "-". Not sure
what's the best action in this case, maybe just exit?


Cheers,
Andreas


> ---
> tools/perf/scripts/python/flamegraph.py | 96 +++++++++++++++++++------
> 1 file changed, 74 insertions(+), 22 deletions(-)
>
> diff --git a/tools/perf/scripts/python/flamegraph.py b/tools/perf/scripts/python/flamegraph.py
> index b6af1dd5f816..086619053e4e 100755
> --- a/tools/perf/scripts/python/flamegraph.py
> +++ b/tools/perf/scripts/python/flamegraph.py
> @@ -19,12 +19,34 @@
> # pylint: disable=missing-function-docstring
>
> from __future__ import print_function
> -import sys
> -import os
> -import io
> import argparse
> +import hashlib
> +import io
> import json
> +import os
> import subprocess
> +import sys
> +import urllib.request
> +
> +minimal_html = """<head>
> + <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.css">
> +</head>
> +<body>
> + <div id="chart"></div>
> + <script type="text/javascript" src="https://d3js.org/d3.v7.js"></script>
> + <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.min.js"></script>
> + <script type="text/javascript">
> + const stacks = [/** @flamegraph_json **/];
> + // Note, options is unused.
> + const options = [/** @options_json **/];
> +
> + var chart = flamegraph();
> + d3.select("#chart")
> + .datum(stacks[0])
> + .call(chart);
> + </script>
> +</body>
> +"""
>
> # pylint: disable=too-few-public-methods
> class Node:
> @@ -50,16 +72,6 @@ class FlameGraphCLI:
> self.args = args
> self.stack = Node("all", "root")
>
> - if self.args.format == "html" and \
> - not os.path.isfile(self.args.template):
> - print("Flame Graph template {} does not exist. Please install "
> - "the js-d3-flame-graph (RPM) or libjs-d3-flame-graph (deb) "
> - "package, specify an existing flame graph template "
> - "(--template PATH) or another output format "
> - "(--format FORMAT).".format(self.args.template),
> - file=sys.stderr)
> - sys.exit(1)
> -
> @staticmethod
> def get_libtype_from_dso(dso):
> """
> @@ -128,16 +140,52 @@ class FlameGraphCLI:
> }
> options_json = json.dumps(options)
>
> + template_md5sum = None
> + if self.args.format == "html":
> + if os.path.isfile(self.args.template):
> + template = f"file://{self.args.template}"
> + else:
> + if not self.args.allow_download:
> + print(f"""Warning: Flame Graph template '{self.args.template}'
> +does not exist. To avoid this please install a package such as the
> +js-d3-flame-graph or libjs-d3-flame-graph, specify an existing flame
> +graph template (--template PATH) or use another output format (--format
> +FORMAT).""",
> + file=sys.stderr)
> + s = None
> + while s != "y" and s != "n":
> + s = input("Do you wish to download a template from cdn.jsdelivr.net? (this warning can be suppressed with --allow-download) [yn] ").lower()
> + if s == "n":
> + quit()
> + template = "https://cdn.jsdelivr.net/npm/[email protected]/dist/templates/d3-flamegraph-base.html"
> + template_md5sum = "143e0d06ba69b8370b9848dcd6ae3f36"
> +
> try:
> - with io.open(self.args.template, encoding="utf-8") as template:
> - output_str = (
> - template.read()
> - .replace("/** @options_json **/", options_json)
> - .replace("/** @flamegraph_json **/", stacks_json)
> - )
> - except IOError as err:
> - print("Error reading template file: {}".format(err), file=sys.stderr)
> - sys.exit(1)
> + with urllib.request.urlopen(template) as template:
> + output_str = "".join([
> + l.decode("utf-8") for l in template.readlines()
> + ])
> + except Exception as err:
> + print(f"Error reading template {template}: {err}\n"
> + "a minimal flame graph will be generated", file=sys.stderr)
> + output_str = minimal_html
> + template_md5sum = None
> +
> + if template_md5sum:
> + download_md5sum = hashlib.md5(output_str.encode("utf-8")).hexdigest()
> + if download_md5sum != template_md5sum:
> + s = None
> + while s != "y" and s != "n":
> + s = input(f"""Unexpected template md5sum.
> +{download_md5sum} != {template_md5sum}, for:
> +{output_str}
> +continue?[yn] """).lower()
> + if s == "n":
> + quit()
> +
> + output_str = output_str.replace("/** @options_json **/", options_json)
> + output_str = output_str.replace("/** @flamegraph_json **/", stacks_json)
> +
> output_fn = self.args.output or "flamegraph.html"
> else:
> output_str = stacks_json
> @@ -172,6 +220,10 @@ if __name__ == "__main__":
> choices=["blue-green", "orange"])
> parser.add_argument("-i", "--input",
> help=argparse.SUPPRESS)
> + parser.add_argument("--allow-download",
> + default=False,
> + action="store_true",
> + help="allow unprompted downloading of HTML template")
>
> cli_args = parser.parse_args()
> cli = FlameGraphCLI(cli_args)

--
Red Hat Austria GmbH, Registered seat: A-1200 Vienna, Millennium Tower,
26.floor, Handelskai 94-96, Austria
Commercial register: Commercial Court Vienna, FN 479668w

2023-01-18 08:14:02

by Ian Rogers

[permalink] [raw]
Subject: Re: [PATCH v2] perf script flamegraph: Avoid d3-flame-graph package dependency

On Tue, Jan 17, 2023 at 7:17 AM Andreas Gerstmayr <[email protected]> wrote:
>
> On 12.01.23 23:00, Ian Rogers wrote:
> > Currently flame graph generation requires a d3-flame-graph template to
> > be installed. Unfortunately this is hard to come by for things like
> > Debian [1]. If the template isn't installed then ask if it should be
> > downloaded from jsdelivr CDN. The downloaded HTML file is validated
> > against an md5sum. If the download fails, generate a minimal flame
> > graph with the javascript coming from links to jsdelivr CDN.
> >
> > v2. Change the warning to a prompt about downloading and add the
> > --allow-download command line flag. Add an md5sum check for the
> > downloaded HTML.
> >
> > [1] https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=996839
> >
> > Signed-off-by: Ian Rogers <[email protected]>
>
> Thank you for the changes. I've tested v2 with:
>
> * d3-flame-graph package installed
> * template not installed, download template from jsdelivr
> * download from jsdelivr, with --allow-download
> * invalid checksum
> * unreachable jsdelivr, creating a minimal template
>
> Everything works great except when I'm invoking "perf script flamegraph
> -a -F 99 sleep 10" (combining perf report + perf script):
>
> [root@agerstmayr-thinkpad tmp]# perf script flamegraph -a -F 99 sleep 10
> ------------------------------------------------------------
> [...]
> ------------------------------------------------------------
> Warning: Flame Graph template
> '/usr/share/d3-flame-graph/d3-flamegraph-base.html'
> does not exist. To avoid this please install a package such as the
> js-d3-flame-graph or libjs-d3-flame-graph, specify an existing flame
> graph template (--template PATH) or use another output format (--format
> FORMAT).
> Do you wish to download a template from cdn.jsdelivr.net? (this warning
> can be suppressed with --allow-download) [yn] Traceback (most recent
> call last):
> File "/usr/libexec/perf-core/scripts/python/flamegraph.py", line 157,
> in trace_end
> s = input("Do you wish to download a template from
> cdn.jsdelivr.net? (this warning can be suppressed with --allow-download)
> [yn] ").lower()
>
> ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
> OSError: [Errno 9] Bad file descriptor
> Fatal Python error: handler_call_die: problem in Python trace event handler
> Python runtime state: initialized
>
> Current thread 0x00007ff4053a3cc0 (most recent call first):
> <no Python frame>
>
> Extension modules: systemd._journal, systemd._reader, systemd.id128
> (total: 3)
> /usr/libexec/perf-core/scripts/python/bin/flamegraph-report: line 3:
> 2135491 Aborted (core dumped) perf script -s
> "$PERF_EXEC_PATH"/scripts/python/flamegraph.py -- "$@"
>
>
> iirc when running "perf script flamegraph" the perf.data gets piped to
> stdin of the flamegraph script, so we can't ask the user in this case.
> You can check this condition with `self.args.input == "-". Not sure
> what's the best action in this case, maybe just exit?

Thanks Andreas,

There's no way to handle command line arguments to the script in
"live" mode and so I sent a v3 where the script warns and then
quit()s. The only other option would have been to assume downloading,
and we'd agreed to avoid that. Hopefully v3 is in the right shape now.

Thanks again,
Ian

>
> Cheers,
> Andreas
>
>
> > ---
> > tools/perf/scripts/python/flamegraph.py | 96 +++++++++++++++++++------
> > 1 file changed, 74 insertions(+), 22 deletions(-)
> >
> > diff --git a/tools/perf/scripts/python/flamegraph.py b/tools/perf/scripts/python/flamegraph.py
> > index b6af1dd5f816..086619053e4e 100755
> > --- a/tools/perf/scripts/python/flamegraph.py
> > +++ b/tools/perf/scripts/python/flamegraph.py
> > @@ -19,12 +19,34 @@
> > # pylint: disable=missing-function-docstring
> >
> > from __future__ import print_function
> > -import sys
> > -import os
> > -import io
> > import argparse
> > +import hashlib
> > +import io
> > import json
> > +import os
> > import subprocess
> > +import sys
> > +import urllib.request
> > +
> > +minimal_html = """<head>
> > + <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.css">
> > +</head>
> > +<body>
> > + <div id="chart"></div>
> > + <script type="text/javascript" src="https://d3js.org/d3.v7.js"></script>
> > + <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-flamegraph.min.js"></script>
> > + <script type="text/javascript">
> > + const stacks = [/** @flamegraph_json **/];
> > + // Note, options is unused.
> > + const options = [/** @options_json **/];
> > +
> > + var chart = flamegraph();
> > + d3.select("#chart")
> > + .datum(stacks[0])
> > + .call(chart);
> > + </script>
> > +</body>
> > +"""
> >
> > # pylint: disable=too-few-public-methods
> > class Node:
> > @@ -50,16 +72,6 @@ class FlameGraphCLI:
> > self.args = args
> > self.stack = Node("all", "root")
> >
> > - if self.args.format == "html" and \
> > - not os.path.isfile(self.args.template):
> > - print("Flame Graph template {} does not exist. Please install "
> > - "the js-d3-flame-graph (RPM) or libjs-d3-flame-graph (deb) "
> > - "package, specify an existing flame graph template "
> > - "(--template PATH) or another output format "
> > - "(--format FORMAT).".format(self.args.template),
> > - file=sys.stderr)
> > - sys.exit(1)
> > -
> > @staticmethod
> > def get_libtype_from_dso(dso):
> > """
> > @@ -128,16 +140,52 @@ class FlameGraphCLI:
> > }
> > options_json = json.dumps(options)
> >
> > + template_md5sum = None
> > + if self.args.format == "html":
> > + if os.path.isfile(self.args.template):
> > + template = f"file://{self.args.template}"
> > + else:
> > + if not self.args.allow_download:
> > + print(f"""Warning: Flame Graph template '{self.args.template}'
> > +does not exist. To avoid this please install a package such as the
> > +js-d3-flame-graph or libjs-d3-flame-graph, specify an existing flame
> > +graph template (--template PATH) or use another output format (--format
> > +FORMAT).""",
> > + file=sys.stderr)
> > + s = None
> > + while s != "y" and s != "n":
> > + s = input("Do you wish to download a template from cdn.jsdelivr.net? (this warning can be suppressed with --allow-download) [yn] ").lower()
> > + if s == "n":
> > + quit()
> > + template = "https://cdn.jsdelivr.net/npm/[email protected]/dist/templates/d3-flamegraph-base.html"
> > + template_md5sum = "143e0d06ba69b8370b9848dcd6ae3f36"
> > +
> > try:
> > - with io.open(self.args.template, encoding="utf-8") as template:
> > - output_str = (
> > - template.read()
> > - .replace("/** @options_json **/", options_json)
> > - .replace("/** @flamegraph_json **/", stacks_json)
> > - )
> > - except IOError as err:
> > - print("Error reading template file: {}".format(err), file=sys.stderr)
> > - sys.exit(1)
> > + with urllib.request.urlopen(template) as template:
> > + output_str = "".join([
> > + l.decode("utf-8") for l in template.readlines()
> > + ])
> > + except Exception as err:
> > + print(f"Error reading template {template}: {err}\n"
> > + "a minimal flame graph will be generated", file=sys.stderr)
> > + output_str = minimal_html
> > + template_md5sum = None
> > +
> > + if template_md5sum:
> > + download_md5sum = hashlib.md5(output_str.encode("utf-8")).hexdigest()
> > + if download_md5sum != template_md5sum:
> > + s = None
> > + while s != "y" and s != "n":
> > + s = input(f"""Unexpected template md5sum.
> > +{download_md5sum} != {template_md5sum}, for:
> > +{output_str}
> > +continue?[yn] """).lower()
> > + if s == "n":
> > + quit()
> > +
> > + output_str = output_str.replace("/** @options_json **/", options_json)
> > + output_str = output_str.replace("/** @flamegraph_json **/", stacks_json)
> > +
> > output_fn = self.args.output or "flamegraph.html"
> > else:
> > output_str = stacks_json
> > @@ -172,6 +220,10 @@ if __name__ == "__main__":
> > choices=["blue-green", "orange"])
> > parser.add_argument("-i", "--input",
> > help=argparse.SUPPRESS)
> > + parser.add_argument("--allow-download",
> > + default=False,
> > + action="store_true",
> > + help="allow unprompted downloading of HTML template")
> >
> > cli_args = parser.parse_args()
> > cli = FlameGraphCLI(cli_args)
>
> --
> Red Hat Austria GmbH, Registered seat: A-1200 Vienna, Millennium Tower,
> 26.floor, Handelskai 94-96, Austria
> Commercial register: Commercial Court Vienna, FN 479668w
>